diff --git a/lib/home_screen.dart b/lib/home_screen.dart index e8809d9..fcae4d9 100644 --- a/lib/home_screen.dart +++ b/lib/home_screen.dart @@ -1,10 +1,16 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; -import 'login_register/login_sheet.dart'; -import 'login_register/register_sheet.dart'; +import 'main.dart' show supabase; +import 'widgets/entrance.dart'; +import 'widgets/tap_bounce.dart'; + +const Color _teal = Color(0xFF2F9E94); +const Color _pink = Color(0xFFFF55A7); class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -14,199 +20,505 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - bool _paused = false; + final _formKey = GlobalKey(); + 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 _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 _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _loading = true); + try { + final email = _emailController.text.trim(); + final password = _passwordController.text; + + if (_isLogin) { + await supabase.auth.signInWithPassword( + email: email, + password: password, + ); + } else { + final name = _nameController.text.trim(); + final response = await supabase.auth + .signUp(email: email, password: password, data: {'name': name}) + .timeout(const Duration(seconds: 20)); + + final user = response.user; + if (user == null) { + throw StateError('Usuário não encontrado após criar conta.'); + } + + unawaited( + _persistRegistrationData( + uid: user.id, + name: name, + email: email, + ).catchError((_) {}), + ); + } + } on AuthException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_friendlyAuthError(e)))); + } on TimeoutException { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Tempo esgotado. Verifique sua conexão e tente novamente.', + ), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Erro: $e'))); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + String _friendlyAuthError(AuthException e) { + switch (e.code) { + case 'invalid_credentials': + return 'Email ou senha incorretos.'; + case 'user_not_found': + return 'Usuário não encontrado.'; + case 'email_exists': + case 'user_already_exists': + return 'Este email já está em uso.'; + case 'weak_password': + return 'Senha fraca. Use pelo menos 6 caracteres.'; + default: + return e.message; + } + } @override Widget build(BuildContext context) { final Size size = MediaQuery.sizeOf(context); - return IgnorePointer( - ignoring: _paused, - child: Scaffold( - body: SafeArea( - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], + return Scaffold( + body: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], + ), + ), + ), + ), + Positioned( + left: -size.width * 0.38, + bottom: -size.width * 0.38, + child: IgnorePointer( + child: SizedBox( + width: size.width * 1.05, + height: size.width * 1.05, + child: Transform.rotate( + angle: 35 * math.pi / 180, + child: Opacity( + opacity: 0.95, + child: Lottie.asset( + 'lottie/Liquid waves.json', + fit: BoxFit.cover, + repeat: true, ), ), ), ), - Positioned( - left: -size.width * 0.38, - bottom: -size.width * 0.38, - child: IgnorePointer( - child: SizedBox( - width: size.width * 1.05, - height: size.width * 1.05, - child: Transform.rotate( - angle: 35 * math.pi / 180, - child: Opacity( - opacity: 0.95, - child: Lottie.asset( - 'lottie/Liquid waves.json', - fit: BoxFit.cover, - repeat: true, - ), + ), + ), + SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 20), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const FadeSlideIn( + child: Text( + 'Check-Teeth Kids', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w900, + color: _pink, + height: 1.0, + letterSpacing: -0.5, + ), + ), + ), + const SizedBox(height: 8), + FadeSlideIn( + delay: const Duration(milliseconds: 80), + child: Text( + 'Organize a rotina de saúde oral com inteligência', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: Colors.black.withValues(alpha: 0.55), + ), + ), + ), + const SizedBox(height: 26), + FadeSlideIn( + delay: const Duration(milliseconds: 140), + child: _AuthTabSwitch( + isLogin: _isLogin, + onChanged: _switchTab, + ), + ), + const SizedBox(height: 18), + FadeSlideIn( + delay: const Duration(milliseconds: 190), + child: _AuthForm( + formKey: _formKey, + isLogin: _isLogin, + loading: _loading, + nameController: _nameController, + emailController: _emailController, + passwordController: _passwordController, + onSubmit: _submit, + ), + ), + ], ), ), ), - ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _AuthTabSwitch extends StatelessWidget { + const _AuthTabSwitch({required this.isLogin, required this.onChanged}); + + final bool isLogin; + final ValueChanged 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 _openLogin() async { - setState(() => _paused = true); - try { - await showLoginSheet(context); - } finally { - if (mounted) setState(() => _paused = false); - } - } - - Future _openRegister() async { - setState(() => _paused = true); - try { - await showRegisterSheet(context); - } finally { - if (mounted) setState(() => _paused = false); - } - } } -class _SecondaryButton extends StatelessWidget { - const _SecondaryButton({required this.label, required this.onPressed}); +class _AuthForm extends StatelessWidget { + const _AuthForm({ + required this.formKey, + required this.isLogin, + required this.loading, + required this.nameController, + required this.emailController, + required this.passwordController, + required this.onSubmit, + }); - final String label; - final VoidCallback onPressed; + final GlobalKey formKey; + final bool isLogin; + final bool loading; + final TextEditingController nameController; + final TextEditingController emailController; + final TextEditingController passwordController; + final VoidCallback onSubmit; @override Widget build(BuildContext context) { - const Color teal = Color(0xFF2F9E94); - return SizedBox( - height: 44, - child: OutlinedButton( - style: OutlinedButton.styleFrom( - foregroundColor: teal, - side: const BorderSide(color: teal, width: 1.6), - shape: const StadiumBorder(), - backgroundColor: Colors.white.withValues(alpha: 0.5), - textStyle: const TextStyle(fontWeight: FontWeight.w800, fontSize: 15), - ), - onPressed: onPressed, - child: Text(label), - ), - ); - } -} - -class _PrimaryButton extends StatelessWidget { - const _PrimaryButton({required this.label, required this.onPressed}); - - final String label; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final Color teal = const Color(0xFF2F9E94); - return SizedBox( - height: 44, - child: FilledButton( - style: - FilledButton.styleFrom( - backgroundColor: teal, - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 15, + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: !isLogin + ? Column( + children: [ + _AuthTextField( + controller: nameController, + hintText: 'Digite seu nome', + icon: Icons.person_outline_rounded, + textInputAction: TextInputAction.next, + validator: (v) { + final value = (v ?? '').trim(); + if (value.isEmpty) return 'Informe seu nome'; + if (value.length < 2) return 'Nome muito curto'; + return null; + }, + ), + const SizedBox(height: 12), + ], + ) + : const SizedBox.shrink(), + ), + _AuthTextField( + controller: emailController, + hintText: 'Digite seu email', + icon: Icons.mail_outline_rounded, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: (v) { + final value = (v ?? '').trim(); + if (value.isEmpty) return 'Informe seu email'; + if (!value.contains('@')) return 'Email inválido'; + return null; + }, + ), + const SizedBox(height: 12), + _AuthTextField( + controller: passwordController, + hintText: 'Digite sua senha', + icon: Icons.lock_outline_rounded, + obscureText: true, + textInputAction: TextInputAction.done, + validator: (v) { + final value = v ?? ''; + if (value.isEmpty) return 'Informe sua senha'; + if (value.length < 6) return 'Mínimo de 6 caracteres'; + return null; + }, + ), + const SizedBox(height: 20), + TapBounce( + child: SizedBox( + height: 50, + child: FilledButton( + style: + FilledButton.styleFrom( + backgroundColor: _teal, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + ), + ).copyWith( + animationDuration: const Duration(milliseconds: 180), + splashFactory: InkSparkle.splashFactory, + overlayColor: WidgetStateProperty.resolveWith(( + 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((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 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), + ), ), ); } diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 3766a4b..cb8b734 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:lottie/lottie.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -12,7 +13,10 @@ import 'quiz/quiz1.dart'; import 'quiz/quiz_prefs.dart'; import 'screens/settings_screen.dart'; import 'screens/video_screen.dart'; +import 'widgets/animated_nav_icon.dart'; import 'widgets/app_dialogs.dart'; +import 'widgets/entrance.dart'; +import 'widgets/tap_bounce.dart'; class LoggedHomeScreen extends StatefulWidget { const LoggedHomeScreen({super.key}); @@ -28,6 +32,7 @@ class _LoggedHomeScreenState extends State static const double _collapsedAppBarHeight = 104; static const double _expandedAppBarHeight = 180; + static const double _nameOnlyAppBarHeight = 130; int _index = 0; @@ -207,8 +212,14 @@ class _LoggedHomeScreenState extends State @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); + + final int? score = _lastScore; + final int? maxScore = _lastMaxScore; + final bool hasScore = score != null && maxScore != null && maxScore > 0; + final int percent = hasScore ? ((score / maxScore) * 100).round() : 0; + final double appBarHeight = _index == 0 - ? _expandedAppBarHeight + ? (hasScore ? _expandedAppBarHeight : _nameOnlyAppBarHeight) : _collapsedAppBarHeight; final double toolbarHeight = _index == 0 ? kToolbarHeight : appBarHeight; final String title = _index == 0 @@ -224,10 +235,6 @@ class _LoggedHomeScreenState extends State final shownName = _cachedUserName; - final int? score = _lastScore; - final int? maxScore = _lastMaxScore; - final bool hasScore = score != null && maxScore != null && maxScore > 0; - final int percent = hasScore ? ((score / maxScore) * 100).round() : 0; final double bodyTopPadding = _index == 0 ? 0 : 10; return Scaffold( @@ -251,47 +258,41 @@ class _LoggedHomeScreenState extends State opacity: 0.22, child: Transform.scale(scale: 1.25), ), - Positioned( - left: 0, - right: 0, - top: toolbarHeight + 26, - child: Center( - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( + if (hasScore) + Positioned( + left: 0, + right: 0, + top: toolbarHeight + 26, + child: Center( + child: Text( + (_selectedChildName ?? '').trim(), + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w800, + color: Colors.white.withValues(alpha: 0.92), + fontSize: 14, + ), + ), + ), + ) + else if ((_selectedChildName ?? '').trim().isNotEmpty) + Positioned( + left: 0, + right: 0, + top: toolbarHeight, + bottom: 0, + child: Center( + child: Text( + _selectedChildName!.trim(), + textAlign: TextAlign.center, style: TextStyle( fontWeight: FontWeight.w800, color: Colors.white.withValues(alpha: 0.92), fontSize: 14, ), - children: [ - if (((_selectedChildName ?? '') - .trim() - .isNotEmpty)) - TextSpan(text: _selectedChildName!.trim()), - if (((_selectedChildName ?? '') - .trim() - .isNotEmpty) && - hasScore) - const WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 12, - ), - child: Text( - '•', - style: TextStyle(color: Colors.white), - ), - ), - ), - if (hasScore) - TextSpan(text: '$score/$maxScore'), - ], ), ), ), - ), if (hasScore) Positioned( left: 0, @@ -308,63 +309,65 @@ class _LoggedHomeScreenState extends State child: _index == 0 ? Padding( padding: const EdgeInsets.only(left: 16, right: 10), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(30), - onTap: () => setState(() => _index = 1), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 4, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - CircleAvatar( - radius: 20, - backgroundColor: Colors.white.withValues( - alpha: 0.25, + child: TapBounce( + scale: 0.96, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(30), + onTap: () => setState(() => _index = 1), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 20, + backgroundColor: Colors.white + .withValues(alpha: 0.25), + backgroundImage: + (_cachedPhotoUrl ?? '').isNotEmpty + ? NetworkImage(_cachedPhotoUrl!) + : null, + child: (_cachedPhotoUrl ?? '').isEmpty + ? const Icon( + Icons.person_rounded, + color: Colors.white, + ) + : null, ), - backgroundImage: - (_cachedPhotoUrl ?? '').isNotEmpty - ? NetworkImage(_cachedPhotoUrl!) - : null, - child: (_cachedPhotoUrl ?? '').isEmpty - ? const Icon( - Icons.person_rounded, - color: Colors.white, - ) - : null, - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _greeting(), - style: TextStyle( - fontWeight: FontWeight.w600, - color: Colors.white.withValues( - alpha: 0.85, + const SizedBox(width: 10), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _greeting(), + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white.withValues( + alpha: 0.85, + ), + fontSize: 12, ), - fontSize: 12, ), - ), - Text( - shownName, - textAlign: TextAlign.left, - style: const TextStyle( - fontWeight: FontWeight.w900, - color: Colors.white, - fontSize: 19, + Text( + shownName, + textAlign: TextAlign.left, + style: const TextStyle( + fontWeight: FontWeight.w900, + color: Colors.white, + fontSize: 19, + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), @@ -442,22 +445,35 @@ class _LoggedHomeScreenState extends State ), bottomNavigationBar: BottomNavigationBar( currentIndex: _index, - onTap: (i) => setState(() => _index = i), + onTap: (i) { + if (i == _index) return; + HapticFeedback.selectionClick(); + setState(() => _index = i); + }, backgroundColor: const Color(0xFFFFE6F1), selectedItemColor: _teal, unselectedItemColor: Colors.black54, type: BottomNavigationBarType.fixed, - items: const [ + items: [ BottomNavigationBarItem( - icon: Icon(Icons.home_rounded), + icon: AnimatedNavIcon( + icon: Icons.home_rounded, + selected: _index == 0, + ), label: 'Início', ), BottomNavigationBarItem( - icon: Icon(Icons.person_rounded), + icon: AnimatedNavIcon( + icon: Icons.person_rounded, + selected: _index == 1, + ), label: 'Perfil', ), BottomNavigationBarItem( - icon: Icon(Icons.settings_rounded), + icon: AnimatedNavIcon( + icon: Icons.settings_rounded, + selected: _index == 2, + ), label: 'Ajustes', ), ], @@ -485,7 +501,7 @@ class _RiskArcGauge extends StatelessWidget { width: 120, height: 60, child: Stack( - alignment: Alignment.center, + clipBehavior: Clip.none, children: [ Positioned.fill( child: CustomPaint( @@ -493,29 +509,19 @@ class _RiskArcGauge extends StatelessWidget { ), ), Positioned( - bottom: 4, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '$shown%', - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w900, - height: 1, - ), + top: 38, + left: 6, + right: 0, + child: Center( + child: Text( + '$shown%', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w900, + height: 1, ), - const SizedBox(height: 2), - Text( - '', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.92), - fontSize: 8, - fontWeight: FontWeight.w900, - ), - ), - ], + ), ), ), ], @@ -630,19 +636,30 @@ class _InicioTab extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _HeroQuizCard( - childName: selectedChildName, - onStartQuiz: () => _startQuiz(context), + FadeSlideIn( + child: TapBounce( + scale: 0.97, + child: _HeroQuizCard( + childName: selectedChildName, + onStartQuiz: () => _startQuiz(context), + ), + ), ), const SizedBox(height: 16), - _VideoLibraryCard( - onOpenLibrary: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const VideoScreen(), - ), - ); - }, + FadeSlideIn( + delay: const Duration(milliseconds: 90), + child: TapBounce( + scale: 0.97, + child: _VideoLibraryCard( + onOpenLibrary: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const VideoScreen(), + ), + ); + }, + ), + ), ), const SizedBox(height: 16), ], @@ -741,7 +758,9 @@ Future?> _pickChildSheet( final label = age != null ? '$name • $age anos' : name; return Padding( padding: const EdgeInsets.only(bottom: 10), - child: Material( + child: TapBounce( + scale: 0.97, + child: Material( color: Colors.white.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(16), child: InkWell( @@ -770,6 +789,7 @@ Future?> _pickChildSheet( ), ), ), + ), ), ); }), @@ -1327,7 +1347,8 @@ class _PerfilTabState extends State<_PerfilTab> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Material( + FadeSlideIn( + child: Material( elevation: 10, color: Colors.white, borderRadius: BorderRadius.circular(20), @@ -1337,7 +1358,9 @@ class _PerfilTabState extends State<_PerfilTab> { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - InkWell( + TapBounce( + scale: 0.92, + child: InkWell( borderRadius: BorderRadius.circular(40), onTap: _updatingPhoto ? null @@ -1415,6 +1438,7 @@ class _PerfilTabState extends State<_PerfilTab> { ], ), ), + ), const SizedBox(width: 14), Expanded( child: Column( @@ -1453,6 +1477,7 @@ class _PerfilTabState extends State<_PerfilTab> { ), ), ), + ), const SizedBox(height: 22), Padding( padding: const EdgeInsets.only(left: 4, bottom: 10), @@ -1554,9 +1579,15 @@ class _PerfilTabState extends State<_PerfilTab> { ].join(' • '); final bool selected = i == selectedIndex; - return Padding( + return FadeSlideIn( + delay: Duration( + milliseconds: 60 * i.clamp(0, 6), + ), + child: Padding( padding: const EdgeInsets.only(bottom: 12), - child: InkWell( + child: TapBounce( + scale: 0.97, + child: InkWell( borderRadius: BorderRadius.circular(16), onTap: () => widget.onChildSelected( i, @@ -1662,47 +1693,53 @@ class _PerfilTabState extends State<_PerfilTab> { ], ), ), + ), + ), ), ); }), - SizedBox( - height: 48, - child: FilledButton.icon( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w800, + TapBounce( + child: SizedBox( + height: 48, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2F9E94), + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + ), ), + onPressed: _addingChild + ? null + : () => _addAnotherChild(context, uid), + icon: const Icon(Icons.add_rounded), + label: const Text('Adicionar criança'), ), - onPressed: _addingChild - ? null - : () => _addAnotherChild(context, uid), - icon: const Icon(Icons.add_rounded), - label: const Text('Adicionar criança'), ), ), const SizedBox(height: 22), - SizedBox( - height: 46, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFFF55A7), - side: const BorderSide( - color: Color(0xFFFF55A7), - width: 1.4, - ), - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w800, + TapBounce( + child: SizedBox( + height: 46, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFFF55A7), + side: const BorderSide( + color: Color(0xFFFF55A7), + width: 1.4, + ), + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + ), ), + onPressed: () async { + await supabase.auth.signOut(); + }, + icon: const Icon(Icons.logout_rounded), + label: const Text('Sair'), ), - onPressed: () async { - await supabase.auth.signOut(); - }, - icon: const Icon(Icons.logout_rounded), - label: const Text('Sair'), ), ), const SizedBox(height: 12), @@ -1844,17 +1881,21 @@ class _AddChildSheetState extends State<_AddChildSheet> { ), const SizedBox(width: 10), Expanded( - child: SizedBox( - height: 44, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle(fontWeight: FontWeight.w900), + child: TapBounce( + child: SizedBox( + height: 44, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2F9E94), + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + onPressed: _submit, + child: const Text('Adicionar'), ), - onPressed: _submit, - child: const Text('Adicionar'), ), ), ), diff --git a/lib/login_register/login_sheet.dart b/lib/login_register/login_sheet.dart deleted file mode 100644 index dd53ae6..0000000 --- a/lib/login_register/login_sheet.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import '../main.dart' show supabase; - -Future showLoginSheet(BuildContext context) { - return showModalBottomSheet( - 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 createState() => _LoginBottomSheetState(); -} - -class _LoginBottomSheetState extends State { - final _formKey = GlobalKey(); - - 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 _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; - } - } -} diff --git a/lib/login_register/register_sheet.dart b/lib/login_register/register_sheet.dart deleted file mode 100644 index d0af41e..0000000 --- a/lib/login_register/register_sheet.dart +++ /dev/null @@ -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 showRegisterSheet(BuildContext context) { - return showModalBottomSheet( - 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 createState() => _RegisterBottomSheetState(); -} - -class _RegisterBottomSheetState extends State { - final _formKey = GlobalKey(); - - final _nameController = TextEditingController(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - - bool _loading = false; - - Future _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 _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; - } - } -} diff --git a/lib/quiz/quiz1.dart b/lib/quiz/quiz1.dart index 1464f55..a9eaaa1 100644 --- a/lib/quiz/quiz1.dart +++ b/lib/quiz/quiz1.dart @@ -13,7 +13,7 @@ class Quiz1Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 1/26', + title: 'Quiz 1/25', question: 'O rosto do seu filho/a se parece com o desta imagem?', questionImagePaths: const ['assets/mockup_images/2.jpeg'], suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 1 @@ -52,7 +52,7 @@ class Quiz2Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 2/26', + title: 'Quiz 2/25', question: 'A boca do seu filho/a fica habitualmente na posição desta imagem (entreaberta)?', questionImagePaths: const ['assets/mockup_images/4.jpeg'], @@ -92,7 +92,7 @@ class Quiz3Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 3/26', + title: 'Quiz 3/25', question: 'O seu filho/a tem olheiras semelhantes às desta imagem?', questionImagePaths: const ['assets/mockup_images/8.jpeg'], suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 3 @@ -131,7 +131,7 @@ class Quiz4Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 4/26', + title: 'Quiz 4/25', question: 'Com a boca fechada, o queixo do seu filho/a se parece com o desta imagem?', questionImagePaths: const ['assets/mockup_images/6.jpeg'], @@ -171,7 +171,7 @@ class Quiz5Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 5/26', + title: 'Quiz 5/25', question: 'Quantos dentes tem o seu filho/a em cima na boca?', answers: const [], currentScore: currentScore, @@ -194,7 +194,7 @@ class Quiz6Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 6/26', + title: 'Quiz 6/25', question: 'Quantos dentes tem o seu filho/a em baixo na boca?', answers: const [], currentScore: currentScore, @@ -217,7 +217,7 @@ class Quiz7Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 7/26', + title: 'Quiz 7/25', question: 'A boca do seu filho/a se parece com a desta imagem?', questionImagePaths: const ['assets/mockup_images/14.jpeg'], suggestedYoutubeId: '', // TODO: colar o ID do YouTube do Episódio 5 @@ -256,7 +256,7 @@ class Quiz8Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 8/26', + title: 'Quiz 8/25', question: 'O frénulo (freio) da língua do seu filho/a se parece com o desta imagem?', questionImagePaths: const ['assets/mockup_images/17.png'], @@ -296,7 +296,7 @@ class Quiz9Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 9/26', + title: 'Quiz 9/25', question: 'O seu filho/a tem problemas respiratórios diagnosticados?', answers: const [ QuizAnswer( @@ -332,7 +332,7 @@ class Quiz10Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 10/26', + title: 'Quiz 10/25', question: 'O seu filho/a respira habitualmente pela boca?', answers: const [ QuizAnswer( @@ -368,7 +368,7 @@ class Quiz11Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 11/26', + title: 'Quiz 11/25', question: 'O seu filho/a ressona habitualmente durante a noite?', answers: const [ QuizAnswer( @@ -404,7 +404,7 @@ class Quiz12Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 12/26', + title: 'Quiz 12/25', question: 'O seu filho/a sente habitualmente o nariz "tapado"?', answers: const [ QuizAnswer( @@ -440,7 +440,7 @@ class Quiz13Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 13/26', + title: 'Quiz 13/25', question: 'Durante o sono, o seu filho/a tem habitualmente interrupções da respiração?', answers: const [ @@ -478,7 +478,7 @@ class Quiz14Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 14/26', + title: 'Quiz 14/25', question: 'O seu filho/a range os dentes com frequência?', answers: const [ QuizAnswer( @@ -514,7 +514,7 @@ class Quiz15Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 15/26', + title: 'Quiz 15/25', question: 'O seu filho/a habitualmente tem alergias sazonais?', answers: const [ QuizAnswer( @@ -550,7 +550,7 @@ class Quiz16Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 16/26', + title: 'Quiz 16/25', question: 'O seu filho/a acorda com saliva seca na cara ou na almofada?', answers: const [ QuizAnswer( @@ -586,7 +586,7 @@ class Quiz17Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 17/26', + title: 'Quiz 17/25', question: 'O seu filho/a teve ou costuma ter com frequência otites?', answers: const [ QuizAnswer( @@ -622,7 +622,7 @@ class Quiz18Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 18/26', + title: 'Quiz 18/25', question: 'O seu filho/a teve ou costuma ter com frequência amigdalites?', answers: const [ QuizAnswer( @@ -658,7 +658,7 @@ class Quiz19Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 19/26', + title: 'Quiz 19/25', question: 'O seu filho/a teve ou costuma ter com frequência bronquiolites?', answers: const [ @@ -695,7 +695,7 @@ class Quiz20Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 20/26', + title: 'Quiz 20/25', question: 'O seu filho/a apresenta dificuldades a mastigar?', answers: const [ QuizAnswer( @@ -731,7 +731,7 @@ class Quiz21Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 21/26', + title: 'Quiz 21/25', question: 'O seu filho/a habitualmente é lento a comer?', answers: const [ QuizAnswer( @@ -767,7 +767,7 @@ class Quiz22Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 22/26', + title: 'Quiz 22/25', question: 'O seu filho/a habitualmente prefere comer alimentos moles?', answers: const [ QuizAnswer( @@ -803,7 +803,7 @@ class Quiz23Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 23/26', + title: 'Quiz 23/25', question: 'Em bebé apenas foi alimentado por biberão?', answers: const [ QuizAnswer( @@ -839,7 +839,7 @@ class Quiz24Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 24/26', + title: 'Quiz 24/25', question: 'O seu filho/a usa ou usou chupeta com frequência?', answers: const [ QuizAnswer( @@ -875,7 +875,7 @@ class Quiz25Screen extends StatelessWidget { @override Widget build(BuildContext context) { return QuizQuestionScreen( - title: 'Quiz 25/26', + title: 'Quiz 25/25', question: 'O seu filho/a chucha ou já chuchou o dedo com frequência?', answers: const [ QuizAnswer( @@ -892,35 +892,6 @@ class Quiz25Screen extends StatelessWidget { ), ], currentScore: currentScore, - nextRoute: (context, nextScore) => MaterialPageRoute( - 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( builder: (_) => QuizResultScreen( finalScore: nextScore, @@ -928,6 +899,7 @@ class Quiz26Screen extends StatelessWidget { scopeId: scopeId, ), ), + answerType: QuizAnswerType.yesNo, isFinal: true, showBackButton: true, ); diff --git a/lib/quiz/quiz_question_screen.dart b/lib/quiz/quiz_question_screen.dart index bee7f6f..3adad32 100644 --- a/lib/quiz/quiz_question_screen.dart +++ b/lib/quiz/quiz_question_screen.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; import '../screens/video_screen.dart'; +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; typedef QuizNextBuilder = Route Function(BuildContext context, int nextScore); @@ -90,6 +92,10 @@ class _QuizQuestionScreenState extends State { canProceed = _numberValue != null && _numberValue! >= 0 && !_navigating; } + final bool hasSuggestedVideo = + (widget.suggestedVideoPath?.isNotEmpty ?? false) || + (widget.suggestedYoutubeId?.isNotEmpty ?? false); + return Scaffold( body: Stack( clipBehavior: Clip.none, @@ -127,267 +133,391 @@ class _QuizQuestionScreenState extends State { ), ), SafeArea( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - widget.title, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black.withValues(alpha: 0.55), - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 6), - if (widget.questionImagePaths.isNotEmpty) ...[ - const SizedBox(height: 6), - _QuestionReferenceImages( - paths: widget.questionImagePaths, - ), - const SizedBox(height: 10), - ], - if (widget.suggestedVideoPath != null || - widget.suggestedYoutubeId != null) ...[ - TextButton.icon( - onPressed: () => showVideoPlayerDialog( - context, - VideoData( - id: 0, - title: - widget.suggestedVideoTitle ?? 'Vídeo', - description: '', - videoPath: widget.suggestedVideoPath, - youtubeId: widget.suggestedYoutubeId, - ), - ), - icon: const Icon( - Icons.play_circle_outline_rounded, - color: Color(0xFF2F9E94), - ), - label: Text( - widget.suggestedVideoTitle ?? - 'Ver vídeo (opcional)', - style: const TextStyle( - color: Color(0xFF2F9E94), - fontWeight: FontWeight.w800, - ), - ), - ), - const SizedBox(height: 4), - ], - Text( - widget.question, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - color: Color(0xFFFF55A7), - height: 1.2, - ), - ), - const SizedBox(height: 8), - Text( - widget.answerType == QuizAnswerType.number - ? 'Insira o número' - : widget.answerType == QuizAnswerType.yesNo - ? 'Escolha uma opção' - : 'Escolha apenas uma opção', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black.withValues(alpha: 0.55), - fontWeight: FontWeight.w700, - ), - ), - ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 44, + child: Stack( + alignment: Alignment.center, + children: [ + Text( + widget.title, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black.withValues(alpha: 0.55), + fontWeight: FontWeight.w800, + ), ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: widget.answerType == QuizAnswerType.number - ? _buildNumberInput() - : ListView.separated( - padding: const EdgeInsets.only(bottom: 12), - itemCount: widget.answers.length, - separatorBuilder: (context, index) => - const SizedBox(height: 12), - itemBuilder: (context, i) { - return _QuizAnswerTile( - answer: widget.answers[i], - selected: _selected == i, - onTap: () => setState(() => _selected = i), - ); - }, + if (widget.showBackButton) + Positioned( + left: 4, + child: TapBounce( + scale: 0.9, + child: Material( + color: Colors.white.withValues(alpha: 0.85), + shape: const CircleBorder(), + elevation: 4, + shadowColor: Colors.black.withValues( + alpha: 0.15, ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 18), - child: Column( - children: [ - SizedBox( - width: size.width * 0.62, - height: 46, - child: FilledButton( - style: - FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w900, - ), - ).copyWith( - animationDuration: const Duration( - milliseconds: 180, - ), - splashFactory: InkSparkle.splashFactory, - overlayColor: - WidgetStateProperty.resolveWith( - (states) { - if (states.contains( - WidgetState.pressed, - )) { - return Colors.white.withValues( - alpha: 0.14, - ); - } - if (states.contains( - WidgetState.hovered, - ) || - states.contains( - WidgetState.focused, - )) { - return Colors.white.withValues( - alpha: 0.08, - ); - } - return null; - }, - ), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => Navigator.of(context).maybePop(), + child: const Padding( + padding: EdgeInsets.all(10), + child: Icon( + Icons.arrow_back_rounded, + color: Color(0xFF2F9E94), + size: 22, ), - onPressed: !canProceed - ? null - : () { - setState(() => _navigating = true); - int nextScore = widget.currentScore; - if (widget.answerType == - QuizAnswerType.number) { - nextScore = - widget.currentScore + - (_numberValue ?? 0); - } else { - final picked = - widget.answers[_selected!]; - nextScore = - widget.currentScore + picked.weight; - } - - if (widget.isFinal) { - final finishedRoute = widget.nextRoute( - context, - nextScore, - ); - Navigator.of( - context, - ).pushReplacement(finishedRoute); - return; - } - - Navigator.of(context).push( - widget.nextRoute(context, nextScore), - ); - }, - child: Text( - widget.isFinal ? 'Concluir' : 'Avançar', + ), ), ), ), - if (widget.showBackButton) ...[ - const SizedBox(height: 10), - SizedBox( - width: size.width * 0.62, - height: 42, - child: FilledButton( - style: - FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w900, - ), - ).copyWith( - animationDuration: const Duration( - milliseconds: 180, - ), - splashFactory: InkSparkle.splashFactory, - overlayColor: - WidgetStateProperty.resolveWith< - Color? - >((states) { - if (states.contains( - WidgetState.pressed, - )) { - return Colors.white.withValues( - alpha: 0.14, - ); - } - if (states.contains( - WidgetState.hovered, - ) || - states.contains( - WidgetState.focused, - )) { - return Colors.white.withValues( - alpha: 0.08, - ); - } - return null; - }), - ), - onPressed: () => - Navigator.of(context).maybePop(), - child: const Text('Voltar'), - ), - ), - ], - const SizedBox(height: 10), - SizedBox( - width: size.width * 0.62, - height: 42, - child: OutlinedButton( - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF2F9E94), - side: const BorderSide( - color: Color(0xFF2F9E94), - width: 1.3, - ), - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w900, - ), - ), - onPressed: () => Navigator.of( - context, - ).popUntil((route) => route.isFirst), - child: const Text('Voltar para homepage'), - ), - ), - ], - ), - ), - ], + ), + ], + ), ), - ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 520, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FadeSlideIn( + child: Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 4, + 20, + 10, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + if (widget + .questionImagePaths + .isNotEmpty) ...[ + const SizedBox(height: 6), + _QuestionReferenceImages( + paths: + widget.questionImagePaths, + ), + const SizedBox(height: 10), + ], + if (hasSuggestedVideo) ...[ + TextButton.icon( + onPressed: () => + showVideoPlayerDialog( + context, + VideoData( + id: 0, + title: + widget + .suggestedVideoTitle ?? + 'Vídeo', + description: '', + videoPath: widget + .suggestedVideoPath, + youtubeId: widget + .suggestedYoutubeId, + ), + ), + icon: const Icon( + Icons + .play_circle_outline_rounded, + color: Color(0xFF2F9E94), + ), + label: Text( + widget.suggestedVideoTitle ?? + 'Ver vídeo (opcional)', + style: const TextStyle( + color: Color(0xFF2F9E94), + fontWeight: + FontWeight.w800, + ), + ), + ), + const SizedBox(height: 4), + ], + Text( + widget.question, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: Color(0xFFFF55A7), + height: 1.2, + ), + ), + const SizedBox(height: 8), + Text( + widget.answerType == + QuizAnswerType.number + ? 'Insira o número' + : widget.answerType == + QuizAnswerType.yesNo + ? 'Escolha uma opção' + : 'Escolha apenas uma opção', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black + .withValues(alpha: 0.55), + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 18), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + child: + widget.answerType == + QuizAnswerType.number + ? _buildNumberInput() + : Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for ( + int i = 0; + i < widget.answers.length; + i++ + ) ...[ + if (i > 0) + const SizedBox(height: 12), + FadeSlideIn( + delay: Duration( + milliseconds: 60 * i, + ), + child: _QuizAnswerTile( + answer: + widget.answers[i], + selected: + _selected == i, + onTap: () => setState( + () => _selected = i, + ), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 0, + 20, + 0, + ), + child: Column( + children: [ + TapBounce( + child: SizedBox( + width: size.width * 0.62, + height: 46, + child: FilledButton( + style: + FilledButton.styleFrom( + backgroundColor: + const Color( + 0xFF2F9E94, + ), + foregroundColor: + Colors.white, + shape: + const StadiumBorder(), + textStyle: + const TextStyle( + fontWeight: + FontWeight + .w900, + ), + ).copyWith( + animationDuration: + const Duration( + milliseconds: 180, + ), + splashFactory: InkSparkle + .splashFactory, + overlayColor: + WidgetStateProperty.resolveWith< + Color? + >((states) { + if (states + .contains( + WidgetState + .pressed, + )) { + return Colors + .white + .withValues( + alpha: + 0.14, + ); + } + if (states.contains( + WidgetState + .hovered, + ) || + states.contains( + WidgetState + .focused, + )) { + return Colors + .white + .withValues( + alpha: + 0.08, + ); + } + return null; + }), + ), + onPressed: !canProceed + ? null + : () async { + setState( + () => _navigating = + true, + ); + int nextScore = + widget + .currentScore; + if (widget.answerType == + QuizAnswerType + .number) { + nextScore = + widget + .currentScore + + (_numberValue ?? + 0); + } else { + final picked = widget + .answers[_selected!]; + nextScore = + widget + .currentScore + + picked.weight; + } + + if (widget.isFinal) { + final finishedRoute = + widget.nextRoute( + context, + nextScore, + ); + Navigator.of( + context, + ).pushReplacement( + finishedRoute, + ); + return; + } + + await Navigator.of( + context, + ).push( + widget.nextRoute( + context, + nextScore, + ), + ); + if (mounted) { + setState( + () => _navigating = + false, + ); + } + }, + child: Text( + widget.isFinal + ? 'Concluir' + : 'Avançar', + ), + ), + ), + ), + const SizedBox(height: 10), + TapBounce( + child: SizedBox( + width: size.width * 0.62, + height: 42, + child: OutlinedButton( + style: + OutlinedButton.styleFrom( + foregroundColor: + const Color( + 0xFF2F9E94, + ), + side: const BorderSide( + color: Color( + 0xFF2F9E94, + ), + width: 1.3, + ), + shape: + const StadiumBorder(), + textStyle: + const TextStyle( + fontWeight: + FontWeight + .w900, + ), + ), + onPressed: () => + Navigator.of( + context, + ).popUntil( + (route) => + route.isFirst, + ), + child: const Text( + 'Voltar para homepage', + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], ), ), ], @@ -469,67 +599,109 @@ class _QuizAnswerTile extends StatelessWidget { ? Colors.white.withValues(alpha: 0.88) : Colors.white.withValues(alpha: 0.70); - return AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: borderColor, width: selected ? 1.4 : 1.0), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.06), - blurRadius: 18, - offset: const Offset(0, 10), - ), - ], - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: onTap, - splashFactory: InkSparkle.splashFactory, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (answer.imagePath != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: AspectRatio( - aspectRatio: 4 / 3, - child: Image.asset( - answer.imagePath!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - color: Colors.black.withValues(alpha: 0.06), - child: const Center( - child: Icon( - Icons.image_not_supported_outlined, - color: Colors.black38, - ), - ), - ), - ), - ), - ), - const SizedBox(height: 10), - ], - Text( - answer.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.w900, - fontSize: 15, - color: Color(0xFF2F9E94), - ), + return TapBounce( + scale: 0.97, + child: Stack( + clipBehavior: Clip.none, + fit: StackFit.passthrough, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: borderColor, + width: selected ? 1.4 : 1.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 18, + offset: const Offset(0, 10), ), ], ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + splashFactory: InkSparkle.splashFactory, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (answer.imagePath != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AspectRatio( + aspectRatio: 4 / 3, + child: Image.asset( + answer.imagePath!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + Container( + color: Colors.black.withValues( + alpha: 0.06, + ), + child: const Center( + child: Icon( + Icons.image_not_supported_outlined, + color: Colors.black38, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 10), + ], + Text( + answer.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 15, + color: Color(0xFF2F9E94), + ), + ), + ], + ), + ), + ), + ), ), - ), + Positioned( + top: 8, + right: 8, + child: IgnorePointer( + child: AnimatedScale( + scale: selected ? 1.0 : 0.0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutBack, + child: Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: Color(0xFF2F9E94), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_rounded, + size: 15, + color: Colors.white, + ), + ), + ), + ), + ), + ], ), ); } diff --git a/lib/quiz/quiz_result.dart b/lib/quiz/quiz_result.dart index 1ba30a9..58f9a84 100644 --- a/lib/quiz/quiz_result.dart +++ b/lib/quiz/quiz_result.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'dart:async'; import '../main.dart' show supabase; +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; import 'quiz_prefs.dart'; class QuizResultScreen extends StatefulWidget { @@ -115,82 +117,98 @@ class _QuizResultScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 6), - const Text( - 'A percentagem de risco\navaliada é de:', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - color: Color(0xFFFF55A7), - height: 1.2, - ), - ), - const SizedBox(height: 18), - Center( - child: SizedBox( - width: 220, - height: 220, - child: Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: 200, - height: 200, - child: CircularProgressIndicator( - value: progress, - strokeWidth: 12, - backgroundColor: Colors.black - .withValues(alpha: 0.10), - valueColor: - const AlwaysStoppedAnimation( - Color(0xFF2F9E94), - ), - ), - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '$percent%', - style: const TextStyle( - fontSize: 34, - fontWeight: FontWeight.w900, - color: Colors.black, - ), - ), - const SizedBox(height: 4), - Text( - '${clamped.toInt()}/${widget.maxScore}', - style: TextStyle( - color: Colors.black.withValues( - alpha: 0.60, - ), - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ], + FadeSlideIn( + child: const Text( + 'A percentagem de risco\navaliada é de:', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: Color(0xFFFF55A7), + height: 1.2, ), ), ), const SizedBox(height: 18), - Text( - 'Conclusões:', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black.withValues(alpha: 0.75), - fontWeight: FontWeight.w900, + Center( + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 1100), + curve: Curves.easeOutCubic, + tween: Tween(begin: 0, end: progress), + builder: (context, animatedProgress, _) { + final animatedPercent = + (animatedProgress * 100).round(); + return SizedBox( + width: 220, + height: 220, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 200, + height: 200, + child: CircularProgressIndicator( + value: animatedProgress, + strokeWidth: 12, + backgroundColor: Colors.black + .withValues(alpha: 0.10), + valueColor: + const AlwaysStoppedAnimation( + Color(0xFF2F9E94), + ), + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$animatedPercent%', + style: const TextStyle( + fontSize: 34, + fontWeight: FontWeight.w900, + color: Colors.black, + ), + ), + const SizedBox(height: 4), + Text( + '${clamped.toInt()}/${widget.maxScore}', + style: TextStyle( + color: Colors.black + .withValues(alpha: 0.60), + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ], + ), + ); + }, + ), + ), + const SizedBox(height: 18), + FadeSlideIn( + delay: const Duration(milliseconds: 120), + child: Text( + 'Conclusões:', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black.withValues(alpha: 0.75), + fontWeight: FontWeight.w900, + ), ), ), const SizedBox(height: 10), - Text( - 'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black.withValues(alpha: 0.70), - fontWeight: FontWeight.w600, - height: 1.25, + FadeSlideIn( + delay: const Duration(milliseconds: 160), + child: Text( + 'Esta avaliação é apenas educativa.\nSe tiver dúvidas ou sinais de cárie/dor, procure um Dentista.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black.withValues(alpha: 0.70), + fontWeight: FontWeight.w600, + height: 1.25, + ), ), ), const SizedBox(height: 16), @@ -210,7 +228,8 @@ class _QuizResultScreenState extends State { ), ), Center( - child: SizedBox( + child: TapBounce( + child: SizedBox( width: 260, height: 46, child: FilledButton( @@ -229,6 +248,7 @@ class _QuizResultScreenState extends State { }, child: const Text('Avançar'), ), + ), ), ), ], diff --git a/lib/screens/curiosidade_screen.dart b/lib/screens/curiosidade_screen.dart index 87d6e80..a740d02 100644 --- a/lib/screens/curiosidade_screen.dart +++ b/lib/screens/curiosidade_screen.dart @@ -3,6 +3,9 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; + class CuriosidadeScreen extends StatelessWidget { const CuriosidadeScreen({super.key}); @@ -68,24 +71,48 @@ class CuriosidadeScreen extends StatelessWidget { child: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), children: [ - _CuriosityTopicTile( - title: 'Tema X', - description: 'Aprenda dicas rápidas e simples para cuidar dos dentes no dia a dia.', + FadeSlideIn( + child: TapBounce( + scale: 0.97, + child: _CuriosityTopicTile( + title: 'Tema X', + description: + 'Aprenda dicas rápidas e simples para cuidar dos dentes no dia a dia.', + ), + ), ), const SizedBox(height: 12), - const _CuriosityTopicTile( - title: 'Tema Y', - description: 'Conteúdo em breve.', + FadeSlideIn( + delay: const Duration(milliseconds: 60), + child: const TapBounce( + scale: 0.97, + child: _CuriosityTopicTile( + title: 'Tema Y', + description: 'Conteúdo em breve.', + ), + ), ), const SizedBox(height: 12), - const _CuriosityTopicTile( - title: 'Tema Z', - description: 'Conteúdo em breve.', + FadeSlideIn( + delay: const Duration(milliseconds: 120), + child: const TapBounce( + scale: 0.97, + child: _CuriosityTopicTile( + title: 'Tema Z', + description: 'Conteúdo em breve.', + ), + ), ), const SizedBox(height: 12), - const _CuriosityTopicTile( - title: 'Tema U', - description: 'Conteúdo em breve.', + FadeSlideIn( + delay: const Duration(milliseconds: 180), + child: const TapBounce( + scale: 0.97, + child: _CuriosityTopicTile( + title: 'Tema U', + description: 'Conteúdo em breve.', + ), + ), ), ], ), @@ -156,17 +183,21 @@ class _CuriosityTopicTile extends StatelessWidget { ), ), const SizedBox(height: 14), - SizedBox( - height: 44, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle(fontWeight: FontWeight.w900), + TapBounce( + child: SizedBox( + height: 44, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2F9E94), + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Fechar'), ), - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Fechar'), ), ), ], diff --git a/lib/screens/hello_splash_screen.dart b/lib/screens/hello_splash_screen.dart index f535182..217a18b 100644 --- a/lib/screens/hello_splash_screen.dart +++ b/lib/screens/hello_splash_screen.dart @@ -12,10 +12,19 @@ class HelloSplashScreen extends StatefulWidget { State createState() => _HelloSplashScreenState(); } -class _HelloSplashScreenState extends State with SingleTickerProviderStateMixin { +class _HelloSplashScreenState extends State with TickerProviderStateMixin { late final AnimationController _controller; late final Animation _opacity; + late final AnimationController _popController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + late final Animation _pop = CurvedAnimation( + parent: _popController, + curve: Curves.elasticOut, + ); + Timer? _fadeTimer; Timer? _doneTimer; @@ -34,6 +43,7 @@ class _HelloSplashScreenState extends State with SingleTicker ); _controller.value = 1.0; + _popController.forward(); final int fadeMs = (widget.duration.inMilliseconds - 500).clamp(0, widget.duration.inMilliseconds); _fadeTimer = Timer(Duration(milliseconds: fadeMs), () { @@ -52,6 +62,7 @@ class _HelloSplashScreenState extends State with SingleTicker _fadeTimer?.cancel(); _doneTimer?.cancel(); _controller.dispose(); + _popController.dispose(); super.dispose(); } @@ -70,15 +81,18 @@ class _HelloSplashScreenState extends State with SingleTicker child: Center( child: Column( mainAxisSize: MainAxisSize.min, - children: const [ - Text( - 'Olá', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 64, - fontWeight: FontWeight.w900, - color: Colors.white, - height: 1.0, + children: [ + ScaleTransition( + scale: _pop, + child: const Text( + 'Olá', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 64, + fontWeight: FontWeight.w900, + color: Colors.white, + height: 1.0, + ), ), ), ], diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8971ea9..0e5a127 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../main.dart' show supabase; import '../widgets/app_dialogs.dart'; +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; import 'terms_screen.dart'; const Color _teal = Color(0xFF2F9E94); @@ -65,53 +67,78 @@ class _SettingsBodyState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - _SectionLabel('Conta'), - _SettingsCard( - children: [ - _InfoTile( - icon: Icons.person_outline_rounded, - title: name.isEmpty ? 'Sem nome' : name, - subtitle: email, - ), - const Divider(height: 1), - _ActionTile( - icon: Icons.logout_rounded, - title: 'Sair', - onTap: _signOut, - ), - ], - ), - const SizedBox(height: 20), - _SectionLabel('Sobre'), - _SettingsCard( - children: [ - _ActionTile( - icon: Icons.description_outlined, - title: 'Termos de Serviço', - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const TermsScreen()), + FadeSlideIn( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionLabel('Conta'), + _SettingsCard( + children: [ + _InfoTile( + icon: Icons.person_outline_rounded, + title: name.isEmpty ? 'Sem nome' : name, + subtitle: email, + ), + const Divider(height: 1), + _ActionTile( + icon: Icons.logout_rounded, + title: 'Sair', + onTap: _signOut, + ), + ], ), - ), - const Divider(height: 1), - const _InfoTile( - icon: Icons.info_outline_rounded, - title: 'Versão do app', - subtitle: '1.0.0', - ), - ], + ], + ), ), const SizedBox(height: 20), - _SectionLabel('Zona de risco'), - _SettingsCard( - children: [ - _ActionTile( - icon: Icons.delete_forever_rounded, - title: 'Apagar dados da conta', - titleColor: _accentPink, - loading: _deletingAccount, - onTap: _deletingAccount ? null : _confirmDeleteAccountData, - ), - ], + FadeSlideIn( + delay: const Duration(milliseconds: 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionLabel('Sobre'), + _SettingsCard( + children: [ + _ActionTile( + icon: Icons.description_outlined, + title: 'Termos de Serviço', + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const TermsScreen(), + ), + ), + ), + const Divider(height: 1), + const _InfoTile( + icon: Icons.info_outline_rounded, + title: 'Versão do app', + subtitle: '1.0.0', + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + FadeSlideIn( + delay: const Duration(milliseconds: 160), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionLabel('Zona de risco'), + _SettingsCard( + children: [ + _ActionTile( + icon: Icons.delete_forever_rounded, + title: 'Apagar dados da conta', + titleColor: _accentPink, + loading: _deletingAccount, + onTap: _deletingAccount ? null : _confirmDeleteAccountData, + ), + ], + ), + ], + ), ), const SizedBox(height: 12), ], @@ -193,20 +220,23 @@ class _ActionTile extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - leading: Icon(icon, color: titleColor ?? _teal), - title: Text( - title, - style: TextStyle(fontWeight: FontWeight.w800, color: titleColor), + return TapBounce( + scale: 0.98, + child: ListTile( + leading: Icon(icon, color: titleColor ?? _teal), + title: Text( + title, + style: TextStyle(fontWeight: FontWeight.w800, color: titleColor), + ), + trailing: loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.chevron_right_rounded), + onTap: onTap, ), - trailing: loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.chevron_right_rounded), - onTap: onTap, ); } } diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index cf69434..7172cbb 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -6,6 +6,9 @@ import 'package:lottie/lottie.dart'; import 'package:video_player/video_player.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart'; +import '../widgets/entrance.dart'; +import '../widgets/tap_bounce.dart'; + // Video data structure - easily editable for future updates. // Episódios 1-7 tocam via YouTube (não listado); preencha youtubeId ao subir // cada vídeo. Episódios 8-13 continuam embutidos no app (assets/videos). @@ -276,8 +279,13 @@ class _VideoScreenState extends State { ), itemCount: _filteredVideos.length, itemBuilder: (context, index) { - return _VideoButton( - video: _filteredVideos[index], + return FadeSlideIn( + delay: Duration( + milliseconds: 40 * (index % 8), + ), + child: _VideoButton( + video: _filteredVideos[index], + ), ); }, ), @@ -437,7 +445,9 @@ class _VideoButton extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( + return TapBounce( + scale: 0.95, + child: Material( elevation: 8, shadowColor: Colors.black.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(16), @@ -484,6 +494,7 @@ class _VideoButton extends StatelessWidget { ), ), ), + ), ); } } diff --git a/lib/widgets/animated_nav_icon.dart b/lib/widgets/animated_nav_icon.dart new file mode 100644 index 0000000..274fe94 --- /dev/null +++ b/lib/widgets/animated_nav_icon.dart @@ -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 createState() => _AnimatedNavIconState(); +} + +class _AnimatedNavIconState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 320), + ); + late final Animation _bounce = TweenSequence([ + 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), + ); + } +} diff --git a/lib/widgets/app_dialogs.dart b/lib/widgets/app_dialogs.dart index 3046715..3218a6b 100644 --- a/lib/widgets/app_dialogs.dart +++ b/lib/widgets/app_dialogs.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'tap_bounce.dart'; + const Color _teal = Color(0xFF2F9E94); const Color _accentPink = Color(0xFFFF55A7); @@ -24,20 +26,24 @@ Future showConfirmDialog( ), content: message == null ? null : Text(message), actions: [ - TextButton( - style: TextButton.styleFrom(foregroundColor: _teal), - onPressed: () => Navigator.of(ctx).pop(false), - child: Text(cancelLabel), - ), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: confirmColor, - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle(fontWeight: FontWeight.w800), + TapBounce( + child: TextButton( + style: TextButton.styleFrom(foregroundColor: _teal), + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(cancelLabel), + ), + ), + TapBounce( + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: confirmColor, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle(fontWeight: FontWeight.w800), + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(confirmLabel), ), - onPressed: () => Navigator.of(ctx).pop(true), - child: Text(confirmLabel), ), ], ); diff --git a/lib/widgets/entrance.dart b/lib/widgets/entrance.dart new file mode 100644 index 0000000..3414b11 --- /dev/null +++ b/lib/widgets/entrance.dart @@ -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 createState() => _FadeSlideInState(); +} + +class _FadeSlideInState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + late final Animation _fade = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + late final Animation _slide = Tween( + 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), + ); + } +} diff --git a/lib/widgets/tap_bounce.dart b/lib/widgets/tap_bounce.dart new file mode 100644 index 0000000..d9c7c2a --- /dev/null +++ b/lib/widgets/tap_bounce.dart @@ -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 createState() => _TapBounceState(); +} + +class _TapBounceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + late final Animation _scale = Tween( + 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, + ), + ); + } +}