Nova tela de login | Adaptaçao nova da AppBar | Animções novas

This commit is contained in:
Carlos Correia
2026-07-07 21:40:05 +01:00
parent a8e04ceeb2
commit 2ced93afdd
15 changed files with 1680 additions and 1322 deletions

View File

@@ -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),
),
), ),
); );
} }

View File

@@ -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'),
), ),
), ),
), ),
),
], ],
), ),
], ],

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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,
); );

View File

@@ -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,
),
),
),
),
),
],
),
); );
} }
} }

View File

@@ -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> {
), ),
), ),
), ),
),
], ],
), ),
), ),

View File

@@ -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'),
), ),
), ),
),
], ],
), ),
), ),

View File

@@ -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,
), ),
), ),
),
], ],
), ),
), ),

View File

@@ -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,
),
); );
} }
} }

View File

@@ -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 {
), ),
), ),
), ),
),
); );
} }
} }

View 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),
);
}
}

View File

@@ -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
View 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),
);
}
}

View 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,
),
);
}
}