Nova tela de login | Adaptaçao nova da AppBar | Animções novas
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'login_register/login_sheet.dart';
|
||||
import 'login_register/register_sheet.dart';
|
||||
import 'main.dart' show supabase;
|
||||
import 'widgets/entrance.dart';
|
||||
import 'widgets/tap_bounce.dart';
|
||||
|
||||
const Color _teal = Color(0xFF2F9E94);
|
||||
const Color _pink = Color(0xFFFF55A7);
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -14,199 +20,505 @@ class HomeScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
bool _paused = false;
|
||||
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) {
|
||||
await supabase.auth.signInWithPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
} 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('Usuário não encontrado após criar conta.');
|
||||
}
|
||||
|
||||
unawaited(
|
||||
_persistRegistrationData(
|
||||
uid: user.id,
|
||||
name: name,
|
||||
email: email,
|
||||
).catchError((_) {}),
|
||||
);
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(_friendlyAuthError(e))));
|
||||
} on TimeoutException {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Tempo esgotado. Verifique sua conexão e tente novamente.',
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Erro: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _friendlyAuthError(AuthException e) {
|
||||
switch (e.code) {
|
||||
case 'invalid_credentials':
|
||||
return 'Email ou senha incorretos.';
|
||||
case 'user_not_found':
|
||||
return 'Usuário não encontrado.';
|
||||
case 'email_exists':
|
||||
case 'user_already_exists':
|
||||
return 'Este email já está em uso.';
|
||||
case 'weak_password':
|
||||
return 'Senha fraca. Use pelo menos 6 caracteres.';
|
||||
default:
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Size size = MediaQuery.sizeOf(context);
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: _paused,
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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: 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,
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Check-Teeth Kids',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFFFF55A7),
|
||||
height: 1.0,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Cuidar do sorriso começa aqui.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF2F9E94).withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Acompanhe a saúde oral do seu filho com\ninformação segura e prevenção inteligente.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: Colors.black.withValues(alpha: 0.52),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: size.width * 0.78,
|
||||
child: _PrimaryButton(
|
||||
label: 'Cadastrar',
|
||||
onPressed: _openRegister,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: size.width * 0.78,
|
||||
child: _SecondaryButton(
|
||||
label: 'Entrar',
|
||||
onPressed: _openLogin,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openLogin() async {
|
||||
setState(() => _paused = true);
|
||||
try {
|
||||
await showLoginSheet(context);
|
||||
} finally {
|
||||
if (mounted) setState(() => _paused = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openRegister() async {
|
||||
setState(() => _paused = true);
|
||||
try {
|
||||
await showRegisterSheet(context);
|
||||
} finally {
|
||||
if (mounted) setState(() => _paused = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SecondaryButton extends StatelessWidget {
|
||||
const _SecondaryButton({required this.label, required this.onPressed});
|
||||
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 String label;
|
||||
final VoidCallback onPressed;
|
||||
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) {
|
||||
const Color teal = Color(0xFF2F9E94);
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: teal,
|
||||
side: const BorderSide(color: teal, width: 1.6),
|
||||
shape: const StadiumBorder(),
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.5),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w800, fontSize: 15),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Text(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrimaryButton extends StatelessWidget {
|
||||
const _PrimaryButton({required this.label, required this.onPressed});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color teal = const Color(0xFF2F9E94);
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
style:
|
||||
FilledButton.styleFrom(
|
||||
backgroundColor: teal,
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
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: 'Digite seu nome',
|
||||
icon: Icons.person_outline_rounded,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (v) {
|
||||
final value = (v ?? '').trim();
|
||||
if (value.isEmpty) return 'Informe seu nome';
|
||||
if (value.length < 2) return 'Nome muito curto';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
_AuthTextField(
|
||||
controller: emailController,
|
||||
hintText: 'Digite seu email',
|
||||
icon: Icons.mail_outline_rounded,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (v) {
|
||||
final value = (v ?? '').trim();
|
||||
if (value.isEmpty) return 'Informe seu email';
|
||||
if (!value.contains('@')) return 'Email inválido';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_AuthTextField(
|
||||
controller: passwordController,
|
||||
hintText: 'Digite sua senha',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
validator: (v) {
|
||||
final value = v ?? '';
|
||||
if (value.isEmpty) return 'Informe sua senha';
|
||||
if (value.length < 6) return 'Mínimo de 6 caracteres';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TapBounce(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
style:
|
||||
FilledButton.styleFrom(
|
||||
backgroundColor: _teal,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).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: onPressed,
|
||||
child: Text(label),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final IconData icon;
|
||||
final FormFieldValidator<String> validator;
|
||||
final bool obscureText;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
|
||||
@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,
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
@@ -12,7 +13,10 @@ import 'quiz/quiz1.dart';
|
||||
import 'quiz/quiz_prefs.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
import 'screens/video_screen.dart';
|
||||
import 'widgets/animated_nav_icon.dart';
|
||||
import 'widgets/app_dialogs.dart';
|
||||
import 'widgets/entrance.dart';
|
||||
import 'widgets/tap_bounce.dart';
|
||||
|
||||
class LoggedHomeScreen extends StatefulWidget {
|
||||
const LoggedHomeScreen({super.key});
|
||||
@@ -28,6 +32,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
|
||||
static const double _collapsedAppBarHeight = 104;
|
||||
static const double _expandedAppBarHeight = 180;
|
||||
static const double _nameOnlyAppBarHeight = 130;
|
||||
|
||||
int _index = 0;
|
||||
|
||||
@@ -207,8 +212,14 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
final int? score = _lastScore;
|
||||
final int? maxScore = _lastMaxScore;
|
||||
final bool hasScore = score != null && maxScore != null && maxScore > 0;
|
||||
final int percent = hasScore ? ((score / maxScore) * 100).round() : 0;
|
||||
|
||||
final double appBarHeight = _index == 0
|
||||
? _expandedAppBarHeight
|
||||
? (hasScore ? _expandedAppBarHeight : _nameOnlyAppBarHeight)
|
||||
: _collapsedAppBarHeight;
|
||||
final double toolbarHeight = _index == 0 ? kToolbarHeight : appBarHeight;
|
||||
final String title = _index == 0
|
||||
@@ -224,10 +235,6 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
|
||||
final shownName = _cachedUserName;
|
||||
|
||||
final int? score = _lastScore;
|
||||
final int? maxScore = _lastMaxScore;
|
||||
final bool hasScore = score != null && maxScore != null && maxScore > 0;
|
||||
final int percent = hasScore ? ((score / maxScore) * 100).round() : 0;
|
||||
final double bodyTopPadding = _index == 0 ? 0 : 10;
|
||||
|
||||
return Scaffold(
|
||||
@@ -251,47 +258,41 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
opacity: 0.22,
|
||||
child: Transform.scale(scale: 1.25),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: toolbarHeight + 26,
|
||||
child: Center(
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
if (hasScore)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: toolbarHeight + 26,
|
||||
child: Center(
|
||||
child: Text(
|
||||
(_selectedChildName ?? '').trim(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if ((_selectedChildName ?? '').trim().isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: toolbarHeight,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_selectedChildName!.trim(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
fontSize: 14,
|
||||
),
|
||||
children: [
|
||||
if (((_selectedChildName ?? '')
|
||||
.trim()
|
||||
.isNotEmpty))
|
||||
TextSpan(text: _selectedChildName!.trim()),
|
||||
if (((_selectedChildName ?? '')
|
||||
.trim()
|
||||
.isNotEmpty) &&
|
||||
hasScore)
|
||||
const WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
child: Text(
|
||||
'•',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasScore)
|
||||
TextSpan(text: '$score/$maxScore'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasScore)
|
||||
Positioned(
|
||||
left: 0,
|
||||
@@ -308,63 +309,65 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
child: _index == 0
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 10),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
onTap: () => setState(() => _index = 1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.white.withValues(
|
||||
alpha: 0.25,
|
||||
child: TapBounce(
|
||||
scale: 0.96,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
onTap: () => setState(() => _index = 1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.white
|
||||
.withValues(alpha: 0.25),
|
||||
backgroundImage:
|
||||
(_cachedPhotoUrl ?? '').isNotEmpty
|
||||
? NetworkImage(_cachedPhotoUrl!)
|
||||
: null,
|
||||
child: (_cachedPhotoUrl ?? '').isEmpty
|
||||
? const Icon(
|
||||
Icons.person_rounded,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
backgroundImage:
|
||||
(_cachedPhotoUrl ?? '').isNotEmpty
|
||||
? NetworkImage(_cachedPhotoUrl!)
|
||||
: null,
|
||||
child: (_cachedPhotoUrl ?? '').isEmpty
|
||||
? const Icon(
|
||||
Icons.person_rounded,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_greeting(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.85,
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_greeting(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
fontSize: 12,
|
||||
),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
shownName,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
fontSize: 19,
|
||||
Text(
|
||||
shownName,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
fontSize: 19,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -442,22 +445,35 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: _index,
|
||||
onTap: (i) => setState(() => _index = i),
|
||||
onTap: (i) {
|
||||
if (i == _index) return;
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _index = i);
|
||||
},
|
||||
backgroundColor: const Color(0xFFFFE6F1),
|
||||
selectedItemColor: _teal,
|
||||
unselectedItemColor: Colors.black54,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
items: const [
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_rounded),
|
||||
icon: AnimatedNavIcon(
|
||||
icon: Icons.home_rounded,
|
||||
selected: _index == 0,
|
||||
),
|
||||
label: 'Início',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.person_rounded),
|
||||
icon: AnimatedNavIcon(
|
||||
icon: Icons.person_rounded,
|
||||
selected: _index == 1,
|
||||
),
|
||||
label: 'Perfil',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_rounded),
|
||||
icon: AnimatedNavIcon(
|
||||
icon: Icons.settings_rounded,
|
||||
selected: _index == 2,
|
||||
),
|
||||
label: 'Ajustes',
|
||||
),
|
||||
],
|
||||
@@ -485,7 +501,7 @@ class _RiskArcGauge extends StatelessWidget {
|
||||
width: 120,
|
||||
height: 60,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
@@ -493,29 +509,19 @@ class _RiskArcGauge extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$shown%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
top: 38,
|
||||
left: 6,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$shown%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -630,19 +636,30 @@ class _InicioTab extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_HeroQuizCard(
|
||||
childName: selectedChildName,
|
||||
onStartQuiz: () => _startQuiz(context),
|
||||
FadeSlideIn(
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: _HeroQuizCard(
|
||||
childName: selectedChildName,
|
||||
onStartQuiz: () => _startQuiz(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_VideoLibraryCard(
|
||||
onOpenLibrary: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const VideoScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 90),
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: _VideoLibraryCard(
|
||||
onOpenLibrary: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const VideoScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
@@ -741,7 +758,9 @@ Future<Map<String, dynamic>?> _pickChildSheet(
|
||||
final label = age != null ? '$name • $age anos' : name;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Material(
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: Material(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
@@ -770,6 +789,7 @@ Future<Map<String, dynamic>?> _pickChildSheet(
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -1327,7 +1347,8 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Material(
|
||||
FadeSlideIn(
|
||||
child: Material(
|
||||
elevation: 10,
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -1337,7 +1358,9 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
InkWell(
|
||||
TapBounce(
|
||||
scale: 0.92,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
onTap: _updatingPhoto
|
||||
? null
|
||||
@@ -1415,6 +1438,7 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -1453,6 +1477,7 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
||||
@@ -1554,9 +1579,15 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
].join(' • ');
|
||||
final bool selected = i == selectedIndex;
|
||||
|
||||
return Padding(
|
||||
return FadeSlideIn(
|
||||
delay: Duration(
|
||||
milliseconds: 60 * i.clamp(0, 6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => widget.onChildSelected(
|
||||
i,
|
||||
@@ -1662,47 +1693,53 @@ class _PerfilTabState extends State<_PerfilTab> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
TapBounce(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
onPressed: _addingChild
|
||||
? null
|
||||
: () => _addAnotherChild(context, uid),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Adicionar criança'),
|
||||
),
|
||||
onPressed: _addingChild
|
||||
? null
|
||||
: () => _addAnotherChild(context, uid),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('Adicionar criança'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
SizedBox(
|
||||
height: 46,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFFF55A7),
|
||||
side: const BorderSide(
|
||||
color: Color(0xFFFF55A7),
|
||||
width: 1.4,
|
||||
),
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
TapBounce(
|
||||
child: SizedBox(
|
||||
height: 46,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFFF55A7),
|
||||
side: const BorderSide(
|
||||
color: Color(0xFFFF55A7),
|
||||
width: 1.4,
|
||||
),
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await supabase.auth.signOut();
|
||||
},
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
label: const Text('Sair'),
|
||||
),
|
||||
onPressed: () async {
|
||||
await supabase.auth.signOut();
|
||||
},
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
label: const Text('Sair'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -1844,17 +1881,21 @@ class _AddChildSheetState extends State<_AddChildSheet> {
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w900),
|
||||
child: TapBounce(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
onPressed: _submit,
|
||||
child: const Text('Adicionar'),
|
||||
),
|
||||
onPressed: _submit,
|
||||
child: const Text('Adicionar'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
|
||||
Future<void> showLoginSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: const Color(0xFFFFE6F1),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) => const LoginBottomSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class LoginBottomSheet extends StatefulWidget {
|
||||
const LoginBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
State<LoginBottomSheet> createState() => _LoginBottomSheetState();
|
||||
}
|
||||
|
||||
class _LoginBottomSheetState extends State<LoginBottomSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(18, 6, 18, 18 + bottomInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Entrar',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFFFF55A7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
validator: (v) {
|
||||
final value = (v ?? '').trim();
|
||||
if (value.isEmpty) return 'Informe seu email';
|
||||
if (!value.contains('@')) return 'Email inválido';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(labelText: 'Senha'),
|
||||
validator: (v) {
|
||||
final value = (v ?? '');
|
||||
if (value.isEmpty) return 'Informe sua senha';
|
||||
if (value.length < 6) return 'Mínimo de 6 caracteres';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: TextButton(
|
||||
onPressed: _loading
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w900),
|
||||
),
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Entrar'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
await supabase.auth.signInWithPassword(email: email, password: password);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Login efetuado')),
|
||||
);
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_friendlyAuthError(e))),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erro: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _friendlyAuthError(AuthException e) {
|
||||
switch (e.code) {
|
||||
case 'invalid_credentials':
|
||||
return 'Email ou senha incorretos.';
|
||||
case 'user_not_found':
|
||||
return 'Usuário não encontrado.';
|
||||
default:
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
|
||||
Future<void> showRegisterSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: const Color(0xFFFFE6F1),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) => const RegisterBottomSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class RegisterBottomSheet extends StatefulWidget {
|
||||
const RegisterBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterBottomSheet> createState() => _RegisterBottomSheetState();
|
||||
}
|
||||
|
||||
class _RegisterBottomSheetState extends State<RegisterBottomSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(18, 6, 18, 18 + bottomInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Criar conta',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFFFF55A7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(labelText: 'Nome'),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return 'Informe seu nome';
|
||||
}
|
||||
if (v.trim().length < 2) {
|
||||
return 'Nome muito curto';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
validator: (v) {
|
||||
final value = (v ?? '').trim();
|
||||
if (value.isEmpty) return 'Informe seu email';
|
||||
if (!value.contains('@')) return 'Email inválido';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(labelText: 'Senha'),
|
||||
validator: (v) {
|
||||
final value = (v ?? '');
|
||||
if (value.isEmpty) return 'Informe sua senha';
|
||||
if (value.length < 6) return 'Mínimo de 6 caracteres';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: TextButton(
|
||||
onPressed: _loading
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancelar'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w900),
|
||||
),
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Registrar'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final name = _nameController.text.trim();
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
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('Usuário não encontrado após criar conta.');
|
||||
}
|
||||
|
||||
final uid = user.id;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Fecha o sheet imediatamente após autenticar.
|
||||
// As gravações no banco seguem em background para não travar a UI.
|
||||
Navigator.of(context).pop();
|
||||
|
||||
unawaited(
|
||||
_persistRegistrationData(
|
||||
uid: uid,
|
||||
name: name,
|
||||
email: email,
|
||||
).catchError((_) {}),
|
||||
);
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(_friendlyAuthError(e))));
|
||||
} on TimeoutException {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Tempo esgotado. Verifique sua conexão e tente novamente.',
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Erro: $e')));
|
||||
} finally {
|
||||
if (mounted && _loading) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _friendlyAuthError(AuthException e) {
|
||||
switch (e.code) {
|
||||
case 'email_exists':
|
||||
case 'user_already_exists':
|
||||
return 'Este email já está em uso.';
|
||||
case 'weak_password':
|
||||
return 'Senha fraca. Use pelo menos 6 caracteres.';
|
||||
default:
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class Quiz1Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 1/26',
|
||||
title: 'Quiz 1/25',
|
||||
question: 'O rosto do seu filho/a se parece com o desta imagem?',
|
||||
questionImagePaths: const ['assets/mockup_images/2.jpeg'],
|
||||
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 1
|
||||
@@ -52,7 +52,7 @@ class Quiz2Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 2/26',
|
||||
title: 'Quiz 2/25',
|
||||
question:
|
||||
'A boca do seu filho/a fica habitualmente na posição desta imagem (entreaberta)?',
|
||||
questionImagePaths: const ['assets/mockup_images/4.jpeg'],
|
||||
@@ -92,7 +92,7 @@ class Quiz3Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 3/26',
|
||||
title: 'Quiz 3/25',
|
||||
question: 'O seu filho/a tem olheiras semelhantes às desta imagem?',
|
||||
questionImagePaths: const ['assets/mockup_images/8.jpeg'],
|
||||
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 3
|
||||
@@ -131,7 +131,7 @@ class Quiz4Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 4/26',
|
||||
title: 'Quiz 4/25',
|
||||
question:
|
||||
'Com a boca fechada, o queixo do seu filho/a se parece com o desta imagem?',
|
||||
questionImagePaths: const ['assets/mockup_images/6.jpeg'],
|
||||
@@ -171,7 +171,7 @@ class Quiz5Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 5/26',
|
||||
title: 'Quiz 5/25',
|
||||
question: 'Quantos dentes tem o seu filho/a em cima na boca?',
|
||||
answers: const [],
|
||||
currentScore: currentScore,
|
||||
@@ -194,7 +194,7 @@ class Quiz6Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 6/26',
|
||||
title: 'Quiz 6/25',
|
||||
question: 'Quantos dentes tem o seu filho/a em baixo na boca?',
|
||||
answers: const [],
|
||||
currentScore: currentScore,
|
||||
@@ -217,7 +217,7 @@ class Quiz7Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 7/26',
|
||||
title: 'Quiz 7/25',
|
||||
question: 'A boca do seu filho/a se parece com a desta imagem?',
|
||||
questionImagePaths: const ['assets/mockup_images/14.jpeg'],
|
||||
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 5
|
||||
@@ -256,7 +256,7 @@ class Quiz8Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 8/26',
|
||||
title: 'Quiz 8/25',
|
||||
question:
|
||||
'O frénulo (freio) da língua do seu filho/a se parece com o desta imagem?',
|
||||
questionImagePaths: const ['assets/mockup_images/17.png'],
|
||||
@@ -296,7 +296,7 @@ class Quiz9Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 9/26',
|
||||
title: 'Quiz 9/25',
|
||||
question: 'O seu filho/a tem problemas respiratórios diagnosticados?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -332,7 +332,7 @@ class Quiz10Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 10/26',
|
||||
title: 'Quiz 10/25',
|
||||
question: 'O seu filho/a respira habitualmente pela boca?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -368,7 +368,7 @@ class Quiz11Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 11/26',
|
||||
title: 'Quiz 11/25',
|
||||
question: 'O seu filho/a ressona habitualmente durante a noite?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -404,7 +404,7 @@ class Quiz12Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 12/26',
|
||||
title: 'Quiz 12/25',
|
||||
question: 'O seu filho/a sente habitualmente o nariz "tapado"?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -440,7 +440,7 @@ class Quiz13Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 13/26',
|
||||
title: 'Quiz 13/25',
|
||||
question:
|
||||
'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?',
|
||||
answers: const [
|
||||
@@ -478,7 +478,7 @@ class Quiz14Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 14/26',
|
||||
title: 'Quiz 14/25',
|
||||
question: 'O seu filho/a range os dentes com frequência?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -514,7 +514,7 @@ class Quiz15Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 15/26',
|
||||
title: 'Quiz 15/25',
|
||||
question: 'O seu filho/a habitualmente tem alergias sazonais?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -550,7 +550,7 @@ class Quiz16Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 16/26',
|
||||
title: 'Quiz 16/25',
|
||||
question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -586,7 +586,7 @@ class Quiz17Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 17/26',
|
||||
title: 'Quiz 17/25',
|
||||
question: 'O seu filho/a teve ou costuma ter com frequência otites?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -622,7 +622,7 @@ class Quiz18Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 18/26',
|
||||
title: 'Quiz 18/25',
|
||||
question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -658,7 +658,7 @@ class Quiz19Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 19/26',
|
||||
title: 'Quiz 19/25',
|
||||
question:
|
||||
'O seu filho/a teve ou costuma ter com frequência bronquiolites?',
|
||||
answers: const [
|
||||
@@ -695,7 +695,7 @@ class Quiz20Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 20/26',
|
||||
title: 'Quiz 20/25',
|
||||
question: 'O seu filho/a apresenta dificuldades a mastigar?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -731,7 +731,7 @@ class Quiz21Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 21/26',
|
||||
title: 'Quiz 21/25',
|
||||
question: 'O seu filho/a habitualmente é lento a comer?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -767,7 +767,7 @@ class Quiz22Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 22/26',
|
||||
title: 'Quiz 22/25',
|
||||
question: 'O seu filho/a habitualmente prefere comer alimentos moles?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -803,7 +803,7 @@ class Quiz23Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 23/26',
|
||||
title: 'Quiz 23/25',
|
||||
question: 'Em bebé apenas foi alimentado por biberão?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -839,7 +839,7 @@ class Quiz24Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 24/26',
|
||||
title: 'Quiz 24/25',
|
||||
question: 'O seu filho/a usa ou usou chupeta com frequência?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -875,7 +875,7 @@ class Quiz25Screen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 25/26',
|
||||
title: 'Quiz 25/25',
|
||||
question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
@@ -892,35 +892,6 @@ class Quiz25Screen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
|
||||
builder: (_) => Quiz26Screen(currentScore: nextScore, scopeId: scopeId),
|
||||
),
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
showBackButton: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Quiz 26: Final
|
||||
class Quiz26Screen extends StatelessWidget {
|
||||
const Quiz26Screen({super.key, required this.currentScore, this.scopeId});
|
||||
|
||||
final int currentScore;
|
||||
final String? scopeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuizQuestionScreen(
|
||||
title: 'Quiz 26/26',
|
||||
question: 'Obrigado por completar o questionário!',
|
||||
answers: const [
|
||||
QuizAnswer(
|
||||
title: 'Concluir',
|
||||
description: 'Clique para ver os resultados',
|
||||
weight: 0,
|
||||
),
|
||||
],
|
||||
currentScore: currentScore,
|
||||
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
|
||||
builder: (_) => QuizResultScreen(
|
||||
finalScore: nextScore,
|
||||
@@ -928,6 +899,7 @@ class Quiz26Screen extends StatelessWidget {
|
||||
scopeId: scopeId,
|
||||
),
|
||||
),
|
||||
answerType: QuizAnswerType.yesNo,
|
||||
isFinal: true,
|
||||
showBackButton: true,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../screens/video_screen.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
|
||||
typedef QuizNextBuilder =
|
||||
Route<void> Function(BuildContext context, int nextScore);
|
||||
@@ -90,6 +92,10 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
canProceed = _numberValue != null && _numberValue! >= 0 && !_navigating;
|
||||
}
|
||||
|
||||
final bool hasSuggestedVideo =
|
||||
(widget.suggestedVideoPath?.isNotEmpty ?? false) ||
|
||||
(widget.suggestedYoutubeId?.isNotEmpty ?? false);
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
@@ -127,267 +133,391 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (widget.questionImagePaths.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
_QuestionReferenceImages(
|
||||
paths: widget.questionImagePaths,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
if (widget.suggestedVideoPath != null ||
|
||||
widget.suggestedYoutubeId != null) ...[
|
||||
TextButton.icon(
|
||||
onPressed: () => showVideoPlayerDialog(
|
||||
context,
|
||||
VideoData(
|
||||
id: 0,
|
||||
title:
|
||||
widget.suggestedVideoTitle ?? 'Vídeo',
|
||||
description: '',
|
||||
videoPath: widget.suggestedVideoPath,
|
||||
youtubeId: widget.suggestedYoutubeId,
|
||||
),
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Color(0xFF2F9E94),
|
||||
),
|
||||
label: Text(
|
||||
widget.suggestedVideoTitle ??
|
||||
'Ver vídeo (opcional)',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF2F9E94),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
widget.question,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFFFF55A7),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.answerType == QuizAnswerType.number
|
||||
? 'Insira o número'
|
||||
: widget.answerType == QuizAnswerType.yesNo
|
||||
? 'Escolha uma opção'
|
||||
: 'Escolha apenas uma opção',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: widget.answerType == QuizAnswerType.number
|
||||
? _buildNumberInput()
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
itemCount: widget.answers.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) {
|
||||
return _QuizAnswerTile(
|
||||
answer: widget.answers[i],
|
||||
selected: _selected == i,
|
||||
onTap: () => setState(() => _selected = i),
|
||||
);
|
||||
},
|
||||
if (widget.showBackButton)
|
||||
Positioned(
|
||||
left: 4,
|
||||
child: TapBounce(
|
||||
scale: 0.9,
|
||||
child: Material(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
shape: const CircleBorder(),
|
||||
elevation: 4,
|
||||
shadowColor: Colors.black.withValues(
|
||||
alpha: 0.15,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 18),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size.width * 0.62,
|
||||
height: 46,
|
||||
child: FilledButton(
|
||||
style:
|
||||
FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).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;
|
||||
},
|
||||
),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
color: Color(0xFF2F9E94),
|
||||
size: 22,
|
||||
),
|
||||
onPressed: !canProceed
|
||||
? null
|
||||
: () {
|
||||
setState(() => _navigating = true);
|
||||
int nextScore = widget.currentScore;
|
||||
if (widget.answerType ==
|
||||
QuizAnswerType.number) {
|
||||
nextScore =
|
||||
widget.currentScore +
|
||||
(_numberValue ?? 0);
|
||||
} else {
|
||||
final picked =
|
||||
widget.answers[_selected!];
|
||||
nextScore =
|
||||
widget.currentScore + picked.weight;
|
||||
}
|
||||
|
||||
if (widget.isFinal) {
|
||||
final finishedRoute = widget.nextRoute(
|
||||
context,
|
||||
nextScore,
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushReplacement(finishedRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).push(
|
||||
widget.nextRoute(context, nextScore),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
widget.isFinal ? 'Concluir' : 'Avançar',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showBackButton) ...[
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: size.width * 0.62,
|
||||
height: 42,
|
||||
child: FilledButton(
|
||||
style:
|
||||
FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).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: () =>
|
||||
Navigator.of(context).maybePop(),
|
||||
child: const Text('Voltar'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: size.width * 0.62,
|
||||
height: 42,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF2F9E94),
|
||||
side: const BorderSide(
|
||||
color: Color(0xFF2F9E94),
|
||||
width: 1.3,
|
||||
),
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).popUntil((route) => route.isFirst),
|
||||
child: const Text('Voltar para homepage'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 520,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FadeSlideIn(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
20,
|
||||
4,
|
||||
20,
|
||||
10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget
|
||||
.questionImagePaths
|
||||
.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
_QuestionReferenceImages(
|
||||
paths:
|
||||
widget.questionImagePaths,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
if (hasSuggestedVideo) ...[
|
||||
TextButton.icon(
|
||||
onPressed: () =>
|
||||
showVideoPlayerDialog(
|
||||
context,
|
||||
VideoData(
|
||||
id: 0,
|
||||
title:
|
||||
widget
|
||||
.suggestedVideoTitle ??
|
||||
'Vídeo',
|
||||
description: '',
|
||||
videoPath: widget
|
||||
.suggestedVideoPath,
|
||||
youtubeId: widget
|
||||
.suggestedYoutubeId,
|
||||
),
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons
|
||||
.play_circle_outline_rounded,
|
||||
color: Color(0xFF2F9E94),
|
||||
),
|
||||
label: Text(
|
||||
widget.suggestedVideoTitle ??
|
||||
'Ver vídeo (opcional)',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF2F9E94),
|
||||
fontWeight:
|
||||
FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
widget.question,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFFFF55A7),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.answerType ==
|
||||
QuizAnswerType.number
|
||||
? 'Insira o número'
|
||||
: widget.answerType ==
|
||||
QuizAnswerType.yesNo
|
||||
? 'Escolha uma opção'
|
||||
: 'Escolha apenas uma opção',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black
|
||||
.withValues(alpha: 0.55),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
child:
|
||||
widget.answerType ==
|
||||
QuizAnswerType.number
|
||||
? _buildNumberInput()
|
||||
: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (
|
||||
int i = 0;
|
||||
i < widget.answers.length;
|
||||
i++
|
||||
) ...[
|
||||
if (i > 0)
|
||||
const SizedBox(height: 12),
|
||||
FadeSlideIn(
|
||||
delay: Duration(
|
||||
milliseconds: 60 * i,
|
||||
),
|
||||
child: _QuizAnswerTile(
|
||||
answer:
|
||||
widget.answers[i],
|
||||
selected:
|
||||
_selected == i,
|
||||
onTap: () => setState(
|
||||
() => _selected = i,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
20,
|
||||
0,
|
||||
20,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TapBounce(
|
||||
child: SizedBox(
|
||||
width: size.width * 0.62,
|
||||
height: 46,
|
||||
child: FilledButton(
|
||||
style:
|
||||
FilledButton.styleFrom(
|
||||
backgroundColor:
|
||||
const Color(
|
||||
0xFF2F9E94,
|
||||
),
|
||||
foregroundColor:
|
||||
Colors.white,
|
||||
shape:
|
||||
const StadiumBorder(),
|
||||
textStyle:
|
||||
const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.w900,
|
||||
),
|
||||
).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: !canProceed
|
||||
? null
|
||||
: () async {
|
||||
setState(
|
||||
() => _navigating =
|
||||
true,
|
||||
);
|
||||
int nextScore =
|
||||
widget
|
||||
.currentScore;
|
||||
if (widget.answerType ==
|
||||
QuizAnswerType
|
||||
.number) {
|
||||
nextScore =
|
||||
widget
|
||||
.currentScore +
|
||||
(_numberValue ??
|
||||
0);
|
||||
} else {
|
||||
final picked = widget
|
||||
.answers[_selected!];
|
||||
nextScore =
|
||||
widget
|
||||
.currentScore +
|
||||
picked.weight;
|
||||
}
|
||||
|
||||
if (widget.isFinal) {
|
||||
final finishedRoute =
|
||||
widget.nextRoute(
|
||||
context,
|
||||
nextScore,
|
||||
);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushReplacement(
|
||||
finishedRoute,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(
|
||||
widget.nextRoute(
|
||||
context,
|
||||
nextScore,
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _navigating =
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
widget.isFinal
|
||||
? 'Concluir'
|
||||
: 'Avançar',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TapBounce(
|
||||
child: SizedBox(
|
||||
width: size.width * 0.62,
|
||||
height: 42,
|
||||
child: OutlinedButton(
|
||||
style:
|
||||
OutlinedButton.styleFrom(
|
||||
foregroundColor:
|
||||
const Color(
|
||||
0xFF2F9E94,
|
||||
),
|
||||
side: const BorderSide(
|
||||
color: Color(
|
||||
0xFF2F9E94,
|
||||
),
|
||||
width: 1.3,
|
||||
),
|
||||
shape:
|
||||
const StadiumBorder(),
|
||||
textStyle:
|
||||
const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.w900,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
Navigator.of(
|
||||
context,
|
||||
).popUntil(
|
||||
(route) =>
|
||||
route.isFirst,
|
||||
),
|
||||
child: const Text(
|
||||
'Voltar para homepage',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -469,67 +599,109 @@ class _QuizAnswerTile extends StatelessWidget {
|
||||
? Colors.white.withValues(alpha: 0.88)
|
||||
: Colors.white.withValues(alpha: 0.70);
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: borderColor, width: selected ? 1.4 : 1.0),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (answer.imagePath != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: Image.asset(
|
||||
answer.imagePath!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Colors.black38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
Text(
|
||||
answer.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 15,
|
||||
color: Color(0xFF2F9E94),
|
||||
),
|
||||
return TapBounce(
|
||||
scale: 0.97,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: selected ? 1.4 : 1.0,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (answer.imagePath != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: Image.asset(
|
||||
answer.imagePath!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
Container(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.06,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Colors.black38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
Text(
|
||||
answer.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 15,
|
||||
color: Color(0xFF2F9E94),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedScale(
|
||||
scale: selected ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2F9E94),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
size: 15,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
import 'quiz_prefs.dart';
|
||||
|
||||
class QuizResultScreen extends StatefulWidget {
|
||||
@@ -115,82 +117,98 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'A percentagem de risco\navaliada é de:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFFFF55A7),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 220,
|
||||
height: 220,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 12,
|
||||
backgroundColor: Colors.black
|
||||
.withValues(alpha: 0.10),
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation(
|
||||
Color(0xFF2F9E94),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$percent%',
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${clamped.toInt()}/${widget.maxScore}',
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(
|
||||
alpha: 0.60,
|
||||
),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
FadeSlideIn(
|
||||
child: const Text(
|
||||
'A percentagem de risco\navaliada é de:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFFFF55A7),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
'Conclusões:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.75),
|
||||
fontWeight: FontWeight.w900,
|
||||
Center(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 1100),
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween<double>(begin: 0, end: progress),
|
||||
builder: (context, animatedProgress, _) {
|
||||
final animatedPercent =
|
||||
(animatedProgress * 100).round();
|
||||
return SizedBox(
|
||||
width: 220,
|
||||
height: 220,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 200,
|
||||
child: CircularProgressIndicator(
|
||||
value: animatedProgress,
|
||||
strokeWidth: 12,
|
||||
backgroundColor: Colors.black
|
||||
.withValues(alpha: 0.10),
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation(
|
||||
Color(0xFF2F9E94),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$animatedPercent%',
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${clamped.toInt()}/${widget.maxScore}',
|
||||
style: TextStyle(
|
||||
color: Colors.black
|
||||
.withValues(alpha: 0.60),
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 120),
|
||||
child: Text(
|
||||
'Conclusões:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.75),
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.70),
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.25,
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 160),
|
||||
child: Text(
|
||||
'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black.withValues(alpha: 0.70),
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -210,7 +228,8 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: SizedBox(
|
||||
child: TapBounce(
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
height: 46,
|
||||
child: FilledButton(
|
||||
@@ -229,6 +248,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
||||
},
|
||||
child: const Text('Avançar'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,9 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
|
||||
class CuriosidadeScreen extends StatelessWidget {
|
||||
const CuriosidadeScreen({super.key});
|
||||
|
||||
@@ -68,24 +71,48 @@ class CuriosidadeScreen extends StatelessWidget {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
children: [
|
||||
_CuriosityTopicTile(
|
||||
title: 'Tema X',
|
||||
description: 'Aprenda dicas rápidas e simples para cuidar dos dentes no dia a dia.',
|
||||
FadeSlideIn(
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: _CuriosityTopicTile(
|
||||
title: 'Tema X',
|
||||
description:
|
||||
'Aprenda dicas rápidas e simples para cuidar dos dentes no dia a dia.',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _CuriosityTopicTile(
|
||||
title: 'Tema Y',
|
||||
description: 'Conteúdo em breve.',
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 60),
|
||||
child: const TapBounce(
|
||||
scale: 0.97,
|
||||
child: _CuriosityTopicTile(
|
||||
title: 'Tema Y',
|
||||
description: 'Conteúdo em breve.',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _CuriosityTopicTile(
|
||||
title: 'Tema Z',
|
||||
description: 'Conteúdo em breve.',
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 120),
|
||||
child: const TapBounce(
|
||||
scale: 0.97,
|
||||
child: _CuriosityTopicTile(
|
||||
title: 'Tema Z',
|
||||
description: 'Conteúdo em breve.',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _CuriosityTopicTile(
|
||||
title: 'Tema U',
|
||||
description: 'Conteúdo em breve.',
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 180),
|
||||
child: const TapBounce(
|
||||
scale: 0.97,
|
||||
child: _CuriosityTopicTile(
|
||||
title: 'Tema U',
|
||||
description: 'Conteúdo em breve.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -156,17 +183,21 @@ class _CuriosityTopicTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w900),
|
||||
TapBounce(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2F9E94),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Fechar'),
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Fechar'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -12,10 +12,19 @@ class HelloSplashScreen extends StatefulWidget {
|
||||
State<HelloSplashScreen> createState() => _HelloSplashScreenState();
|
||||
}
|
||||
|
||||
class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTickerProviderStateMixin {
|
||||
class _HelloSplashScreenState extends State<HelloSplashScreen> with TickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _opacity;
|
||||
|
||||
late final AnimationController _popController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 700),
|
||||
);
|
||||
late final Animation<double> _pop = CurvedAnimation(
|
||||
parent: _popController,
|
||||
curve: Curves.elasticOut,
|
||||
);
|
||||
|
||||
Timer? _fadeTimer;
|
||||
Timer? _doneTimer;
|
||||
|
||||
@@ -34,6 +43,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTicker
|
||||
);
|
||||
|
||||
_controller.value = 1.0;
|
||||
_popController.forward();
|
||||
|
||||
final int fadeMs = (widget.duration.inMilliseconds - 500).clamp(0, widget.duration.inMilliseconds);
|
||||
_fadeTimer = Timer(Duration(milliseconds: fadeMs), () {
|
||||
@@ -52,6 +62,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTicker
|
||||
_fadeTimer?.cancel();
|
||||
_doneTimer?.cancel();
|
||||
_controller.dispose();
|
||||
_popController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -70,15 +81,18 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTicker
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Text(
|
||||
'Olá',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 64,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
height: 1.0,
|
||||
children: [
|
||||
ScaleTransition(
|
||||
scale: _pop,
|
||||
child: const Text(
|
||||
'Olá',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 64,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
import '../widgets/app_dialogs.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
import 'terms_screen.dart';
|
||||
|
||||
const Color _teal = Color(0xFF2F9E94);
|
||||
@@ -65,53 +67,78 @@ class _SettingsBodyState extends State<SettingsBody> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SectionLabel('Conta'),
|
||||
_SettingsCard(
|
||||
children: [
|
||||
_InfoTile(
|
||||
icon: Icons.person_outline_rounded,
|
||||
title: name.isEmpty ? 'Sem nome' : name,
|
||||
subtitle: email,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_ActionTile(
|
||||
icon: Icons.logout_rounded,
|
||||
title: 'Sair',
|
||||
onTap: _signOut,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_SectionLabel('Sobre'),
|
||||
_SettingsCard(
|
||||
children: [
|
||||
_ActionTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: 'Termos de Serviço',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const TermsScreen()),
|
||||
FadeSlideIn(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SectionLabel('Conta'),
|
||||
_SettingsCard(
|
||||
children: [
|
||||
_InfoTile(
|
||||
icon: Icons.person_outline_rounded,
|
||||
title: name.isEmpty ? 'Sem nome' : name,
|
||||
subtitle: email,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_ActionTile(
|
||||
icon: Icons.logout_rounded,
|
||||
title: 'Sair',
|
||||
onTap: _signOut,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const _InfoTile(
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: 'Versão do app',
|
||||
subtitle: '1.0.0',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_SectionLabel('Zona de risco'),
|
||||
_SettingsCard(
|
||||
children: [
|
||||
_ActionTile(
|
||||
icon: Icons.delete_forever_rounded,
|
||||
title: 'Apagar dados da conta',
|
||||
titleColor: _accentPink,
|
||||
loading: _deletingAccount,
|
||||
onTap: _deletingAccount ? null : _confirmDeleteAccountData,
|
||||
),
|
||||
],
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 80),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SectionLabel('Sobre'),
|
||||
_SettingsCard(
|
||||
children: [
|
||||
_ActionTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: 'Termos de Serviço',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const TermsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const _InfoTile(
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: 'Versão do app',
|
||||
subtitle: '1.0.0',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 160),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SectionLabel('Zona de risco'),
|
||||
_SettingsCard(
|
||||
children: [
|
||||
_ActionTile(
|
||||
icon: Icons.delete_forever_rounded,
|
||||
title: 'Apagar dados da conta',
|
||||
titleColor: _accentPink,
|
||||
loading: _deletingAccount,
|
||||
onTap: _deletingAccount ? null : _confirmDeleteAccountData,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
@@ -193,20 +220,23 @@ class _ActionTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: titleColor ?? _teal),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(fontWeight: FontWeight.w800, color: titleColor),
|
||||
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,
|
||||
),
|
||||
trailing: loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import 'package:lottie/lottie.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
|
||||
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
|
||||
// Video data structure - easily editable for future updates.
|
||||
// Episódios 1-7 tocam via YouTube (não listado); preencha youtubeId ao subir
|
||||
// cada vídeo. Episódios 8-13 continuam embutidos no app (assets/videos).
|
||||
@@ -276,8 +279,13 @@ class _VideoScreenState extends State<VideoScreen> {
|
||||
),
|
||||
itemCount: _filteredVideos.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _VideoButton(
|
||||
video: _filteredVideos[index],
|
||||
return FadeSlideIn(
|
||||
delay: Duration(
|
||||
milliseconds: 40 * (index % 8),
|
||||
),
|
||||
child: _VideoButton(
|
||||
video: _filteredVideos[index],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -437,7 +445,9 @@ class _VideoButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
return TapBounce(
|
||||
scale: 0.95,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -484,6 +494,7 @@ class _VideoButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
62
lib/widgets/animated_nav_icon.dart
Normal file
62
lib/widgets/animated_nav_icon.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Ícone de navegação que dá um pequeno "pulo" (scale bounce) sempre que
|
||||
/// passa a ficar selecionado, para reforçar o feedback de toque na
|
||||
/// bottom navigation bar.
|
||||
class AnimatedNavIcon extends StatefulWidget {
|
||||
const AnimatedNavIcon({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
State<AnimatedNavIcon> createState() => _AnimatedNavIconState();
|
||||
}
|
||||
|
||||
class _AnimatedNavIconState extends State<AnimatedNavIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 320),
|
||||
);
|
||||
late final Animation<double> _bounce = TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween(begin: 1.0, end: 1.35).chain(
|
||||
CurveTween(curve: Curves.easeOut),
|
||||
),
|
||||
weight: 40,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween(begin: 1.35, end: 1.0).chain(
|
||||
CurveTween(curve: Curves.easeOutBack),
|
||||
),
|
||||
weight: 60,
|
||||
),
|
||||
]).animate(_controller);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AnimatedNavIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.selected && !oldWidget.selected) {
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(
|
||||
scale: _bounce,
|
||||
child: Icon(widget.icon),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'tap_bounce.dart';
|
||||
|
||||
const Color _teal = Color(0xFF2F9E94);
|
||||
const Color _accentPink = Color(0xFFFF55A7);
|
||||
|
||||
@@ -24,20 +26,24 @@ Future<bool?> showConfirmDialog(
|
||||
),
|
||||
content: message == null ? null : Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(foregroundColor: _teal),
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(cancelLabel),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: confirmColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||
TapBounce(
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(foregroundColor: _teal),
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(cancelLabel),
|
||||
),
|
||||
),
|
||||
TapBounce(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: confirmColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
64
lib/widgets/entrance.dart
Normal file
64
lib/widgets/entrance.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Animação de entrada (fade + leve deslize para cima) para dar vida a
|
||||
/// cards e listas quando aparecem em ecrã. Suporta [delay] para permitir
|
||||
/// efeito "staggered" (itens surgindo em sequência) em listas/grades.
|
||||
class FadeSlideIn extends StatefulWidget {
|
||||
const FadeSlideIn({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.delay = Duration.zero,
|
||||
this.duration = const Duration(milliseconds: 420),
|
||||
this.offset = const Offset(0, 0.08),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Duration delay;
|
||||
final Duration duration;
|
||||
final Offset offset;
|
||||
|
||||
@override
|
||||
State<FadeSlideIn> createState() => _FadeSlideInState();
|
||||
}
|
||||
|
||||
class _FadeSlideInState extends State<FadeSlideIn>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
);
|
||||
late final Animation<double> _fade = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
late final Animation<Offset> _slide = Tween<Offset>(
|
||||
begin: widget.offset,
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.delay == Duration.zero) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
Future.delayed(widget.delay, () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _fade,
|
||||
child: SlideTransition(position: _slide, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/widgets/tap_bounce.dart
Normal file
60
lib/widgets/tap_bounce.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Envolve [child] com um efeito de "aperto" ao toque: encolhe levemente
|
||||
/// no pointer-down e volta ao tamanho normal com uma pequena mola ao soltar.
|
||||
///
|
||||
/// Usa [Listener] (eventos de ponteiro puros) em vez de [GestureDetector]
|
||||
/// para não competir na arena de gestos com um `InkWell`/`Button` filho —
|
||||
/// o toque real continua a ser tratado pelo widget interno normalmente.
|
||||
class TapBounce extends StatefulWidget {
|
||||
const TapBounce({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.scale = 0.94,
|
||||
this.duration = const Duration(milliseconds: 110),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final double scale;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
State<TapBounce> createState() => _TapBounceState();
|
||||
}
|
||||
|
||||
class _TapBounceState extends State<TapBounce>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.duration,
|
||||
);
|
||||
late final Animation<double> _scale = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: widget.scale,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _press(PointerDownEvent _) => _controller.forward();
|
||||
|
||||
void _release([PointerEvent? _]) => _controller.reverse();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _press,
|
||||
onPointerUp: _release,
|
||||
onPointerCancel: _release,
|
||||
child: AnimatedBuilder(
|
||||
animation: _scale,
|
||||
builder: (context, child) =>
|
||||
Transform.scale(scale: _scale.value, child: child),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user