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,199 +20,505 @@ 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( clipBehavior: Clip.none,
body: SafeArea( children: [
child: Stack( Positioned.fill(
clipBehavior: Clip.none, child: Container(
children: [ decoration: const BoxDecoration(
Positioned.fill( gradient: LinearGradient(
child: Container( begin: Alignment.topCenter,
decoration: const BoxDecoration( end: Alignment.bottomCenter,
gradient: LinearGradient( colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
begin: Alignment.topCenter, ),
end: Alignment.bottomCenter, ),
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], ),
),
Positioned(
left: -size.width * 0.38,
bottom: -size.width * 0.38,
child: IgnorePointer(
child: SizedBox(
width: size.width * 1.05,
height: size.width * 1.05,
child: Transform.rotate(
angle: 35 * math.pi / 180,
child: Opacity(
opacity: 0.95,
child: Lottie.asset(
'lottie/Liquid waves.json',
fit: BoxFit.cover,
repeat: true,
), ),
), ),
), ),
), ),
Positioned( ),
left: -size.width * 0.38, ),
bottom: -size.width * 0.38, SafeArea(
child: IgnorePointer( child: LayoutBuilder(
child: SizedBox( builder: (context, constraints) {
width: size.width * 1.05, return SingleChildScrollView(
height: size.width * 1.05, padding: const EdgeInsets.fromLTRB(24, 28, 24, 20),
child: Transform.rotate( child: ConstrainedBox(
angle: 35 * math.pi / 180, constraints: BoxConstraints(
child: Opacity( minHeight: constraints.maxHeight,
opacity: 0.95, ),
child: Lottie.asset( child: IntrinsicHeight(
'lottie/Liquid waves.json', child: Column(
fit: BoxFit.cover, mainAxisAlignment: MainAxisAlignment.center,
repeat: true, children: [
), const FadeSlideIn(
child: Text(
'Check-Teeth Kids',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w900,
color: _pink,
height: 1.0,
letterSpacing: -0.5,
),
),
),
const SizedBox(height: 8),
FadeSlideIn(
delay: const Duration(milliseconds: 80),
child: Text(
'Organize a rotina de saúde oral com inteligência',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: Colors.black.withValues(alpha: 0.55),
),
),
),
const SizedBox(height: 26),
FadeSlideIn(
delay: const Duration(milliseconds: 140),
child: _AuthTabSwitch(
isLogin: _isLogin,
onChanged: _switchTab,
),
),
const SizedBox(height: 18),
FadeSlideIn(
delay: const Duration(milliseconds: 190),
child: _AuthForm(
formKey: _formKey,
isLogin: _isLogin,
loading: _loading,
nameController: _nameController,
emailController: _emailController,
passwordController: _passwordController,
onSubmit: _submit,
),
),
],
), ),
), ),
), ),
), );
},
),
),
],
),
);
}
}
class _AuthTabSwitch extends StatelessWidget {
const _AuthTabSwitch({required this.isLogin, required this.onChanged});
final bool isLogin;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(999),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: Row(
children: [
Expanded(
child: _AuthTab(
label: 'Entrar',
selected: isLogin,
onTap: () => onChanged(true),
),
),
Expanded(
child: _AuthTab(
label: 'Criar Conta',
selected: !isLogin,
onTap: () => onChanged(false),
),
),
],
),
);
}
}
class _AuthTab extends StatelessWidget {
const _AuthTab({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return TapBounce(
scale: 0.97,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? _teal : Colors.transparent,
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 14,
color: selected ? Colors.white : _teal,
), ),
Center( ),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Check-Teeth Kids',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
height: 1.0,
letterSpacing: -0.5,
),
),
const SizedBox(height: 10),
Text(
'Cuidar do sorriso começa aqui.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: const Color(0xFF2F9E94).withValues(alpha: 0.9),
),
),
const SizedBox(height: 6),
Text(
'Acompanhe a saúde oral do seu filho com\ninformação segura e prevenção inteligente.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
height: 1.35,
color: Colors.black.withValues(alpha: 0.52),
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 32),
SizedBox(
width: size.width * 0.78,
child: _PrimaryButton(
label: 'Cadastrar',
onPressed: _openRegister,
),
),
const SizedBox(height: 12),
SizedBox(
width: size.width * 0.78,
child: _SecondaryButton(
label: 'Entrar',
onPressed: _openLogin,
),
),
],
),
),
),
],
), ),
), ),
), ),
); );
} }
Future<void> _openLogin() async {
setState(() => _paused = true);
try {
await showLoginSheet(context);
} finally {
if (mounted) setState(() => _paused = false);
}
}
Future<void> _openRegister() async {
setState(() => _paused = true);
try {
await showRegisterSheet(context);
} finally {
if (mounted) setState(() => _paused = false);
}
}
} }
class _SecondaryButton extends StatelessWidget { class _AuthForm extends StatelessWidget {
const _SecondaryButton({required this.label, required this.onPressed}); const _AuthForm({
required this.formKey,
required this.isLogin,
required this.loading,
required this.nameController,
required this.emailController,
required this.passwordController,
required this.onSubmit,
});
final String label; final GlobalKey<FormState> formKey;
final VoidCallback onPressed; final bool isLogin;
final bool loading;
final TextEditingController nameController;
final TextEditingController emailController;
final TextEditingController passwordController;
final VoidCallback onSubmit;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const Color teal = Color(0xFF2F9E94); return Form(
return SizedBox( key: formKey,
height: 44, child: Column(
child: OutlinedButton( crossAxisAlignment: CrossAxisAlignment.stretch,
style: OutlinedButton.styleFrom( children: [
foregroundColor: teal, AnimatedSize(
side: const BorderSide(color: teal, width: 1.6), duration: const Duration(milliseconds: 220),
shape: const StadiumBorder(), curve: Curves.easeOutCubic,
backgroundColor: Colors.white.withValues(alpha: 0.5), alignment: Alignment.topCenter,
textStyle: const TextStyle(fontWeight: FontWeight.w800, fontSize: 15), child: !isLogin
), ? Column(
onPressed: onPressed, children: [
child: Text(label), _AuthTextField(
), controller: nameController,
); hintText: 'Digite seu nome',
} icon: Icons.person_outline_rounded,
} textInputAction: TextInputAction.next,
validator: (v) {
class _PrimaryButton extends StatelessWidget { final value = (v ?? '').trim();
const _PrimaryButton({required this.label, required this.onPressed}); if (value.isEmpty) return 'Informe seu nome';
if (value.length < 2) return 'Nome muito curto';
final String label; return null;
final VoidCallback onPressed; },
),
@override const SizedBox(height: 12),
Widget build(BuildContext context) { ],
final Color teal = const Color(0xFF2F9E94); )
return SizedBox( : const SizedBox.shrink(),
height: 44, ),
child: FilledButton( _AuthTextField(
style: controller: emailController,
FilledButton.styleFrom( hintText: 'Digite seu email',
backgroundColor: teal, icon: Icons.mail_outline_rounded,
foregroundColor: Colors.white, keyboardType: TextInputType.emailAddress,
shape: const StadiumBorder(), textInputAction: TextInputAction.next,
textStyle: const TextStyle( validator: (v) {
fontWeight: FontWeight.w800, final value = (v ?? '').trim();
fontSize: 15, if (value.isEmpty) return 'Informe seu email';
if (!value.contains('@')) return 'Email inválido';
return null;
},
),
const SizedBox(height: 12),
_AuthTextField(
controller: passwordController,
hintText: 'Digite sua senha',
icon: Icons.lock_outline_rounded,
obscureText: true,
textInputAction: TextInputAction.done,
validator: (v) {
final value = v ?? '';
if (value.isEmpty) return 'Informe sua senha';
if (value.length < 6) return 'Mínimo de 6 caracteres';
return null;
},
),
const SizedBox(height: 20),
TapBounce(
child: SizedBox(
height: 50,
child: FilledButton(
style:
FilledButton.styleFrom(
backgroundColor: _teal,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 15,
),
).copyWith(
animationDuration: const Duration(milliseconds: 180),
splashFactory: InkSparkle.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.white.withValues(alpha: 0.14);
}
if (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return Colors.white.withValues(alpha: 0.08);
}
return null;
}),
),
onPressed: loading ? null : onSubmit,
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Colors.white,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(isLogin ? 'Entrar' : 'Criar Conta'),
const SizedBox(width: 8),
const Icon(
Icons.arrow_forward_rounded,
size: 18,
),
],
),
), ),
).copyWith(
animationDuration: const Duration(milliseconds: 180),
splashFactory: InkSparkle.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.pressed)) {
return Colors.white.withValues(alpha: 0.14);
}
if (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return Colors.white.withValues(alpha: 0.08);
}
return null;
}),
), ),
onPressed: onPressed, ),
child: Text(label), ],
),
);
}
}
class _AuthTextField extends StatelessWidget {
const _AuthTextField({
required this.controller,
required this.hintText,
required this.icon,
required this.validator,
this.obscureText = false,
this.keyboardType,
this.textInputAction,
});
final TextEditingController controller;
final String hintText;
final IconData icon;
final FormFieldValidator<String> validator;
final bool obscureText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
textInputAction: textInputAction,
validator: validator,
style: const TextStyle(fontWeight: FontWeight.w700),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.black.withValues(alpha: 0.35),
),
prefixIcon: Icon(icon, color: _teal, size: 20),
border: InputBorder.none,
errorBorder: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
),
), ),
); );
} }

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,47 +258,41 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
opacity: 0.22, opacity: 0.22,
child: Transform.scale(scale: 1.25), child: Transform.scale(scale: 1.25),
), ),
Positioned( if (hasScore)
left: 0, Positioned(
right: 0, left: 0,
top: toolbarHeight + 26, right: 0,
child: Center( top: toolbarHeight + 26,
child: RichText( child: Center(
textAlign: TextAlign.center, child: Text(
text: TextSpan( (_selectedChildName ?? '').trim(),
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w800,
color: Colors.white.withValues(alpha: 0.92),
fontSize: 14,
),
),
),
)
else if ((_selectedChildName ?? '').trim().isNotEmpty)
Positioned(
left: 0,
right: 0,
top: toolbarHeight,
bottom: 0,
child: Center(
child: Text(
_selectedChildName!.trim(),
textAlign: TextAlign.center,
style: TextStyle( 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,
),
child: Text(
'',
style: TextStyle(color: Colors.white),
),
),
),
if (hasScore)
TextSpan(text: '$score/$maxScore'),
],
), ),
), ),
), ),
),
if (hasScore) if (hasScore)
Positioned( Positioned(
left: 0, left: 0,
@@ -308,63 +309,65 @@ 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: Material( child: TapBounce(
color: Colors.transparent, scale: 0.96,
child: InkWell( child: Material(
borderRadius: BorderRadius.circular(30), color: Colors.transparent,
onTap: () => setState(() => _index = 1), child: InkWell(
child: Padding( borderRadius: BorderRadius.circular(30),
padding: const EdgeInsets.symmetric( onTap: () => setState(() => _index = 1),
vertical: 4, child: Padding(
horizontal: 4, padding: const EdgeInsets.symmetric(
), vertical: 4,
child: Row( horizontal: 4,
mainAxisSize: MainAxisSize.min, ),
children: [ child: Row(
CircleAvatar( mainAxisSize: MainAxisSize.min,
radius: 20, children: [
backgroundColor: Colors.white.withValues( CircleAvatar(
alpha: 0.25, radius: 20,
backgroundColor: Colors.white
.withValues(alpha: 0.25),
backgroundImage:
(_cachedPhotoUrl ?? '').isNotEmpty
? NetworkImage(_cachedPhotoUrl!)
: null,
child: (_cachedPhotoUrl ?? '').isEmpty
? const Icon(
Icons.person_rounded,
color: Colors.white,
)
: null,
), ),
backgroundImage: const SizedBox(width: 10),
(_cachedPhotoUrl ?? '').isNotEmpty Column(
? NetworkImage(_cachedPhotoUrl!) crossAxisAlignment:
: null, CrossAxisAlignment.start,
child: (_cachedPhotoUrl ?? '').isEmpty mainAxisSize: MainAxisSize.min,
? const Icon( children: [
Icons.person_rounded, Text(
color: Colors.white, _greeting(),
) style: TextStyle(
: null, fontWeight: FontWeight.w600,
), color: Colors.white.withValues(
const SizedBox(width: 10), alpha: 0.85,
Column( ),
crossAxisAlignment: fontSize: 12,
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_greeting(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white.withValues(
alpha: 0.85,
), ),
fontSize: 12,
), ),
), Text(
Text( shownName,
shownName, textAlign: TextAlign.left,
textAlign: TextAlign.left, style: const TextStyle(
style: const TextStyle( fontWeight: FontWeight.w900,
fontWeight: FontWeight.w900, color: Colors.white,
color: Colors.white, fontSize: 19,
fontSize: 19, ),
), ),
), ],
], ),
), ],
], ),
), ),
), ),
), ),
@@ -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,29 +509,19 @@ 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,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
height: 1, height: 1,
),
), ),
const SizedBox(height: 2), ),
Text(
'',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.92),
fontSize: 8,
fontWeight: FontWeight.w900,
),
),
],
), ),
), ),
], ],
@@ -630,19 +636,30 @@ class _InicioTab extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_HeroQuizCard( FadeSlideIn(
childName: selectedChildName, child: TapBounce(
onStartQuiz: () => _startQuiz(context), scale: 0.97,
child: _HeroQuizCard(
childName: selectedChildName,
onStartQuiz: () => _startQuiz(context),
),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_VideoLibraryCard( FadeSlideIn(
onOpenLibrary: () { delay: const Duration(milliseconds: 90),
Navigator.of(context).push( child: TapBounce(
MaterialPageRoute<void>( scale: 0.97,
builder: (_) => const VideoScreen(), child: _VideoLibraryCard(
), onOpenLibrary: () {
); Navigator.of(context).push(
}, MaterialPageRoute<void>(
builder: (_) => const VideoScreen(),
),
);
},
),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@@ -741,7 +758,9 @@ 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: Material( child: TapBounce(
scale: 0.97,
child: Material(
color: Colors.white.withValues(alpha: 0.85), color: Colors.white.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: InkWell( child: InkWell(
@@ -770,6 +789,7 @@ Future<Map<String, dynamic>?> _pickChildSheet(
), ),
), ),
), ),
),
), ),
); );
}), }),
@@ -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,9 +1579,15 @@ 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: InkWell( child: TapBounce(
scale: 0.97,
child: InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
onTap: () => widget.onChildSelected( onTap: () => widget.onChildSelected(
i, i,
@@ -1662,47 +1693,53 @@ class _PerfilTabState extends State<_PerfilTab> {
], ],
), ),
), ),
),
),
), ),
); );
}), }),
SizedBox( TapBounce(
height: 48, child: SizedBox(
child: FilledButton.icon( height: 48,
style: FilledButton.styleFrom( child: FilledButton.icon(
backgroundColor: const Color(0xFF2F9E94), style: FilledButton.styleFrom(
foregroundColor: Colors.white, backgroundColor: const Color(0xFF2F9E94),
shape: const StadiumBorder(), foregroundColor: Colors.white,
textStyle: const TextStyle( shape: const StadiumBorder(),
fontWeight: FontWeight.w800, textStyle: const TextStyle(
fontWeight: FontWeight.w800,
),
), ),
onPressed: _addingChild
? null
: () => _addAnotherChild(context, uid),
icon: const Icon(Icons.add_rounded),
label: const Text('Adicionar criança'),
), ),
onPressed: _addingChild
? null
: () => _addAnotherChild(context, uid),
icon: const Icon(Icons.add_rounded),
label: const Text('Adicionar criança'),
), ),
), ),
const SizedBox(height: 22), const SizedBox(height: 22),
SizedBox( TapBounce(
height: 46, child: SizedBox(
child: OutlinedButton.icon( height: 46,
style: OutlinedButton.styleFrom( child: OutlinedButton.icon(
foregroundColor: const Color(0xFFFF55A7), style: OutlinedButton.styleFrom(
side: const BorderSide( foregroundColor: const Color(0xFFFF55A7),
color: Color(0xFFFF55A7), side: const BorderSide(
width: 1.4, color: Color(0xFFFF55A7),
), width: 1.4,
shape: const StadiumBorder(), ),
textStyle: const TextStyle( shape: const StadiumBorder(),
fontWeight: FontWeight.w800, textStyle: const TextStyle(
fontWeight: FontWeight.w800,
),
), ),
onPressed: () async {
await supabase.auth.signOut();
},
icon: const Icon(Icons.logout_rounded),
label: const Text('Sair'),
), ),
onPressed: () async {
await supabase.auth.signOut();
},
icon: const Icon(Icons.logout_rounded),
label: const Text('Sair'),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1844,17 +1881,21 @@ class _AddChildSheetState extends State<_AddChildSheet> {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: SizedBox( child: TapBounce(
height: 44, child: SizedBox(
child: FilledButton( height: 44,
style: FilledButton.styleFrom( child: FilledButton(
backgroundColor: const Color(0xFF2F9E94), style: FilledButton.styleFrom(
foregroundColor: Colors.white, backgroundColor: const Color(0xFF2F9E94),
shape: const StadiumBorder(), foregroundColor: Colors.white,
textStyle: const TextStyle(fontWeight: FontWeight.w900), shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
),
onPressed: _submit,
child: const Text('Adicionar'),
), ),
onPressed: _submit,
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,267 +133,391 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
), ),
), ),
SafeArea( SafeArea(
child: Center( child: Column(
child: ConstrainedBox( crossAxisAlignment: CrossAxisAlignment.stretch,
constraints: const BoxConstraints(maxWidth: 520), children: [
child: Column( SizedBox(
crossAxisAlignment: CrossAxisAlignment.stretch, height: 44,
children: [ child: Stack(
Padding( alignment: Alignment.center,
padding: const EdgeInsets.fromLTRB(20, 18, 20, 10), children: [
child: Column( Text(
crossAxisAlignment: CrossAxisAlignment.stretch, widget.title,
children: [ textAlign: TextAlign.center,
Text( style: TextStyle(
widget.title, color: Colors.black.withValues(alpha: 0.55),
textAlign: TextAlign.center, fontWeight: FontWeight.w800,
style: TextStyle( ),
color: Colors.black.withValues(alpha: 0.55),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
if (widget.questionImagePaths.isNotEmpty) ...[
const SizedBox(height: 6),
_QuestionReferenceImages(
paths: widget.questionImagePaths,
),
const SizedBox(height: 10),
],
if (widget.suggestedVideoPath != null ||
widget.suggestedYoutubeId != null) ...[
TextButton.icon(
onPressed: () => showVideoPlayerDialog(
context,
VideoData(
id: 0,
title:
widget.suggestedVideoTitle ?? 'Vídeo',
description: '',
videoPath: widget.suggestedVideoPath,
youtubeId: widget.suggestedYoutubeId,
),
),
icon: const Icon(
Icons.play_circle_outline_rounded,
color: Color(0xFF2F9E94),
),
label: Text(
widget.suggestedVideoTitle ??
'Ver vídeo (opcional)',
style: const TextStyle(
color: Color(0xFF2F9E94),
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 4),
],
Text(
widget.question,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
height: 1.2,
),
),
const SizedBox(height: 8),
Text(
widget.answerType == QuizAnswerType.number
? 'Insira o número'
: widget.answerType == QuizAnswerType.yesNo
? 'Escolha uma opção'
: 'Escolha apenas uma opção',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.55),
fontWeight: FontWeight.w700,
),
),
],
), ),
), if (widget.showBackButton)
Expanded( Positioned(
child: Padding( left: 4,
padding: const EdgeInsets.symmetric(horizontal: 20), child: TapBounce(
child: widget.answerType == QuizAnswerType.number scale: 0.9,
? _buildNumberInput() child: Material(
: ListView.separated( color: Colors.white.withValues(alpha: 0.85),
padding: const EdgeInsets.only(bottom: 12), shape: const CircleBorder(),
itemCount: widget.answers.length, elevation: 4,
separatorBuilder: (context, index) => shadowColor: Colors.black.withValues(
const SizedBox(height: 12), alpha: 0.15,
itemBuilder: (context, i) {
return _QuizAnswerTile(
answer: widget.answers[i],
selected: _selected == i,
onTap: () => setState(() => _selected = i),
);
},
), ),
), child: InkWell(
), customBorder: const CircleBorder(),
Padding( onTap: () => Navigator.of(context).maybePop(),
padding: const EdgeInsets.fromLTRB(20, 8, 20, 18), child: const Padding(
child: Column( padding: EdgeInsets.all(10),
children: [ child: Icon(
SizedBox( Icons.arrow_back_rounded,
width: size.width * 0.62, color: Color(0xFF2F9E94),
height: 46, size: 22,
child: FilledButton(
style:
FilledButton.styleFrom(
backgroundColor: const Color(0xFF2F9E94),
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
).copyWith(
animationDuration: const Duration(
milliseconds: 180,
),
splashFactory: InkSparkle.splashFactory,
overlayColor:
WidgetStateProperty.resolveWith<Color?>(
(states) {
if (states.contains(
WidgetState.pressed,
)) {
return Colors.white.withValues(
alpha: 0.14,
);
}
if (states.contains(
WidgetState.hovered,
) ||
states.contains(
WidgetState.focused,
)) {
return Colors.white.withValues(
alpha: 0.08,
);
}
return null;
},
),
), ),
onPressed: !canProceed ),
? null
: () {
setState(() => _navigating = true);
int nextScore = widget.currentScore;
if (widget.answerType ==
QuizAnswerType.number) {
nextScore =
widget.currentScore +
(_numberValue ?? 0);
} else {
final picked =
widget.answers[_selected!];
nextScore =
widget.currentScore + picked.weight;
}
if (widget.isFinal) {
final finishedRoute = widget.nextRoute(
context,
nextScore,
);
Navigator.of(
context,
).pushReplacement(finishedRoute);
return;
}
Navigator.of(context).push(
widget.nextRoute(context, nextScore),
);
},
child: Text(
widget.isFinal ? 'Concluir' : 'Avançar',
), ),
), ),
), ),
if (widget.showBackButton) ...[ ),
const SizedBox(height: 10), ],
SizedBox( ),
width: size.width * 0.62,
height: 42,
child: FilledButton(
style:
FilledButton.styleFrom(
backgroundColor: const Color(0xFF2F9E94),
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
).copyWith(
animationDuration: const Duration(
milliseconds: 180,
),
splashFactory: InkSparkle.splashFactory,
overlayColor:
WidgetStateProperty.resolveWith<
Color?
>((states) {
if (states.contains(
WidgetState.pressed,
)) {
return Colors.white.withValues(
alpha: 0.14,
);
}
if (states.contains(
WidgetState.hovered,
) ||
states.contains(
WidgetState.focused,
)) {
return Colors.white.withValues(
alpha: 0.08,
);
}
return null;
}),
),
onPressed: () =>
Navigator.of(context).maybePop(),
child: const Text('Voltar'),
),
),
],
const SizedBox(height: 10),
SizedBox(
width: size.width * 0.62,
height: 42,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF2F9E94),
side: const BorderSide(
color: Color(0xFF2F9E94),
width: 1.3,
),
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
),
onPressed: () => Navigator.of(
context,
).popUntil((route) => route.isFirst),
child: const Text('Voltar para homepage'),
),
),
],
),
),
],
), ),
), Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 520,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
FadeSlideIn(
child: Padding(
padding: const EdgeInsets.fromLTRB(
20,
4,
20,
10,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
if (widget
.questionImagePaths
.isNotEmpty) ...[
const SizedBox(height: 6),
_QuestionReferenceImages(
paths:
widget.questionImagePaths,
),
const SizedBox(height: 10),
],
if (hasSuggestedVideo) ...[
TextButton.icon(
onPressed: () =>
showVideoPlayerDialog(
context,
VideoData(
id: 0,
title:
widget
.suggestedVideoTitle ??
'Vídeo',
description: '',
videoPath: widget
.suggestedVideoPath,
youtubeId: widget
.suggestedYoutubeId,
),
),
icon: const Icon(
Icons
.play_circle_outline_rounded,
color: Color(0xFF2F9E94),
),
label: Text(
widget.suggestedVideoTitle ??
'Ver vídeo (opcional)',
style: const TextStyle(
color: Color(0xFF2F9E94),
fontWeight:
FontWeight.w800,
),
),
),
const SizedBox(height: 4),
],
Text(
widget.question,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFF55A7),
height: 1.2,
),
),
const SizedBox(height: 8),
Text(
widget.answerType ==
QuizAnswerType.number
? 'Insira o número'
: widget.answerType ==
QuizAnswerType.yesNo
? 'Escolha uma opção'
: 'Escolha apenas uma opção',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black
.withValues(alpha: 0.55),
fontWeight: FontWeight.w700,
),
),
],
),
),
),
const SizedBox(height: 18),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child:
widget.answerType ==
QuizAnswerType.number
? _buildNumberInput()
: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (
int i = 0;
i < widget.answers.length;
i++
) ...[
if (i > 0)
const SizedBox(height: 12),
FadeSlideIn(
delay: Duration(
milliseconds: 60 * i,
),
child: _QuizAnswerTile(
answer:
widget.answers[i],
selected:
_selected == i,
onTap: () => setState(
() => _selected = i,
),
),
),
],
],
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.fromLTRB(
20,
0,
20,
0,
),
child: Column(
children: [
TapBounce(
child: SizedBox(
width: size.width * 0.62,
height: 46,
child: FilledButton(
style:
FilledButton.styleFrom(
backgroundColor:
const Color(
0xFF2F9E94,
),
foregroundColor:
Colors.white,
shape:
const StadiumBorder(),
textStyle:
const TextStyle(
fontWeight:
FontWeight
.w900,
),
).copyWith(
animationDuration:
const Duration(
milliseconds: 180,
),
splashFactory: InkSparkle
.splashFactory,
overlayColor:
WidgetStateProperty.resolveWith<
Color?
>((states) {
if (states
.contains(
WidgetState
.pressed,
)) {
return Colors
.white
.withValues(
alpha:
0.14,
);
}
if (states.contains(
WidgetState
.hovered,
) ||
states.contains(
WidgetState
.focused,
)) {
return Colors
.white
.withValues(
alpha:
0.08,
);
}
return null;
}),
),
onPressed: !canProceed
? null
: () async {
setState(
() => _navigating =
true,
);
int nextScore =
widget
.currentScore;
if (widget.answerType ==
QuizAnswerType
.number) {
nextScore =
widget
.currentScore +
(_numberValue ??
0);
} else {
final picked = widget
.answers[_selected!];
nextScore =
widget
.currentScore +
picked.weight;
}
if (widget.isFinal) {
final finishedRoute =
widget.nextRoute(
context,
nextScore,
);
Navigator.of(
context,
).pushReplacement(
finishedRoute,
);
return;
}
await Navigator.of(
context,
).push(
widget.nextRoute(
context,
nextScore,
),
);
if (mounted) {
setState(
() => _navigating =
false,
);
}
},
child: Text(
widget.isFinal
? 'Concluir'
: 'Avançar',
),
),
),
),
const SizedBox(height: 10),
TapBounce(
child: SizedBox(
width: size.width * 0.62,
height: 42,
child: OutlinedButton(
style:
OutlinedButton.styleFrom(
foregroundColor:
const Color(
0xFF2F9E94,
),
side: const BorderSide(
color: Color(
0xFF2F9E94,
),
width: 1.3,
),
shape:
const StadiumBorder(),
textStyle:
const TextStyle(
fontWeight:
FontWeight
.w900,
),
),
onPressed: () =>
Navigator.of(
context,
).popUntil(
(route) =>
route.isFirst,
),
child: const Text(
'Voltar para homepage',
),
),
),
),
],
),
),
],
),
),
),
),
),
);
},
),
),
],
), ),
), ),
], ],
@@ -469,67 +599,109 @@ class _QuizAnswerTile extends StatelessWidget {
? Colors.white.withValues(alpha: 0.88) ? Colors.white.withValues(alpha: 0.88)
: Colors.white.withValues(alpha: 0.70); : Colors.white.withValues(alpha: 0.70);
return AnimatedContainer( return TapBounce(
duration: const Duration(milliseconds: 220), scale: 0.97,
curve: Curves.easeOutCubic, child: Stack(
decoration: BoxDecoration( clipBehavior: Clip.none,
color: bg, fit: StackFit.passthrough,
borderRadius: BorderRadius.circular(16), children: [
border: Border.all(color: borderColor, width: selected ? 1.4 : 1.0), AnimatedContainer(
boxShadow: [ duration: const Duration(milliseconds: 220),
BoxShadow( curve: Curves.easeOutCubic,
color: Colors.black.withValues(alpha: 0.06), decoration: BoxDecoration(
blurRadius: 18, color: bg,
offset: const Offset(0, 10), borderRadius: BorderRadius.circular(16),
), border: Border.all(
], color: borderColor,
), width: selected ? 1.4 : 1.0,
child: Material( ),
color: Colors.transparent, boxShadow: [
child: InkWell( BoxShadow(
borderRadius: BorderRadius.circular(16), color: Colors.black.withValues(alpha: 0.06),
onTap: onTap, blurRadius: 18,
splashFactory: InkSparkle.splashFactory, offset: const Offset(0, 10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (answer.imagePath != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 4 / 3,
child: Image.asset(
answer.imagePath!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.black.withValues(alpha: 0.06),
child: const Center(
child: Icon(
Icons.image_not_supported_outlined,
color: Colors.black38,
),
),
),
),
),
),
const SizedBox(height: 10),
],
Text(
answer.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFF2F9E94),
),
), ),
], ],
), ),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
splashFactory: InkSparkle.splashFactory,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (answer.imagePath != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 4 / 3,
child: Image.asset(
answer.imagePath!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Container(
color: Colors.black.withValues(
alpha: 0.06,
),
child: const Center(
child: Icon(
Icons.image_not_supported_outlined,
color: Colors.black38,
),
),
),
),
),
),
const SizedBox(height: 10),
],
Text(
answer.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFF2F9E94),
),
),
],
),
),
),
),
), ),
), Positioned(
top: 8,
right: 8,
child: IgnorePointer(
child: AnimatedScale(
scale: selected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutBack,
child: Container(
width: 22,
height: 22,
decoration: const BoxDecoration(
color: Color(0xFF2F9E94),
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_rounded,
size: 15,
color: Colors.white,
),
),
),
),
),
],
), ),
); );
} }

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,82 +117,98 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 6), const SizedBox(height: 6),
const Text( FadeSlideIn(
'A percentagem de risco\navaliada é de:', child: const Text(
textAlign: TextAlign.center, 'A percentagem de risco\navaliada é de:',
style: TextStyle( textAlign: TextAlign.center,
fontSize: 18, style: TextStyle(
fontWeight: FontWeight.w900, fontSize: 18,
color: Color(0xFFFF55A7), fontWeight: FontWeight.w900,
height: 1.2, color: Color(0xFFFF55A7),
), height: 1.2,
),
const SizedBox(height: 18),
Center(
child: SizedBox(
width: 220,
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 200,
height: 200,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 12,
backgroundColor: Colors.black
.withValues(alpha: 0.10),
valueColor:
const AlwaysStoppedAnimation(
Color(0xFF2F9E94),
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$percent%',
style: const TextStyle(
fontSize: 34,
fontWeight: FontWeight.w900,
color: Colors.black,
),
),
const SizedBox(height: 4),
Text(
'${clamped.toInt()}/${widget.maxScore}',
style: TextStyle(
color: Colors.black.withValues(
alpha: 0.60,
),
fontWeight: FontWeight.w800,
),
),
],
),
],
), ),
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
Text( Center(
'Conclusões:', child: TweenAnimationBuilder<double>(
textAlign: TextAlign.center, duration: const Duration(milliseconds: 1100),
style: TextStyle( curve: Curves.easeOutCubic,
color: Colors.black.withValues(alpha: 0.75), tween: Tween<double>(begin: 0, end: progress),
fontWeight: FontWeight.w900, builder: (context, animatedProgress, _) {
final animatedPercent =
(animatedProgress * 100).round();
return SizedBox(
width: 220,
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 200,
height: 200,
child: CircularProgressIndicator(
value: animatedProgress,
strokeWidth: 12,
backgroundColor: Colors.black
.withValues(alpha: 0.10),
valueColor:
const AlwaysStoppedAnimation(
Color(0xFF2F9E94),
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$animatedPercent%',
style: const TextStyle(
fontSize: 34,
fontWeight: FontWeight.w900,
color: Colors.black,
),
),
const SizedBox(height: 4),
Text(
'${clamped.toInt()}/${widget.maxScore}',
style: TextStyle(
color: Colors.black
.withValues(alpha: 0.60),
fontWeight: FontWeight.w800,
),
),
],
),
],
),
);
},
),
),
const SizedBox(height: 18),
FadeSlideIn(
delay: const Duration(milliseconds: 120),
child: Text(
'Conclusões:',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.75),
fontWeight: FontWeight.w900,
),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( FadeSlideIn(
'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.', delay: const Duration(milliseconds: 160),
textAlign: TextAlign.center, child: Text(
style: TextStyle( 'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.',
color: Colors.black.withValues(alpha: 0.70), textAlign: TextAlign.center,
fontWeight: FontWeight.w600, style: TextStyle(
height: 1.25, color: Colors.black.withValues(alpha: 0.70),
fontWeight: FontWeight.w600,
height: 1.25,
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -210,7 +228,8 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
), ),
), ),
Center( Center(
child: SizedBox( child: TapBounce(
child: SizedBox(
width: 260, width: 260,
height: 46, height: 46,
child: FilledButton( child: FilledButton(
@@ -229,6 +248,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
}, },
child: const Text('Avançar'), child: const Text('Avançar'),
), ),
),
), ),
), ),
], ],

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,24 +71,48 @@ 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(
title: 'Tema X', child: TapBounce(
description: 'Aprenda dicas rápidas e simples para cuidar dos dentes no dia a dia.', scale: 0.97,
child: _CuriosityTopicTile(
title: 'Tema X',
description:
'Aprenda dicas rápidas e simples para cuidar dos dentes no dia a dia.',
),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const _CuriosityTopicTile( FadeSlideIn(
title: 'Tema Y', delay: const Duration(milliseconds: 60),
description: 'Conteúdo em breve.', child: const TapBounce(
scale: 0.97,
child: _CuriosityTopicTile(
title: 'Tema Y',
description: 'Conteúdo em breve.',
),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const _CuriosityTopicTile( FadeSlideIn(
title: 'Tema Z', delay: const Duration(milliseconds: 120),
description: 'Conteúdo em breve.', child: const TapBounce(
scale: 0.97,
child: _CuriosityTopicTile(
title: 'Tema Z',
description: 'Conteúdo em breve.',
),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
const _CuriosityTopicTile( FadeSlideIn(
title: 'Tema U', delay: const Duration(milliseconds: 180),
description: 'Conteúdo em breve.', child: const TapBounce(
scale: 0.97,
child: _CuriosityTopicTile(
title: 'Tema U',
description: 'Conteúdo em breve.',
),
),
), ),
], ],
), ),
@@ -156,17 +183,21 @@ class _CuriosityTopicTile extends StatelessWidget {
), ),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
SizedBox( TapBounce(
height: 44, child: SizedBox(
child: FilledButton( height: 44,
style: FilledButton.styleFrom( child: FilledButton(
backgroundColor: const Color(0xFF2F9E94), style: FilledButton.styleFrom(
foregroundColor: Colors.white, backgroundColor: const Color(0xFF2F9E94),
shape: const StadiumBorder(), foregroundColor: Colors.white,
textStyle: const TextStyle(fontWeight: FontWeight.w900), shape: const StadiumBorder(),
textStyle: const TextStyle(
fontWeight: FontWeight.w900,
),
),
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Fechar'),
), ),
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Fechar'),
), ),
), ),
], ],

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,15 +81,18 @@ 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(
'Olá', scale: _pop,
textAlign: TextAlign.center, child: const Text(
style: TextStyle( 'Olá',
fontSize: 64, textAlign: TextAlign.center,
fontWeight: FontWeight.w900, style: TextStyle(
color: Colors.white, fontSize: 64,
height: 1.0, fontWeight: FontWeight.w900,
color: Colors.white,
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);
@@ -65,53 +67,78 @@ class _SettingsBodyState extends State<SettingsBody> {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_SectionLabel('Conta'), FadeSlideIn(
_SettingsCard( child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
_InfoTile( children: [
icon: Icons.person_outline_rounded, _SectionLabel('Conta'),
title: name.isEmpty ? 'Sem nome' : name, _SettingsCard(
subtitle: email, children: [
), _InfoTile(
const Divider(height: 1), icon: Icons.person_outline_rounded,
_ActionTile( title: name.isEmpty ? 'Sem nome' : name,
icon: Icons.logout_rounded, subtitle: email,
title: 'Sair', ),
onTap: _signOut, const Divider(height: 1),
), _ActionTile(
], icon: Icons.logout_rounded,
), title: 'Sair',
const SizedBox(height: 20), onTap: _signOut,
_SectionLabel('Sobre'), ),
_SettingsCard( ],
children: [
_ActionTile(
icon: Icons.description_outlined,
title: 'Termos de Serviço',
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const TermsScreen()),
), ),
), ],
const Divider(height: 1), ),
const _InfoTile(
icon: Icons.info_outline_rounded,
title: 'Versão do app',
subtitle: '1.0.0',
),
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_SectionLabel('Zona de risco'), FadeSlideIn(
_SettingsCard( delay: const Duration(milliseconds: 80),
children: [ child: Column(
_ActionTile( crossAxisAlignment: CrossAxisAlignment.stretch,
icon: Icons.delete_forever_rounded, children: [
title: 'Apagar dados da conta', _SectionLabel('Sobre'),
titleColor: _accentPink, _SettingsCard(
loading: _deletingAccount, children: [
onTap: _deletingAccount ? null : _confirmDeleteAccountData, _ActionTile(
), icon: Icons.description_outlined,
], title: 'Termos de Serviço',
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const TermsScreen(),
),
),
),
const Divider(height: 1),
const _InfoTile(
icon: Icons.info_outline_rounded,
title: 'Versão do app',
subtitle: '1.0.0',
),
],
),
],
),
),
const SizedBox(height: 20),
FadeSlideIn(
delay: const Duration(milliseconds: 160),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SectionLabel('Zona de risco'),
_SettingsCard(
children: [
_ActionTile(
icon: Icons.delete_forever_rounded,
title: 'Apagar dados da conta',
titleColor: _accentPink,
loading: _deletingAccount,
onTap: _deletingAccount ? null : _confirmDeleteAccountData,
),
],
),
],
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
@@ -193,20 +220,23 @@ class _ActionTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return TapBounce(
leading: Icon(icon, color: titleColor ?? _teal), scale: 0.98,
title: Text( child: ListTile(
title, leading: Icon(icon, color: titleColor ?? _teal),
style: TextStyle(fontWeight: FontWeight.w800, color: titleColor), title: Text(
title,
style: TextStyle(fontWeight: FontWeight.w800, color: titleColor),
),
trailing: loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right_rounded),
onTap: onTap,
), ),
trailing: loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right_rounded),
onTap: onTap,
); );
} }
} }

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(
video: _filteredVideos[index], delay: Duration(
milliseconds: 40 * (index % 8),
),
child: _VideoButton(
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,20 +26,24 @@ Future<bool?> showConfirmDialog(
), ),
content: message == null ? null : Text(message), content: message == null ? null : Text(message),
actions: [ actions: [
TextButton( TapBounce(
style: TextButton.styleFrom(foregroundColor: _teal), child: TextButton(
onPressed: () => Navigator.of(ctx).pop(false), style: TextButton.styleFrom(foregroundColor: _teal),
child: Text(cancelLabel), onPressed: () => Navigator.of(ctx).pop(false),
), child: Text(cancelLabel),
FilledButton( ),
style: FilledButton.styleFrom( ),
backgroundColor: confirmColor, TapBounce(
foregroundColor: Colors.white, child: FilledButton(
shape: const StadiumBorder(), style: FilledButton.styleFrom(
textStyle: const TextStyle(fontWeight: FontWeight.w800), backgroundColor: confirmColor,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(confirmLabel),
), ),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(confirmLabel),
), ),
], ],
); );

64
lib/widgets/entrance.dart Normal file
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,
),
);
}
}