diff --git a/lib/home_screen.dart b/lib/home_screen.dart index fcae4d9..a40a0f8 100644 --- a/lib/home_screen.dart +++ b/lib/home_screen.dart @@ -12,6 +12,9 @@ import 'widgets/tap_bounce.dart'; const Color _teal = Color(0xFF2F9E94); const Color _pink = Color(0xFFFF55A7); +/// Nomes só podem ter letras (incluindo acentuadas) e espaços — sem números. +final RegExp _namePattern = RegExp(r"^[a-zA-ZÀ-ÖØ-öø-ÿ' -]+$"); + class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -369,6 +372,9 @@ class _AuthForm extends StatelessWidget { final value = (v ?? '').trim(); if (value.isEmpty) return 'Informe seu nome'; if (value.length < 2) return 'Nome muito curto'; + if (!_namePattern.hasMatch(value)) { + return 'O nome não pode conter números'; + } return null; }, ), diff --git a/lib/logged_home.dart b/lib/logged_home.dart index cb8b734..4cc6e20 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -15,9 +15,13 @@ import 'screens/settings_screen.dart'; import 'screens/video_screen.dart'; import 'widgets/animated_nav_icon.dart'; import 'widgets/app_dialogs.dart'; +import 'widgets/app_gradients.dart'; import 'widgets/entrance.dart'; import 'widgets/tap_bounce.dart'; +/// Nomes só podem ter letras (incluindo acentuadas) e espaços — sem números. +final RegExp _namePattern = RegExp(r"^[a-zA-ZÀ-ÖØ-öø-ÿ' -]+$"); + class LoggedHomeScreen extends StatefulWidget { const LoggedHomeScreen({super.key}); @@ -249,7 +253,13 @@ class _LoggedHomeScreenState extends State child: AppBar( toolbarHeight: toolbarHeight, clipBehavior: Clip.antiAlias, - flexibleSpace: _index != 0 + flexibleSpace: ClipRRect( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(_index == 0 ? 24 : 0), + ), + child: Container( + decoration: const BoxDecoration(gradient: kAppBarGradient), + child: _index != 0 ? null : Stack( fit: StackFit.expand, @@ -304,6 +314,8 @@ class _LoggedHomeScreenState extends State ), ], ), + ), + ), title: Align( alignment: _index == 0 ? Alignment.topLeft : Alignment.center, child: _index == 0 @@ -735,70 +747,83 @@ Future?> _pickChildSheet( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (ctx) { + final maxHeight = MediaQuery.sizeOf(ctx).height * 0.8; return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 6, 18, 18), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Para qual criança é o quiz?', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - color: Color(0xFFFF55A7), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 6, 18, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Para qual criança é o quiz?', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: Color(0xFFFF55A7), + ), ), - ), - const SizedBox(height: 14), - ...children.map((c) { - final name = (c['name'] ?? '').toString(); - final age = c['age']; - final label = age != null ? '$name • $age anos' : name; - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: TapBounce( - scale: 0.97, - child: Material( - color: Colors.white.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => Navigator.of(ctx).pop(c), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: const TextStyle( - fontWeight: FontWeight.w800, + const SizedBox(height: 14), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children.map((c) { + final name = (c['name'] ?? '').toString(); + final age = c['age']; + final label = age != null + ? '$name • $age anos' + : name; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TapBounce( + scale: 0.97, + child: Material( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => Navigator.of(ctx).pop(c), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w800, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: teal, + ), + ], + ), ), ), ), - const Icon( - Icons.chevron_right_rounded, - color: teal, - ), - ], - ), - ), - ), + ), + ); + }).toList(), ), ), - ); - }), - const SizedBox(height: 4), - TextButton( - onPressed: () => Navigator.of(ctx).pop(null), - child: const Text('Cancelar'), - ), - ], + ), + const SizedBox(height: 4), + TextButton( + onPressed: () => Navigator.of(ctx).pop(null), + child: const Text('Cancelar'), + ), + ], + ), ), ), ); @@ -1824,6 +1849,9 @@ class _AddChildSheetState extends State<_AddChildSheet> { final value = (v ?? '').trim(); if (value.isEmpty) return 'Informe o nome'; if (value.length < 2) return 'Nome muito curto'; + if (!_namePattern.hasMatch(value)) { + return 'O nome não pode conter números'; + } return null; }, ), @@ -1831,13 +1859,19 @@ class _AddChildSheetState extends State<_AddChildSheet> { controller: _ageController, keyboardType: TextInputType.number, textInputAction: TextInputAction.next, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], decoration: const InputDecoration(labelText: 'Idade'), validator: (v) { final raw = (v ?? '').trim(); if (raw.isEmpty) return 'Informe a idade'; final age = int.tryParse(raw); if (age == null) return 'Idade inválida'; - if (age < 0 || age > 25) return 'Idade inválida'; + if (age < 0 || age > 17) { + return 'Idade deve ser entre 0 e 17 anos'; + } return null; }, ), diff --git a/lib/quiz/quiz_question_screen.dart b/lib/quiz/quiz_question_screen.dart index 3adad32..85a5fb0 100644 --- a/lib/quiz/quiz_question_screen.dart +++ b/lib/quiz/quiz_question_screen.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:lottie/lottie.dart'; import '../screens/video_screen.dart'; @@ -65,6 +66,11 @@ class QuizQuestionScreen extends StatefulWidget { } class _QuizQuestionScreenState extends State { + // Uma criança não tem, na prática, mais do que ~20 dentes numa arcada + // (dentição decídua + permanente em transição já é um caso extremo). + // Serve para barrar valores absurdos como "400". + static const int _maxTeethCount = 20; + int? _selected; TextEditingController? _numberController; int? _numberValue; @@ -89,7 +95,11 @@ class _QuizQuestionScreenState extends State { final size = MediaQuery.sizeOf(context); bool canProceed = _selected != null && !_navigating; if (widget.answerType == QuizAnswerType.number) { - canProceed = _numberValue != null && _numberValue! >= 0 && !_navigating; + canProceed = + _numberValue != null && + _numberValue! >= 0 && + _numberValue! <= _maxTeethCount && + !_navigating; } final bool hasSuggestedVideo = @@ -551,6 +561,10 @@ class _QuizQuestionScreenState extends State { controller: _numberController, keyboardType: TextInputType.number, textAlign: TextAlign.center, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w900, @@ -573,6 +587,18 @@ class _QuizQuestionScreenState extends State { }, ), ), + if (_numberValue != null && _numberValue! > _maxTeethCount) ...[ + const SizedBox(height: 10), + Text( + 'Um número tão alto assim não é possível.\nO máximo é $_maxTeethCount.', + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFFFF55A7), + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ], ], ), ); diff --git a/lib/screens/curiosidade_screen.dart b/lib/screens/curiosidade_screen.dart index a740d02..4f0d028 100644 --- a/lib/screens/curiosidade_screen.dart +++ b/lib/screens/curiosidade_screen.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; +import '../widgets/app_gradients.dart'; import '../widgets/entrance.dart'; import '../widgets/tap_bounce.dart'; @@ -20,6 +21,9 @@ class CuriosidadeScreen extends StatelessWidget { backgroundColor: _teal, foregroundColor: Colors.white, elevation: 0, + flexibleSpace: Container( + decoration: const BoxDecoration(gradient: kAppBarGradient), + ), title: const Text( 'Curiosidades', style: TextStyle(fontWeight: FontWeight.w900), diff --git a/lib/screens/terms_screen.dart b/lib/screens/terms_screen.dart index b6e7aec..9059d52 100644 --- a/lib/screens/terms_screen.dart +++ b/lib/screens/terms_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../widgets/app_gradients.dart'; + class TermsScreen extends StatelessWidget { const TermsScreen({super.key}); @@ -13,6 +15,9 @@ class TermsScreen extends StatelessWidget { backgroundColor: _teal, foregroundColor: Colors.white, elevation: 0, + flexibleSpace: Container( + decoration: const BoxDecoration(gradient: kAppBarGradient), + ), title: const Text( 'Termos de Serviço', style: TextStyle(fontWeight: FontWeight.w900), diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index 7172cbb..0057e91 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -6,6 +6,7 @@ import 'package:lottie/lottie.dart'; import 'package:video_player/video_player.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart'; +import '../widgets/app_gradients.dart'; import '../widgets/entrance.dart'; import '../widgets/tap_bounce.dart'; @@ -110,8 +111,48 @@ final List videoList = [ ), ]; -// Cache for video controllers to avoid re-initializing +// Cache de controllers de vídeo, partilhado entre qualquer ecrã que mostre +// uma prévia (grelha de vídeos e o cartão em destaque na Home). Como vários +// widgets podem apontar para o mesmo controller ao mesmo tempo, mantemos uma +// contagem de referências para só o libertar quando ninguém mais o usa. final Map _videoControllerCache = {}; +final Map _videoControllerRefCounts = {}; + +// Dispositivos (sobretudo emuladores) só suportam um número pequeno de +// decodificadores de vídeo/áudio simultâneos. A grelha de vídeos pode ter +// dezenas de prévias e o GridView constrói várias de uma vez ao rolar; sem +// este limite, cada thumbnail tenta abrir o seu próprio decoder e o sistema +// trava (era exatamente isto que causava o congelamento e perda de som ao +// navegar por muitos vídeos). Acima do limite, a prévia simplesmente mostra +// o ícone de play em vez do frame real do vídeo. +const int _maxConcurrentThumbnailDecoders = 1; + +bool get _hasThumbnailDecoderSlot => + _videoControllerCache.length < _maxConcurrentThumbnailDecoders; + +void _releaseVideoController(String path) { + final remaining = (_videoControllerRefCounts[path] ?? 0) - 1; + if (remaining > 0) { + _videoControllerRefCounts[path] = remaining; + return; + } + _videoControllerRefCounts.remove(path); + _videoControllerCache.remove(path)?.dispose(); +} + +/// Descarta TODOS os controllers de prévia em cache. Chamado antes de abrir +/// um player real (dialog/fullscreen), que já precisa dos seus próprios +/// decodificadores de vídeo e áudio — em dispositivos com pouca capacidade +/// (sobretudo emuladores), manter qualquer prévia viva ao mesmo tempo é +/// suficiente para esgotar os decodificadores e travar o aparelho. +void _evictAllVideoControllers() { + _videoControllerRefCounts.clear(); + final controllers = _videoControllerCache.values.toList(); + _videoControllerCache.clear(); + for (final controller in controllers) { + controller.dispose(); + } +} Future showVideoPlayerDialog(BuildContext context, VideoData video) { if (video.youtubeId != null) { @@ -156,11 +197,9 @@ class _VideoScreenState extends State { @override void dispose() { _searchController.dispose(); - // Dispose all cached controllers - for (var controller in _videoControllerCache.values) { - controller.dispose(); - } - _videoControllerCache.clear(); + // Não descartamos o cache global aqui: outros widgets (como o cartão em + // destaque na Home) podem estar a usar os mesmos controllers. Cada + // [VideoThumbnail] liberta a sua própria referência ao ser desmontado. super.dispose(); } @@ -180,7 +219,11 @@ class _VideoScreenState extends State { appBar: AppBar( backgroundColor: VideoScreen._teal, foregroundColor: Colors.white, + surfaceTintColor: VideoScreen._teal, elevation: 0, + flexibleSpace: Container( + decoration: const BoxDecoration(gradient: kAppBarGradient), + ), title: const Text( 'Videos Educativos', style: TextStyle(fontWeight: FontWeight.w900), @@ -321,9 +364,18 @@ class VideoThumbnail extends StatefulWidget { class _VideoThumbnailState extends State { VideoPlayerController? _controller; bool _isInitialized = false; + bool _hasOwnRef = false; bool get _isYoutube => widget.video.youtubeId != null; + /// Verdadeiro quando outro widget descartou o controller que este + /// thumbnail estava a usar (ex.: ao abrir o player em dialog/fullscreen). + /// Nesse caso [_controller] deixou de ser o mesmo objeto guardado no + /// cache partilhado — nunca é seguro tocar num controller já disposed. + bool get _isControllerStale => + _controller != null && + _videoControllerCache[widget.video.videoPath] != _controller; + @override void initState() { super.initState(); @@ -332,47 +384,62 @@ class _VideoThumbnailState extends State { Future _initializeVideo() async { final path = widget.video.videoPath!; - // Check if controller exists in cache - if (_videoControllerCache.containsKey(path)) { - _controller = _videoControllerCache[path]; - await _controller!.seekTo(const Duration(seconds: 2)); - await _controller!.pause(); - if (mounted) { - setState(() { - _isInitialized = true; - }); + final cached = _videoControllerCache[path]; + if (cached != null) { + _controller = cached; + _videoControllerRefCounts[path] = + (_videoControllerRefCounts[path] ?? 0) + 1; + _hasOwnRef = true; + try { + await cached.seekTo(const Duration(seconds: 2)); + await cached.pause(); + if (mounted) setState(() => _isInitialized = true); + } catch (_) { + // O controller partilhado foi descartado por outra tela entretanto; + // larga a referência inválida e cria um novo do zero. + _releaseOwnRef(); + _controller = null; + if (mounted) await _createFreshController(path); } return; } + if (!_hasThumbnailDecoderSlot) { + // Já há decoders suficientes ativos noutras prévias; fica só com o + // ícone de play em vez de arriscar sobrecarregar o dispositivo. + if (mounted) setState(() => _isInitialized = false); + return; + } + await _createFreshController(path); + } - // Create new controller and cache it - _controller = VideoPlayerController.asset(path); + Future _createFreshController(String path) async { + final controller = VideoPlayerController.asset(path); + _controller = controller; try { - await _controller!.initialize(); - await _controller!.seekTo(const Duration(seconds: 2)); - await _controller!.pause(); - _videoControllerCache[path] = _controller!; - if (mounted) { - setState(() { - _isInitialized = true; - }); - } + await controller.initialize(); + await controller.seekTo(const Duration(seconds: 2)); + await controller.pause(); + _videoControllerCache[path] = controller; + _videoControllerRefCounts[path] = + (_videoControllerRefCounts[path] ?? 0) + 1; + _hasOwnRef = true; + if (mounted) setState(() => _isInitialized = true); } catch (e) { - if (mounted) { - setState(() { - _isInitialized = false; - }); - } + if (mounted) setState(() => _isInitialized = false); + } + } + + void _releaseOwnRef() { + final path = widget.video.videoPath; + if (_hasOwnRef && path != null) { + _releaseVideoController(path); + _hasOwnRef = false; } } @override void dispose() { - // Don't dispose cached controllers, they will be disposed when screen is disposed - if (_controller != null && - !_videoControllerCache.containsKey(widget.video.videoPath)) { - _controller!.dispose(); - } + _releaseOwnRef(); super.dispose(); } @@ -406,6 +473,18 @@ class _VideoThumbnailState extends State { ), ); } + + if (_isControllerStale) { + // Não mexemos no controller antigo (pode já estar disposed); larga a + // referência e agenda uma reinicialização para o próximo frame. + _hasOwnRef = false; + _controller = null; + _isInitialized = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initializeVideo(); + }); + } + return _isInitialized && _controller != null ? ClipRRect( borderRadius: BorderRadius.circular(widget.borderRadius), @@ -434,11 +513,10 @@ class _VideoButton extends StatelessWidget { final VideoData video; void _showVideoPlayer(BuildContext context, VideoData video) { - if (video.youtubeId == null && - _videoControllerCache.containsKey(video.videoPath)) { - // Dispose the cached controller to avoid codec conflict with dialog controller - _videoControllerCache[video.videoPath]!.dispose(); - _videoControllerCache.remove(video.videoPath); + if (video.youtubeId == null) { + // Liberta todos os decodificadores de prévia antes de abrir o player + // em dialog, que precisa dos seus próprios decoders de vídeo/áudio. + _evictAllVideoControllers(); } showVideoPlayerDialog(context, video); } @@ -696,6 +774,15 @@ class _VideoControlsState extends State<_VideoControls> { } } + void _seekBy(Duration offset) { + final position = widget.controller.value.position; + final duration = widget.controller.value.duration; + var target = position + offset; + if (target < Duration.zero) target = Duration.zero; + if (target > duration) target = duration; + widget.controller.seekTo(target); + } + @override Widget build(BuildContext context) { return Container( @@ -723,6 +810,15 @@ class _VideoControlsState extends State<_VideoControls> { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ + IconButton( + icon: const Icon( + Icons.replay_10_rounded, + color: Colors.white, + ), + tooltip: 'Retroceder 10s', + onPressed: () => + _seekBy(const Duration(seconds: -10)), + ), IconButton( icon: Icon( widget.controller.value.isPlaying @@ -738,16 +834,20 @@ class _VideoControlsState extends State<_VideoControls> { } }, ), + IconButton( + icon: const Icon( + Icons.forward_10_rounded, + color: Colors.white, + ), + tooltip: 'Avançar 10s', + onPressed: () => _seekBy(const Duration(seconds: 10)), + ), IconButton( icon: const Icon(Icons.fullscreen, color: Colors.white), onPressed: () { - // Dispose the cached controller to avoid codec conflict with fullscreen controller - if (_videoControllerCache.containsKey( - widget.videoPath, - )) { - _videoControllerCache[widget.videoPath]!.dispose(); - _videoControllerCache.remove(widget.videoPath); - } + // Liberta todos os decodificadores de prévia antes + // de abrir o player em fullscreen. + _evictAllVideoControllers(); Navigator.of(context).pop(); Navigator.of(context).push( MaterialPageRoute( @@ -826,6 +926,15 @@ class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> { } } + void _seekBy(Duration offset) { + final position = _controller.value.position; + final duration = _controller.value.duration; + var target = position + offset; + if (target < Duration.zero) target = Duration.zero; + if (target > duration) target = duration; + _controller.seekTo(target); + } + @override void dispose() { SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); @@ -854,20 +963,49 @@ class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> { mainAxisSize: MainAxisSize.min, children: [ if (_isInitialized) - FloatingActionButton( - heroTag: 'play_pause', - backgroundColor: VideoScreen._teal, - onPressed: () { - if (_controller.value.isPlaying) { - _controller.pause(); - } else { - _controller.play(); - } - }, - child: Icon( - _controller.value.isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white, - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + heroTag: 'rewind', + mini: true, + backgroundColor: VideoScreen._teal, + onPressed: () => _seekBy(const Duration(seconds: -10)), + child: const Icon( + Icons.replay_10_rounded, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + FloatingActionButton( + heroTag: 'play_pause', + backgroundColor: VideoScreen._teal, + onPressed: () { + if (_controller.value.isPlaying) { + _controller.pause(); + } else { + _controller.play(); + } + }, + child: Icon( + _controller.value.isPlaying + ? Icons.pause + : Icons.play_arrow, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + FloatingActionButton( + heroTag: 'forward', + mini: true, + backgroundColor: VideoScreen._teal, + onPressed: () => _seekBy(const Duration(seconds: 10)), + child: const Icon( + Icons.forward_10_rounded, + color: Colors.white, + ), + ), + ], ), if (_isInitialized) const SizedBox(height: 16), FloatingActionButton( diff --git a/lib/widgets/app_gradients.dart b/lib/widgets/app_gradients.dart new file mode 100644 index 0000000..274337f --- /dev/null +++ b/lib/widgets/app_gradients.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +/// Gradiente usado em todas as app bars da aplicação — um destaque em verde +/// vivo concentrado no canto superior direito, dissolvendo rapidamente para +/// o teal da marca no resto da barra. +const LinearGradient kAppBarGradient = LinearGradient( + begin: Alignment.topRight, + end: Alignment.bottomLeft, + colors: [Color(0xFF31C679), Color(0xFF2F9E94)], + stops: [0.0, 0.55], +);