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 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lottie/lottie.dart';
|
import 'package:lottie/lottie.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
import 'login_register/login_sheet.dart';
|
import 'main.dart' show supabase;
|
||||||
import 'login_register/register_sheet.dart';
|
import 'widgets/entrance.dart';
|
||||||
|
import 'widgets/tap_bounce.dart';
|
||||||
|
|
||||||
|
const Color _teal = Color(0xFF2F9E94);
|
||||||
|
const Color _pink = Color(0xFFFF55A7);
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -14,17 +20,118 @@ class HomeScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final Size size = MediaQuery.sizeOf(context);
|
final Size size = MediaQuery.sizeOf(context);
|
||||||
|
|
||||||
return IgnorePointer(
|
return Scaffold(
|
||||||
ignoring: _paused,
|
body: Stack(
|
||||||
child: Scaffold(
|
|
||||||
body: SafeArea(
|
|
||||||
child: Stack(
|
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
@@ -59,132 +166,252 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
SafeArea(
|
||||||
child: Padding(
|
child: LayoutBuilder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 20),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: constraints.maxHeight,
|
||||||
|
),
|
||||||
|
child: IntrinsicHeight(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const FadeSlideIn(
|
||||||
|
child: Text(
|
||||||
'Check-Teeth Kids',
|
'Check-Teeth Kids',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 26,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: Color(0xFFFF55A7),
|
color: _pink,
|
||||||
height: 1.0,
|
height: 1.0,
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
Text(
|
const SizedBox(height: 8),
|
||||||
'Cuidar do sorriso começa aqui.',
|
FadeSlideIn(
|
||||||
|
delay: const Duration(milliseconds: 80),
|
||||||
|
child: Text(
|
||||||
|
'Organize a rotina de saúde oral com inteligência',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 13.5,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFF2F9E94).withValues(alpha: 0.9),
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
),
|
||||||
Text(
|
const SizedBox(height: 26),
|
||||||
'Acompanhe a saúde oral do seu filho com\ninformação segura e prevenção inteligente.',
|
FadeSlideIn(
|
||||||
textAlign: TextAlign.center,
|
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(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontWeight: FontWeight.w800,
|
||||||
height: 1.35,
|
fontSize: 14,
|
||||||
color: Colors.black.withValues(alpha: 0.52),
|
color: selected ? Colors.white : _teal,
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
|
||||||
SizedBox(
|
|
||||||
width: size.width * 0.78,
|
|
||||||
child: _PrimaryButton(
|
|
||||||
label: 'Cadastrar',
|
|
||||||
onPressed: _openRegister,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthForm extends StatelessWidget {
|
||||||
|
const _AuthForm({
|
||||||
|
required this.formKey,
|
||||||
|
required this.isLogin,
|
||||||
|
required this.loading,
|
||||||
|
required this.nameController,
|
||||||
|
required this.emailController,
|
||||||
|
required this.passwordController,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
final GlobalKey<FormState> formKey;
|
||||||
|
final bool isLogin;
|
||||||
|
final bool loading;
|
||||||
|
final TextEditingController nameController;
|
||||||
|
final TextEditingController emailController;
|
||||||
|
final TextEditingController passwordController;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: !isLogin
|
||||||
|
? Column(
|
||||||
|
children: [
|
||||||
|
_AuthTextField(
|
||||||
|
controller: nameController,
|
||||||
|
hintText: '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(height: 12),
|
||||||
SizedBox(
|
|
||||||
width: size.width * 0.78,
|
|
||||||
child: _SecondaryButton(
|
|
||||||
label: 'Entrar',
|
|
||||||
onPressed: _openLogin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
)
|
||||||
|
: 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,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
|
||||||
@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(
|
child: FilledButton(
|
||||||
style:
|
style:
|
||||||
FilledButton.styleFrom(
|
FilledButton.styleFrom(
|
||||||
backgroundColor: teal,
|
backgroundColor: _teal,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: const StadiumBorder(),
|
shape: const StadiumBorder(),
|
||||||
textStyle: const TextStyle(
|
textStyle: const TextStyle(
|
||||||
@@ -194,7 +421,9 @@ class _PrimaryButton extends StatelessWidget {
|
|||||||
).copyWith(
|
).copyWith(
|
||||||
animationDuration: const Duration(milliseconds: 180),
|
animationDuration: const Duration(milliseconds: 180),
|
||||||
splashFactory: InkSparkle.splashFactory,
|
splashFactory: InkSparkle.splashFactory,
|
||||||
overlayColor: WidgetStateProperty.resolveWith<Color?>((states) {
|
overlayColor: WidgetStateProperty.resolveWith<Color?>((
|
||||||
|
states,
|
||||||
|
) {
|
||||||
if (states.contains(WidgetState.pressed)) {
|
if (states.contains(WidgetState.pressed)) {
|
||||||
return Colors.white.withValues(alpha: 0.14);
|
return Colors.white.withValues(alpha: 0.14);
|
||||||
}
|
}
|
||||||
@@ -205,8 +434,91 @@ class _PrimaryButton extends StatelessWidget {
|
|||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
onPressed: onPressed,
|
onPressed: loading ? null : onSubmit,
|
||||||
child: Text(label),
|
child: loading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(isLogin ? 'Entrar' : 'Criar Conta'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_forward_rounded,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthTextField extends StatelessWidget {
|
||||||
|
const _AuthTextField({
|
||||||
|
required this.controller,
|
||||||
|
required this.hintText,
|
||||||
|
required this.icon,
|
||||||
|
required this.validator,
|
||||||
|
this.obscureText = false,
|
||||||
|
this.keyboardType,
|
||||||
|
this.textInputAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
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/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:lottie/lottie.dart';
|
import 'package:lottie/lottie.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
@@ -12,7 +13,10 @@ import 'quiz/quiz1.dart';
|
|||||||
import 'quiz/quiz_prefs.dart';
|
import 'quiz/quiz_prefs.dart';
|
||||||
import 'screens/settings_screen.dart';
|
import 'screens/settings_screen.dart';
|
||||||
import 'screens/video_screen.dart';
|
import 'screens/video_screen.dart';
|
||||||
|
import 'widgets/animated_nav_icon.dart';
|
||||||
import 'widgets/app_dialogs.dart';
|
import 'widgets/app_dialogs.dart';
|
||||||
|
import 'widgets/entrance.dart';
|
||||||
|
import 'widgets/tap_bounce.dart';
|
||||||
|
|
||||||
class LoggedHomeScreen extends StatefulWidget {
|
class LoggedHomeScreen extends StatefulWidget {
|
||||||
const LoggedHomeScreen({super.key});
|
const LoggedHomeScreen({super.key});
|
||||||
@@ -28,6 +32,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
|
|
||||||
static const double _collapsedAppBarHeight = 104;
|
static const double _collapsedAppBarHeight = 104;
|
||||||
static const double _expandedAppBarHeight = 180;
|
static const double _expandedAppBarHeight = 180;
|
||||||
|
static const double _nameOnlyAppBarHeight = 130;
|
||||||
|
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
|
|
||||||
@@ -207,8 +212,14 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = MediaQuery.sizeOf(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
|
final double appBarHeight = _index == 0
|
||||||
? _expandedAppBarHeight
|
? (hasScore ? _expandedAppBarHeight : _nameOnlyAppBarHeight)
|
||||||
: _collapsedAppBarHeight;
|
: _collapsedAppBarHeight;
|
||||||
final double toolbarHeight = _index == 0 ? kToolbarHeight : appBarHeight;
|
final double toolbarHeight = _index == 0 ? kToolbarHeight : appBarHeight;
|
||||||
final String title = _index == 0
|
final String title = _index == 0
|
||||||
@@ -224,10 +235,6 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
|
|
||||||
final shownName = _cachedUserName;
|
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;
|
final double bodyTopPadding = _index == 0 ? 0 : 10;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -251,43 +258,37 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
opacity: 0.22,
|
opacity: 0.22,
|
||||||
child: Transform.scale(scale: 1.25),
|
child: Transform.scale(scale: 1.25),
|
||||||
),
|
),
|
||||||
|
if (hasScore)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: toolbarHeight + 26,
|
top: toolbarHeight + 26,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: RichText(
|
child: Text(
|
||||||
|
(_selectedChildName ?? '').trim(),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
text: TextSpan(
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: Colors.white.withValues(alpha: 0.92),
|
color: Colors.white.withValues(alpha: 0.92),
|
||||||
fontSize: 14,
|
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,
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if ((_selectedChildName ?? '').trim().isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: toolbarHeight,
|
||||||
|
bottom: 0,
|
||||||
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'•',
|
_selectedChildName!.trim(),
|
||||||
style: TextStyle(color: Colors.white),
|
textAlign: TextAlign.center,
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
fontWeight: FontWeight.w800,
|
||||||
),
|
color: Colors.white.withValues(alpha: 0.92),
|
||||||
if (hasScore)
|
fontSize: 14,
|
||||||
TextSpan(text: '$score/$maxScore'),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -308,6 +309,8 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
child: _index == 0
|
child: _index == 0
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.only(left: 16, right: 10),
|
padding: const EdgeInsets.only(left: 16, right: 10),
|
||||||
|
child: TapBounce(
|
||||||
|
scale: 0.96,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -323,9 +326,8 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
backgroundColor: Colors.white.withValues(
|
backgroundColor: Colors.white
|
||||||
alpha: 0.25,
|
.withValues(alpha: 0.25),
|
||||||
),
|
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
(_cachedPhotoUrl ?? '').isNotEmpty
|
(_cachedPhotoUrl ?? '').isNotEmpty
|
||||||
? NetworkImage(_cachedPhotoUrl!)
|
? NetworkImage(_cachedPhotoUrl!)
|
||||||
@@ -369,6 +371,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
title,
|
title,
|
||||||
@@ -442,22 +445,35 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
currentIndex: _index,
|
currentIndex: _index,
|
||||||
onTap: (i) => setState(() => _index = i),
|
onTap: (i) {
|
||||||
|
if (i == _index) return;
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
setState(() => _index = i);
|
||||||
|
},
|
||||||
backgroundColor: const Color(0xFFFFE6F1),
|
backgroundColor: const Color(0xFFFFE6F1),
|
||||||
selectedItemColor: _teal,
|
selectedItemColor: _teal,
|
||||||
unselectedItemColor: Colors.black54,
|
unselectedItemColor: Colors.black54,
|
||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
items: const [
|
items: [
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.home_rounded),
|
icon: AnimatedNavIcon(
|
||||||
|
icon: Icons.home_rounded,
|
||||||
|
selected: _index == 0,
|
||||||
|
),
|
||||||
label: 'Início',
|
label: 'Início',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.person_rounded),
|
icon: AnimatedNavIcon(
|
||||||
|
icon: Icons.person_rounded,
|
||||||
|
selected: _index == 1,
|
||||||
|
),
|
||||||
label: 'Perfil',
|
label: 'Perfil',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.settings_rounded),
|
icon: AnimatedNavIcon(
|
||||||
|
icon: Icons.settings_rounded,
|
||||||
|
selected: _index == 2,
|
||||||
|
),
|
||||||
label: 'Ajustes',
|
label: 'Ajustes',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -485,7 +501,7 @@ class _RiskArcGauge extends StatelessWidget {
|
|||||||
width: 120,
|
width: 120,
|
||||||
height: 60,
|
height: 60,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
@@ -493,11 +509,11 @@ class _RiskArcGauge extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 4,
|
top: 38,
|
||||||
child: Column(
|
left: 6,
|
||||||
mainAxisSize: MainAxisSize.min,
|
right: 0,
|
||||||
children: [
|
child: Center(
|
||||||
Text(
|
child: Text(
|
||||||
'$shown%',
|
'$shown%',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -506,16 +522,6 @@ class _RiskArcGauge extends StatelessWidget {
|
|||||||
height: 1,
|
height: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.92),
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -630,12 +636,21 @@ class _InicioTab extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_HeroQuizCard(
|
FadeSlideIn(
|
||||||
|
child: TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
|
child: _HeroQuizCard(
|
||||||
childName: selectedChildName,
|
childName: selectedChildName,
|
||||||
onStartQuiz: () => _startQuiz(context),
|
onStartQuiz: () => _startQuiz(context),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_VideoLibraryCard(
|
FadeSlideIn(
|
||||||
|
delay: const Duration(milliseconds: 90),
|
||||||
|
child: TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
|
child: _VideoLibraryCard(
|
||||||
onOpenLibrary: () {
|
onOpenLibrary: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
@@ -644,6 +659,8 @@ class _InicioTab extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -741,6 +758,8 @@ Future<Map<String, dynamic>?> _pickChildSheet(
|
|||||||
final label = age != null ? '$name • $age anos' : name;
|
final label = age != null ? '$name • $age anos' : name;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.white.withValues(alpha: 0.85),
|
color: Colors.white.withValues(alpha: 0.85),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -771,6 +790,7 @@ Future<Map<String, dynamic>?> _pickChildSheet(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -1327,7 +1347,8 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Material(
|
FadeSlideIn(
|
||||||
|
child: Material(
|
||||||
elevation: 10,
|
elevation: 10,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -1337,7 +1358,9 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
TapBounce(
|
||||||
|
scale: 0.92,
|
||||||
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(40),
|
borderRadius: BorderRadius.circular(40),
|
||||||
onTap: _updatingPhoto
|
onTap: _updatingPhoto
|
||||||
? null
|
? null
|
||||||
@@ -1415,6 +1438,7 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1453,6 +1477,7 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
||||||
@@ -1554,8 +1579,14 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
].join(' • ');
|
].join(' • ');
|
||||||
final bool selected = i == selectedIndex;
|
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),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () => widget.onChildSelected(
|
onTap: () => widget.onChildSelected(
|
||||||
@@ -1663,9 +1694,12 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
SizedBox(
|
TapBounce(
|
||||||
|
child: SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -1683,8 +1717,10 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
label: const Text('Adicionar criança'),
|
label: const Text('Adicionar criança'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
SizedBox(
|
TapBounce(
|
||||||
|
child: SizedBox(
|
||||||
height: 46,
|
height: 46,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
@@ -1705,6 +1741,7 @@ class _PerfilTabState extends State<_PerfilTab> {
|
|||||||
label: const Text('Sair'),
|
label: const Text('Sair'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1844,6 +1881,7 @@ class _AddChildSheetState extends State<_AddChildSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: TapBounce(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 44,
|
height: 44,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
@@ -1851,13 +1889,16 @@ class _AddChildSheetState extends State<_AddChildSheet> {
|
|||||||
backgroundColor: const Color(0xFF2F9E94),
|
backgroundColor: const Color(0xFF2F9E94),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: const StadiumBorder(),
|
shape: const StadiumBorder(),
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.w900),
|
textStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPressed: _submit,
|
onPressed: _submit,
|
||||||
child: const Text('Adicionar'),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 1/26',
|
title: 'Quiz 1/25',
|
||||||
question: 'O rosto do seu filho/a se parece com o desta imagem?',
|
question: 'O rosto do seu filho/a se parece com o desta imagem?',
|
||||||
questionImagePaths: const ['assets/mockup_images/2.jpeg'],
|
questionImagePaths: const ['assets/mockup_images/2.jpeg'],
|
||||||
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 1
|
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 1
|
||||||
@@ -52,7 +52,7 @@ class Quiz2Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 2/26',
|
title: 'Quiz 2/25',
|
||||||
question:
|
question:
|
||||||
'A boca do seu filho/a fica habitualmente na posição desta imagem (entreaberta)?',
|
'A boca do seu filho/a fica habitualmente na posição desta imagem (entreaberta)?',
|
||||||
questionImagePaths: const ['assets/mockup_images/4.jpeg'],
|
questionImagePaths: const ['assets/mockup_images/4.jpeg'],
|
||||||
@@ -92,7 +92,7 @@ class Quiz3Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 3/26',
|
title: 'Quiz 3/25',
|
||||||
question: 'O seu filho/a tem olheiras semelhantes às desta imagem?',
|
question: 'O seu filho/a tem olheiras semelhantes às desta imagem?',
|
||||||
questionImagePaths: const ['assets/mockup_images/8.jpeg'],
|
questionImagePaths: const ['assets/mockup_images/8.jpeg'],
|
||||||
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 3
|
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 3
|
||||||
@@ -131,7 +131,7 @@ class Quiz4Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 4/26',
|
title: 'Quiz 4/25',
|
||||||
question:
|
question:
|
||||||
'Com a boca fechada, o queixo do seu filho/a se parece com o desta imagem?',
|
'Com a boca fechada, o queixo do seu filho/a se parece com o desta imagem?',
|
||||||
questionImagePaths: const ['assets/mockup_images/6.jpeg'],
|
questionImagePaths: const ['assets/mockup_images/6.jpeg'],
|
||||||
@@ -171,7 +171,7 @@ class Quiz5Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 5/26',
|
title: 'Quiz 5/25',
|
||||||
question: 'Quantos dentes tem o seu filho/a em cima na boca?',
|
question: 'Quantos dentes tem o seu filho/a em cima na boca?',
|
||||||
answers: const [],
|
answers: const [],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
@@ -194,7 +194,7 @@ class Quiz6Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 6/26',
|
title: 'Quiz 6/25',
|
||||||
question: 'Quantos dentes tem o seu filho/a em baixo na boca?',
|
question: 'Quantos dentes tem o seu filho/a em baixo na boca?',
|
||||||
answers: const [],
|
answers: const [],
|
||||||
currentScore: currentScore,
|
currentScore: currentScore,
|
||||||
@@ -217,7 +217,7 @@ class Quiz7Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 7/26',
|
title: 'Quiz 7/25',
|
||||||
question: 'A boca do seu filho/a se parece com a desta imagem?',
|
question: 'A boca do seu filho/a se parece com a desta imagem?',
|
||||||
questionImagePaths: const ['assets/mockup_images/14.jpeg'],
|
questionImagePaths: const ['assets/mockup_images/14.jpeg'],
|
||||||
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 5
|
suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 5
|
||||||
@@ -256,7 +256,7 @@ class Quiz8Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 8/26',
|
title: 'Quiz 8/25',
|
||||||
question:
|
question:
|
||||||
'O frénulo (freio) da língua do seu filho/a se parece com o desta imagem?',
|
'O frénulo (freio) da língua do seu filho/a se parece com o desta imagem?',
|
||||||
questionImagePaths: const ['assets/mockup_images/17.png'],
|
questionImagePaths: const ['assets/mockup_images/17.png'],
|
||||||
@@ -296,7 +296,7 @@ class Quiz9Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 9/26',
|
title: 'Quiz 9/25',
|
||||||
question: 'O seu filho/a tem problemas respiratórios diagnosticados?',
|
question: 'O seu filho/a tem problemas respiratórios diagnosticados?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -332,7 +332,7 @@ class Quiz10Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 10/26',
|
title: 'Quiz 10/25',
|
||||||
question: 'O seu filho/a respira habitualmente pela boca?',
|
question: 'O seu filho/a respira habitualmente pela boca?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -368,7 +368,7 @@ class Quiz11Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 11/26',
|
title: 'Quiz 11/25',
|
||||||
question: 'O seu filho/a ressona habitualmente durante a noite?',
|
question: 'O seu filho/a ressona habitualmente durante a noite?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -404,7 +404,7 @@ class Quiz12Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 12/26',
|
title: 'Quiz 12/25',
|
||||||
question: 'O seu filho/a sente habitualmente o nariz "tapado"?',
|
question: 'O seu filho/a sente habitualmente o nariz "tapado"?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -440,7 +440,7 @@ class Quiz13Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 13/26',
|
title: 'Quiz 13/25',
|
||||||
question:
|
question:
|
||||||
'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?',
|
'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?',
|
||||||
answers: const [
|
answers: const [
|
||||||
@@ -478,7 +478,7 @@ class Quiz14Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 14/26',
|
title: 'Quiz 14/25',
|
||||||
question: 'O seu filho/a range os dentes com frequência?',
|
question: 'O seu filho/a range os dentes com frequência?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -514,7 +514,7 @@ class Quiz15Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 15/26',
|
title: 'Quiz 15/25',
|
||||||
question: 'O seu filho/a habitualmente tem alergias sazonais?',
|
question: 'O seu filho/a habitualmente tem alergias sazonais?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -550,7 +550,7 @@ class Quiz16Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 16/26',
|
title: 'Quiz 16/25',
|
||||||
question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?',
|
question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -586,7 +586,7 @@ class Quiz17Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 17/26',
|
title: 'Quiz 17/25',
|
||||||
question: 'O seu filho/a teve ou costuma ter com frequência otites?',
|
question: 'O seu filho/a teve ou costuma ter com frequência otites?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -622,7 +622,7 @@ class Quiz18Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 18/26',
|
title: 'Quiz 18/25',
|
||||||
question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?',
|
question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -658,7 +658,7 @@ class Quiz19Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 19/26',
|
title: 'Quiz 19/25',
|
||||||
question:
|
question:
|
||||||
'O seu filho/a teve ou costuma ter com frequência bronquiolites?',
|
'O seu filho/a teve ou costuma ter com frequência bronquiolites?',
|
||||||
answers: const [
|
answers: const [
|
||||||
@@ -695,7 +695,7 @@ class Quiz20Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 20/26',
|
title: 'Quiz 20/25',
|
||||||
question: 'O seu filho/a apresenta dificuldades a mastigar?',
|
question: 'O seu filho/a apresenta dificuldades a mastigar?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -731,7 +731,7 @@ class Quiz21Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 21/26',
|
title: 'Quiz 21/25',
|
||||||
question: 'O seu filho/a habitualmente é lento a comer?',
|
question: 'O seu filho/a habitualmente é lento a comer?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -767,7 +767,7 @@ class Quiz22Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 22/26',
|
title: 'Quiz 22/25',
|
||||||
question: 'O seu filho/a habitualmente prefere comer alimentos moles?',
|
question: 'O seu filho/a habitualmente prefere comer alimentos moles?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -803,7 +803,7 @@ class Quiz23Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 23/26',
|
title: 'Quiz 23/25',
|
||||||
question: 'Em bebé apenas foi alimentado por biberão?',
|
question: 'Em bebé apenas foi alimentado por biberão?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -839,7 +839,7 @@ class Quiz24Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 24/26',
|
title: 'Quiz 24/25',
|
||||||
question: 'O seu filho/a usa ou usou chupeta com frequência?',
|
question: 'O seu filho/a usa ou usou chupeta com frequência?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -875,7 +875,7 @@ class Quiz25Screen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return QuizQuestionScreen(
|
return QuizQuestionScreen(
|
||||||
title: 'Quiz 25/26',
|
title: 'Quiz 25/25',
|
||||||
question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?',
|
question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?',
|
||||||
answers: const [
|
answers: const [
|
||||||
QuizAnswer(
|
QuizAnswer(
|
||||||
@@ -892,35 +892,6 @@ class Quiz25Screen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
currentScore: currentScore,
|
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>(
|
nextRoute: (context, nextScore) => MaterialPageRoute<void>(
|
||||||
builder: (_) => QuizResultScreen(
|
builder: (_) => QuizResultScreen(
|
||||||
finalScore: nextScore,
|
finalScore: nextScore,
|
||||||
@@ -928,6 +899,7 @@ class Quiz26Screen extends StatelessWidget {
|
|||||||
scopeId: scopeId,
|
scopeId: scopeId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
answerType: QuizAnswerType.yesNo,
|
||||||
isFinal: true,
|
isFinal: true,
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:lottie/lottie.dart';
|
import 'package:lottie/lottie.dart';
|
||||||
|
|
||||||
import '../screens/video_screen.dart';
|
import '../screens/video_screen.dart';
|
||||||
|
import '../widgets/entrance.dart';
|
||||||
|
import '../widgets/tap_bounce.dart';
|
||||||
|
|
||||||
typedef QuizNextBuilder =
|
typedef QuizNextBuilder =
|
||||||
Route<void> Function(BuildContext context, int nextScore);
|
Route<void> Function(BuildContext context, int nextScore);
|
||||||
@@ -90,6 +92,10 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
|||||||
canProceed = _numberValue != null && _numberValue! >= 0 && !_navigating;
|
canProceed = _numberValue != null && _numberValue! >= 0 && !_navigating;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final bool hasSuggestedVideo =
|
||||||
|
(widget.suggestedVideoPath?.isNotEmpty ?? false) ||
|
||||||
|
(widget.suggestedYoutubeId?.isNotEmpty ?? false);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
@@ -127,16 +133,13 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
SizedBox(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 10),
|
height: 44,
|
||||||
child: Column(
|
child: Stack(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.title,
|
widget.title,
|
||||||
@@ -146,30 +149,102 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
|||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
if (widget.showBackButton)
|
||||||
if (widget.questionImagePaths.isNotEmpty) ...[
|
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,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
const SizedBox(height: 6),
|
||||||
_QuestionReferenceImages(
|
_QuestionReferenceImages(
|
||||||
paths: widget.questionImagePaths,
|
paths:
|
||||||
|
widget.questionImagePaths,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
],
|
],
|
||||||
if (widget.suggestedVideoPath != null ||
|
if (hasSuggestedVideo) ...[
|
||||||
widget.suggestedYoutubeId != null) ...[
|
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => showVideoPlayerDialog(
|
onPressed: () =>
|
||||||
|
showVideoPlayerDialog(
|
||||||
context,
|
context,
|
||||||
VideoData(
|
VideoData(
|
||||||
id: 0,
|
id: 0,
|
||||||
title:
|
title:
|
||||||
widget.suggestedVideoTitle ?? 'Vídeo',
|
widget
|
||||||
|
.suggestedVideoTitle ??
|
||||||
|
'Vídeo',
|
||||||
description: '',
|
description: '',
|
||||||
videoPath: widget.suggestedVideoPath,
|
videoPath: widget
|
||||||
youtubeId: widget.suggestedYoutubeId,
|
.suggestedVideoPath,
|
||||||
|
youtubeId: widget
|
||||||
|
.suggestedYoutubeId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.play_circle_outline_rounded,
|
Icons
|
||||||
|
.play_circle_outline_rounded,
|
||||||
color: Color(0xFF2F9E94),
|
color: Color(0xFF2F9E94),
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
@@ -177,7 +252,8 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
|||||||
'Ver vídeo (opcional)',
|
'Ver vídeo (opcional)',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFF2F9E94),
|
color: Color(0xFF2F9E94),
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight:
|
||||||
|
FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -195,191 +271,237 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
widget.answerType == QuizAnswerType.number
|
widget.answerType ==
|
||||||
|
QuizAnswerType.number
|
||||||
? 'Insira o número'
|
? 'Insira o número'
|
||||||
: widget.answerType == QuizAnswerType.yesNo
|
: widget.answerType ==
|
||||||
|
QuizAnswerType.yesNo
|
||||||
? 'Escolha uma opção'
|
? 'Escolha uma opção'
|
||||||
: 'Escolha apenas uma opção',
|
: 'Escolha apenas uma opção',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
color: Colors.black
|
||||||
|
.withValues(alpha: 0.55),
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 18),
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
TapBounce(
|
||||||
|
child: SizedBox(
|
||||||
width: size.width * 0.62,
|
width: size.width * 0.62,
|
||||||
height: 46,
|
height: 46,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
style:
|
style:
|
||||||
FilledButton.styleFrom(
|
FilledButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF2F9E94),
|
backgroundColor:
|
||||||
foregroundColor: Colors.white,
|
const Color(
|
||||||
shape: const StadiumBorder(),
|
0xFF2F9E94,
|
||||||
textStyle: const TextStyle(
|
),
|
||||||
fontWeight: FontWeight.w900,
|
foregroundColor:
|
||||||
|
Colors.white,
|
||||||
|
shape:
|
||||||
|
const StadiumBorder(),
|
||||||
|
textStyle:
|
||||||
|
const TextStyle(
|
||||||
|
fontWeight:
|
||||||
|
FontWeight
|
||||||
|
.w900,
|
||||||
),
|
),
|
||||||
).copyWith(
|
).copyWith(
|
||||||
animationDuration: const Duration(
|
animationDuration:
|
||||||
|
const Duration(
|
||||||
milliseconds: 180,
|
milliseconds: 180,
|
||||||
),
|
),
|
||||||
splashFactory: InkSparkle.splashFactory,
|
splashFactory: InkSparkle
|
||||||
overlayColor:
|
.splashFactory,
|
||||||
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
|
|
||||||
: () {
|
|
||||||
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:
|
overlayColor:
|
||||||
WidgetStateProperty.resolveWith<
|
WidgetStateProperty.resolveWith<
|
||||||
Color?
|
Color?
|
||||||
>((states) {
|
>((states) {
|
||||||
if (states.contains(
|
if (states
|
||||||
WidgetState.pressed,
|
.contains(
|
||||||
|
WidgetState
|
||||||
|
.pressed,
|
||||||
)) {
|
)) {
|
||||||
return Colors.white.withValues(
|
return Colors
|
||||||
alpha: 0.14,
|
.white
|
||||||
|
.withValues(
|
||||||
|
alpha:
|
||||||
|
0.14,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (states.contains(
|
if (states.contains(
|
||||||
WidgetState.hovered,
|
WidgetState
|
||||||
|
.hovered,
|
||||||
) ||
|
) ||
|
||||||
states.contains(
|
states.contains(
|
||||||
WidgetState.focused,
|
WidgetState
|
||||||
|
.focused,
|
||||||
)) {
|
)) {
|
||||||
return Colors.white.withValues(
|
return Colors
|
||||||
alpha: 0.08,
|
.white
|
||||||
|
.withValues(
|
||||||
|
alpha:
|
||||||
|
0.08,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
onPressed: () =>
|
onPressed: !canProceed
|
||||||
Navigator.of(context).maybePop(),
|
? null
|
||||||
child: const Text('Voltar'),
|
: () 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),
|
const SizedBox(height: 10),
|
||||||
SizedBox(
|
TapBounce(
|
||||||
|
child: SizedBox(
|
||||||
width: size.width * 0.62,
|
width: size.width * 0.62,
|
||||||
height: 42,
|
height: 42,
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
style: OutlinedButton.styleFrom(
|
style:
|
||||||
foregroundColor: const Color(0xFF2F9E94),
|
OutlinedButton.styleFrom(
|
||||||
|
foregroundColor:
|
||||||
|
const Color(
|
||||||
|
0xFF2F9E94,
|
||||||
|
),
|
||||||
side: const BorderSide(
|
side: const BorderSide(
|
||||||
color: Color(0xFF2F9E94),
|
color: Color(
|
||||||
|
0xFF2F9E94,
|
||||||
|
),
|
||||||
width: 1.3,
|
width: 1.3,
|
||||||
),
|
),
|
||||||
shape: const StadiumBorder(),
|
shape:
|
||||||
textStyle: const TextStyle(
|
const StadiumBorder(),
|
||||||
fontWeight: FontWeight.w900,
|
textStyle:
|
||||||
|
const TextStyle(
|
||||||
|
fontWeight:
|
||||||
|
FontWeight
|
||||||
|
.w900,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.of(
|
onPressed: () =>
|
||||||
|
Navigator.of(
|
||||||
context,
|
context,
|
||||||
).popUntil((route) => route.isFirst),
|
).popUntil(
|
||||||
child: const Text('Voltar para homepage'),
|
(route) =>
|
||||||
|
route.isFirst,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Voltar para homepage',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -390,6 +512,14 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -469,13 +599,22 @@ class _QuizAnswerTile extends StatelessWidget {
|
|||||||
? Colors.white.withValues(alpha: 0.88)
|
? Colors.white.withValues(alpha: 0.88)
|
||||||
: Colors.white.withValues(alpha: 0.70);
|
: Colors.white.withValues(alpha: 0.70);
|
||||||
|
|
||||||
return AnimatedContainer(
|
return TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
fit: StackFit.passthrough,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bg,
|
color: bg,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: borderColor, width: selected ? 1.4 : 1.0),
|
border: Border.all(
|
||||||
|
color: borderColor,
|
||||||
|
width: selected ? 1.4 : 1.0,
|
||||||
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
@@ -491,7 +630,10 @@ class _QuizAnswerTile extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
splashFactory: InkSparkle.splashFactory,
|
splashFactory: InkSparkle.splashFactory,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -503,8 +645,11 @@ class _QuizAnswerTile extends StatelessWidget {
|
|||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
answer.imagePath!,
|
answer.imagePath!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (context, error, stackTrace) => Container(
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
Container(
|
||||||
|
color: Colors.black.withValues(
|
||||||
|
alpha: 0.06,
|
||||||
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.image_not_supported_outlined,
|
Icons.image_not_supported_outlined,
|
||||||
@@ -531,6 +676,33 @@ class _QuizAnswerTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
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 'dart:async';
|
||||||
|
|
||||||
import '../main.dart' show supabase;
|
import '../main.dart' show supabase;
|
||||||
|
import '../widgets/entrance.dart';
|
||||||
|
import '../widgets/tap_bounce.dart';
|
||||||
import 'quiz_prefs.dart';
|
import 'quiz_prefs.dart';
|
||||||
|
|
||||||
class QuizResultScreen extends StatefulWidget {
|
class QuizResultScreen extends StatefulWidget {
|
||||||
@@ -115,7 +117,8 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
const Text(
|
FadeSlideIn(
|
||||||
|
child: const Text(
|
||||||
'A percentagem de risco\navaliada é de:',
|
'A percentagem de risco\navaliada é de:',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -125,9 +128,17 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Center(
|
Center(
|
||||||
child: SizedBox(
|
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,
|
width: 220,
|
||||||
height: 220,
|
height: 220,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -137,7 +148,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: progress,
|
value: animatedProgress,
|
||||||
strokeWidth: 12,
|
strokeWidth: 12,
|
||||||
backgroundColor: Colors.black
|
backgroundColor: Colors.black
|
||||||
.withValues(alpha: 0.10),
|
.withValues(alpha: 0.10),
|
||||||
@@ -151,7 +162,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'$percent%',
|
'$animatedPercent%',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 34,
|
fontSize: 34,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
@@ -162,9 +173,8 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
Text(
|
Text(
|
||||||
'${clamped.toInt()}/${widget.maxScore}',
|
'${clamped.toInt()}/${widget.maxScore}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black.withValues(
|
color: Colors.black
|
||||||
alpha: 0.60,
|
.withValues(alpha: 0.60),
|
||||||
),
|
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -172,10 +182,14 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Text(
|
FadeSlideIn(
|
||||||
|
delay: const Duration(milliseconds: 120),
|
||||||
|
child: Text(
|
||||||
'Conclusões:',
|
'Conclusões:',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -183,8 +197,11 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
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.',
|
'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -193,6 +210,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
height: 1.25,
|
height: 1.25,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -210,6 +228,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
|
child: TapBounce(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 260,
|
width: 260,
|
||||||
height: 46,
|
height: 46,
|
||||||
@@ -231,6 +250,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import 'dart:math' as math;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lottie/lottie.dart';
|
import 'package:lottie/lottie.dart';
|
||||||
|
|
||||||
|
import '../widgets/entrance.dart';
|
||||||
|
import '../widgets/tap_bounce.dart';
|
||||||
|
|
||||||
class CuriosidadeScreen extends StatelessWidget {
|
class CuriosidadeScreen extends StatelessWidget {
|
||||||
const CuriosidadeScreen({super.key});
|
const CuriosidadeScreen({super.key});
|
||||||
|
|
||||||
@@ -68,25 +71,49 @@ class CuriosidadeScreen extends StatelessWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||||
children: [
|
children: [
|
||||||
_CuriosityTopicTile(
|
FadeSlideIn(
|
||||||
|
child: TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
|
child: _CuriosityTopicTile(
|
||||||
title: 'Tema X',
|
title: 'Tema X',
|
||||||
description: 'Aprenda dicas rápidas e simples para cuidar dos dentes no dia a dia.',
|
description:
|
||||||
|
'Aprenda dicas rápidas e simples para cuidar dos dentes no dia a dia.',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const _CuriosityTopicTile(
|
FadeSlideIn(
|
||||||
|
delay: const Duration(milliseconds: 60),
|
||||||
|
child: const TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
|
child: _CuriosityTopicTile(
|
||||||
title: 'Tema Y',
|
title: 'Tema Y',
|
||||||
description: 'Conteúdo em breve.',
|
description: 'Conteúdo em breve.',
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const _CuriosityTopicTile(
|
FadeSlideIn(
|
||||||
|
delay: const Duration(milliseconds: 120),
|
||||||
|
child: const TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
|
child: _CuriosityTopicTile(
|
||||||
title: 'Tema Z',
|
title: 'Tema Z',
|
||||||
description: 'Conteúdo em breve.',
|
description: 'Conteúdo em breve.',
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const _CuriosityTopicTile(
|
FadeSlideIn(
|
||||||
|
delay: const Duration(milliseconds: 180),
|
||||||
|
child: const TapBounce(
|
||||||
|
scale: 0.97,
|
||||||
|
child: _CuriosityTopicTile(
|
||||||
title: 'Tema U',
|
title: 'Tema U',
|
||||||
description: 'Conteúdo em breve.',
|
description: 'Conteúdo em breve.',
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -156,19 +183,23 @@ class _CuriosityTopicTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
SizedBox(
|
TapBounce(
|
||||||
|
child: SizedBox(
|
||||||
height: 44,
|
height: 44,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF2F9E94),
|
backgroundColor: const Color(0xFF2F9E94),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: const StadiumBorder(),
|
shape: const StadiumBorder(),
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.w900),
|
textStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
child: const Text('Fechar'),
|
child: const Text('Fechar'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,10 +12,19 @@ class HelloSplashScreen extends StatefulWidget {
|
|||||||
State<HelloSplashScreen> createState() => _HelloSplashScreenState();
|
State<HelloSplashScreen> createState() => _HelloSplashScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTickerProviderStateMixin {
|
class _HelloSplashScreenState extends State<HelloSplashScreen> with TickerProviderStateMixin {
|
||||||
late final AnimationController _controller;
|
late final AnimationController _controller;
|
||||||
late final Animation<double> _opacity;
|
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? _fadeTimer;
|
||||||
Timer? _doneTimer;
|
Timer? _doneTimer;
|
||||||
|
|
||||||
@@ -34,6 +43,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTicker
|
|||||||
);
|
);
|
||||||
|
|
||||||
_controller.value = 1.0;
|
_controller.value = 1.0;
|
||||||
|
_popController.forward();
|
||||||
|
|
||||||
final int fadeMs = (widget.duration.inMilliseconds - 500).clamp(0, widget.duration.inMilliseconds);
|
final int fadeMs = (widget.duration.inMilliseconds - 500).clamp(0, widget.duration.inMilliseconds);
|
||||||
_fadeTimer = Timer(Duration(milliseconds: fadeMs), () {
|
_fadeTimer = Timer(Duration(milliseconds: fadeMs), () {
|
||||||
@@ -52,6 +62,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTicker
|
|||||||
_fadeTimer?.cancel();
|
_fadeTimer?.cancel();
|
||||||
_doneTimer?.cancel();
|
_doneTimer?.cancel();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
|
_popController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +81,10 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTicker
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: const [
|
children: [
|
||||||
Text(
|
ScaleTransition(
|
||||||
|
scale: _pop,
|
||||||
|
child: const Text(
|
||||||
'Olá',
|
'Olá',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -81,6 +94,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with SingleTicker
|
|||||||
height: 1.0,
|
height: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../main.dart' show supabase;
|
import '../main.dart' show supabase;
|
||||||
import '../widgets/app_dialogs.dart';
|
import '../widgets/app_dialogs.dart';
|
||||||
|
import '../widgets/entrance.dart';
|
||||||
|
import '../widgets/tap_bounce.dart';
|
||||||
import 'terms_screen.dart';
|
import 'terms_screen.dart';
|
||||||
|
|
||||||
const Color _teal = Color(0xFF2F9E94);
|
const Color _teal = Color(0xFF2F9E94);
|
||||||
@@ -64,6 +66,10 @@ class _SettingsBodyState extends State<SettingsBody> {
|
|||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
FadeSlideIn(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_SectionLabel('Conta'),
|
_SectionLabel('Conta'),
|
||||||
_SettingsCard(
|
_SettingsCard(
|
||||||
@@ -81,7 +87,15 @@ class _SettingsBodyState extends State<SettingsBody> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
FadeSlideIn(
|
||||||
|
delay: const Duration(milliseconds: 80),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
_SectionLabel('Sobre'),
|
_SectionLabel('Sobre'),
|
||||||
_SettingsCard(
|
_SettingsCard(
|
||||||
children: [
|
children: [
|
||||||
@@ -89,7 +103,9 @@ class _SettingsBodyState extends State<SettingsBody> {
|
|||||||
icon: Icons.description_outlined,
|
icon: Icons.description_outlined,
|
||||||
title: 'Termos de Serviço',
|
title: 'Termos de Serviço',
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(builder: (_) => const TermsScreen()),
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => const TermsScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
@@ -100,7 +116,15 @@ class _SettingsBodyState extends State<SettingsBody> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
FadeSlideIn(
|
||||||
|
delay: const Duration(milliseconds: 160),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
_SectionLabel('Zona de risco'),
|
_SectionLabel('Zona de risco'),
|
||||||
_SettingsCard(
|
_SettingsCard(
|
||||||
children: [
|
children: [
|
||||||
@@ -113,6 +137,9 @@ class _SettingsBodyState extends State<SettingsBody> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -193,7 +220,9 @@ class _ActionTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return TapBounce(
|
||||||
|
scale: 0.98,
|
||||||
|
child: ListTile(
|
||||||
leading: Icon(icon, color: titleColor ?? _teal),
|
leading: Icon(icon, color: titleColor ?? _teal),
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
@@ -207,6 +236,7 @@ class _ActionTile extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: const Icon(Icons.chevron_right_rounded),
|
: const Icon(Icons.chevron_right_rounded),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import 'package:lottie/lottie.dart';
|
|||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import 'package:youtube_player_flutter/youtube_player_flutter.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.
|
// Video data structure - easily editable for future updates.
|
||||||
// Episódios 1-7 tocam via YouTube (não listado); preencha youtubeId ao subir
|
// 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).
|
// 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,
|
itemCount: _filteredVideos.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return _VideoButton(
|
return FadeSlideIn(
|
||||||
|
delay: Duration(
|
||||||
|
milliseconds: 40 * (index % 8),
|
||||||
|
),
|
||||||
|
child: _VideoButton(
|
||||||
video: _filteredVideos[index],
|
video: _filteredVideos[index],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -437,7 +445,9 @@ class _VideoButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return TapBounce(
|
||||||
|
scale: 0.95,
|
||||||
|
child: Material(
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
shadowColor: Colors.black.withValues(alpha: 0.12),
|
shadowColor: Colors.black.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(16),
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'tap_bounce.dart';
|
||||||
|
|
||||||
const Color _teal = Color(0xFF2F9E94);
|
const Color _teal = Color(0xFF2F9E94);
|
||||||
const Color _accentPink = Color(0xFFFF55A7);
|
const Color _accentPink = Color(0xFFFF55A7);
|
||||||
|
|
||||||
@@ -24,12 +26,15 @@ Future<bool?> showConfirmDialog(
|
|||||||
),
|
),
|
||||||
content: message == null ? null : Text(message),
|
content: message == null ? null : Text(message),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TapBounce(
|
||||||
|
child: TextButton(
|
||||||
style: TextButton.styleFrom(foregroundColor: _teal),
|
style: TextButton.styleFrom(foregroundColor: _teal),
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
child: Text(cancelLabel),
|
child: Text(cancelLabel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
),
|
||||||
|
TapBounce(
|
||||||
|
child: FilledButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: confirmColor,
|
backgroundColor: confirmColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
@@ -39,6 +44,7 @@ Future<bool?> showConfirmDialog(
|
|||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
child: Text(confirmLabel),
|
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