From 824fdb808906f68fa0a116a497fb7ab53484c5ff Mon Sep 17 00:00:00 2001 From: Carlos Correia <240402@epvc.ptm> Date: Fri, 10 Jul 2026 23:53:33 +0100 Subject: [PATCH] MVP I --- lib/brushing_prefs.dart | 60 +- lib/home_screen.dart | 10 +- lib/logged_home.dart | 1002 +++++++++++++++----------- lib/main.dart | 10 +- lib/quiz/quiz_question_screen.dart | 10 +- lib/quiz/quiz_result.dart | 8 +- lib/screens/curiosidade_screen.dart | 35 +- lib/screens/hello_splash_screen.dart | 4 +- lib/screens/terms_screen.dart | 32 +- lib/screens/video_screen.dart | 145 ++-- lib/watched_videos_prefs.dart | 6 - lib/widgets/app_gradients.dart | 27 +- 12 files changed, 771 insertions(+), 578 deletions(-) diff --git a/lib/brushing_prefs.dart b/lib/brushing_prefs.dart index 1aa286a..550de0e 100644 --- a/lib/brushing_prefs.dart +++ b/lib/brushing_prefs.dart @@ -15,10 +15,36 @@ class BrushingPrefs { static String _key(String base, String scopeId) => '${base}_$scopeId'; + static DateTime _currentWeekMonday() { + final now = DateTime.now(); + return DateTime( + now.year, + now.month, + now.day, + ).subtract(Duration(days: now.weekday - 1)); + } + + /// Lê as escovagens guardadas e, de caminho, descarta (apaga do + /// armazenamento) qualquer registo de semanas anteriores — o contador + /// semanal deve zerar a cada nova semana, não só na exibição mas também + /// nos dados guardados, para não crescer para sempre. static Future> _getEntries(String scopeId) async { final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getStringList(_key(_kDatesKey, scopeId)) ?? const []; - return raw.map(DateTime.tryParse).whereType().toList(); + final key = _key(_kDatesKey, scopeId); + final raw = prefs.getStringList(key) ?? const []; + final entries = raw.map(DateTime.tryParse).whereType().toList(); + + final monday = _currentWeekMonday(); + final kept = entries + .where((d) => !DateTime(d.year, d.month, d.day).isBefore(monday)) + .toList(); + if (kept.length != entries.length) { + await prefs.setStringList( + key, + kept.map((d) => d.toIso8601String()).toList(), + ); + } + return kept; } static Future getWeeklyGoal(String scopeId) async { @@ -51,11 +77,15 @@ class BrushingPrefs { /// o utilizador). static Future logToday(String scopeId) async { if (!(await canLogMore(scopeId))) return; + // Usa as entradas já podadas (só da semana atual) para não ressuscitar + // registos de semanas anteriores ao gravar de volta. + final entries = await _getEntries(scopeId); + entries.add(DateTime.now()); final prefs = await SharedPreferences.getInstance(); - final key = _key(_kDatesKey, scopeId); - final list = prefs.getStringList(key) ?? []; - list.add(DateTime.now().toIso8601String()); - await prefs.setStringList(key, list); + await prefs.setStringList( + _key(_kDatesKey, scopeId), + entries.map((d) => d.toIso8601String()).toList(), + ); } /// Já atingiu o limite diário de [maxPerDay] escovagens hoje? @@ -64,21 +94,9 @@ class BrushingPrefs { } /// Conta quantas escovagens foram registadas na semana atual - /// (segunda-feira a domingo). + /// (segunda-feira a domingo). [_getEntries] já descarta semanas + /// anteriores, por isso todas as entradas devolvidas já são desta semana. static Future getWeekCount(String scopeId) async { - final entries = await _getEntries(scopeId); - - final now = DateTime.now(); - final monday = DateTime( - now.year, - now.month, - now.day, - ).subtract(Duration(days: now.weekday - 1)); - - return entries - .where( - (d) => !DateTime(d.year, d.month, d.day).isBefore(monday), - ) - .length; + return (await _getEntries(scopeId)).length; } } diff --git a/lib/home_screen.dart b/lib/home_screen.dart index e5071c6..ab4a88b 100644 --- a/lib/home_screen.dart +++ b/lib/home_screen.dart @@ -166,15 +166,7 @@ class _HomeScreenState extends State { clipBehavior: Clip.none, children: [ Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], - ), - ), - ), + child: Container(color: const Color(0xFFFAFAF7)), ), Positioned( left: -size.width * 0.38, diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 8464f94..9daa8dc 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -60,7 +60,7 @@ class _LoggedHomeScreenState extends State static const Color _teal = Color(0xFF2F9E94); static const String _kPendingQuizScopeKey = 'pending_quiz_scope_v1'; - static const double _collapsedAppBarHeight = 80; + static const double _collapsedAppBarHeight = kToolbarHeight; static const double _expandedAppBarHeight = 190; static const double _nameOnlyAppBarHeight = 160; @@ -77,7 +77,6 @@ class _LoggedHomeScreenState extends State int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal; bool _brushingDailyLimitReached = false; int? _watchedVideoCount; - VideoData? _continueVideo; String _cachedUserName = 'Sem nome'; String? _cachedPhotoUrl; @@ -105,7 +104,6 @@ class _LoggedHomeScreenState extends State _weeklyGoal = BrushingPrefs.defaultWeeklyGoal; _brushingDailyLimitReached = false; _watchedVideoCount = null; - _continueVideo = videoList.first; }); return; } @@ -114,11 +112,6 @@ class _LoggedHomeScreenState extends State final goal = await BrushingPrefs.getWeeklyGoal(scope); final dailyLimitReached = await BrushingPrefs.hasReachedDailyLimit(scope); final watchedCount = await WatchedVideosPrefs.getWatchedCount(scope); - final watchedIds = await WatchedVideosPrefs.getWatchedIds(scope); - final continueVideo = videoList.firstWhere( - (v) => !watchedIds.contains(v.id), - orElse: () => videoList.last, - ); if (!mounted) return; setState(() { @@ -126,7 +119,6 @@ class _LoggedHomeScreenState extends State _weeklyGoal = goal; _brushingDailyLimitReached = dailyLimitReached; _watchedVideoCount = watchedCount; - _continueVideo = continueVideo; }); } @@ -285,6 +277,83 @@ class _LoggedHomeScreenState extends State return 'Boa noite'; } + /// Fundo comum (cor sólida + ondas decorativas) atrás de qualquer aba, com + /// o conteúdo real posicionado por cima via [SafeArea]/[Padding]. + Widget _decoratedBody(Size size, Widget child, double topPadding) { + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))), + Positioned( + left: -size.width * 0.40, + bottom: -size.width * 0.45, + 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( + top: false, + child: Align( + alignment: Alignment.center, + child: Padding( + padding: EdgeInsets.fromLTRB(16, topPadding, 16, 16), + child: child, + ), + ), + ), + ], + ); + } + + Widget _bottomNav() { + return BottomNavigationBar( + currentIndex: _index, + onTap: (i) { + if (i == _index) return; + HapticFeedback.selectionClick(); + setState(() => _index = i); + }, + backgroundColor: const Color(0xFFFAFAF7), + selectedItemColor: _teal, + unselectedItemColor: Colors.black54, + type: BottomNavigationBarType.fixed, + items: [ + BottomNavigationBarItem( + icon: AnimatedNavIcon(icon: Icons.home_rounded, selected: _index == 0), + label: 'Início', + ), + BottomNavigationBarItem( + icon: AnimatedNavIcon( + icon: Icons.person_rounded, + selected: _index == 1, + ), + label: 'Perfil', + ), + BottomNavigationBarItem( + icon: AnimatedNavIcon( + icon: Icons.settings_rounded, + selected: _index == 2, + ), + label: 'Ajustes', + ), + ], + ); + } + @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); @@ -293,287 +362,229 @@ class _LoggedHomeScreenState extends State 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 - ? (hasScore ? _expandedAppBarHeight : _nameOnlyAppBarHeight) - : _collapsedAppBarHeight; - final double toolbarHeight = _index == 0 ? kToolbarHeight : appBarHeight; - final String title = _index == 0 - ? '' - : _index == 1 - ? 'Perfil' - : 'Configurações'; - final ShapeBorder appBarShape = _index == 0 - ? const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(40)), - ) - : const RoundedRectangleBorder(borderRadius: BorderRadius.zero); - final shownName = _cachedUserName; - final double bodyTopPadding = _index == 0 ? 0 : 10; + if (_index == 0) { + // Home: a app bar é retrátil — encolhe (escondendo o nome da criança e + // o gauge) à medida que o utilizador scrolla a lista, mantendo só a + // linha do avatar/saudação fixa no topo. É um SliverAppBar dentro de + // um NestedScrollView em vez do AppBar fixo usado nas outras abas, + // para que a altura acompanhe o scroll em vez de ficar sempre cheia + // (o que cortava o conteúdo do card do quiz contra a barra ao rolar). + final double expandedHeight = hasScore + ? _expandedAppBarHeight + : _nameOnlyAppBarHeight; - return Scaffold( - appBar: PreferredSize( - preferredSize: Size.fromHeight(appBarHeight), - child: AnimatedSize( - duration: const Duration(milliseconds: 320), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: SizedBox( - height: appBarHeight, - child: AppBar( - toolbarHeight: toolbarHeight, - clipBehavior: Clip.antiAlias, - flexibleSpace: ClipRRect( - borderRadius: BorderRadius.vertical( - bottom: Radius.circular(_index == 0 ? 40 : 0), - ), - child: Container( - decoration: const BoxDecoration(gradient: kAppBarGradient), - child: _index != 0 - ? null - : Stack( - fit: StackFit.expand, - children: [ - Opacity( - opacity: 0.22, - child: Transform.scale(scale: 1.25), - ), - if (hasScore) - Positioned( - left: 0, - right: 0, - top: toolbarHeight + 34, - 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, - ), - ), - ), - ), - if (hasScore) - Positioned( - left: 0, - right: 0, - bottom: 4, - child: Center( - child: _RiskArcGauge(percent: percent), - ), - ), - ], - ), - ), + return Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverAppBar( + expandedHeight: expandedHeight, + toolbarHeight: kToolbarHeight, + pinned: true, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: _teal, + foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(bottom: Radius.circular(40)), ), - title: Align( - alignment: _index != 0 - ? Alignment.center - : (_selectedChildName ?? '').trim().isEmpty - ? Alignment.centerLeft - : Alignment.topLeft, - child: _index == 0 - ? Padding( - padding: const EdgeInsets.only(left: 16, right: 10), - 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, + flexibleSpace: FlexibleSpaceBar( + titlePadding: EdgeInsets.zero, + background: ClipRRect( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(40), + ), + child: Container( + decoration: const BoxDecoration(gradient: kAppBarGradient), + child: Stack( + fit: StackFit.expand, + children: [ + if (hasScore) + Positioned( + left: 0, + right: 0, + top: kToolbarHeight + 34, + child: Center( + child: Text( + (_selectedChildName ?? '').trim(), + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w800, + color: Colors.white.withValues(alpha: 0.92), + fontSize: 14, ), - 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, - ), - 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, - ), - ), - Text( - shownName, - textAlign: TextAlign.left, - style: const TextStyle( - fontWeight: FontWeight.w900, - color: Colors.white, - fontSize: 19, - ), - ), - ], - ), - ], + ), + ), + ) + else if ((_selectedChildName ?? '').trim().isNotEmpty) + Positioned( + left: 0, + right: 0, + top: kToolbarHeight, + 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, ), ), ), ), - ), - ) - : Text( - title, - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.w900, - color: Colors.white, - ), - ), - ), - centerTitle: _index == 0 ? false : true, - backgroundColor: _teal, - foregroundColor: Colors.white, - surfaceTintColor: _teal, - elevation: 0, - shape: appBarShape, - ), - ), - ), - ), - body: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill(child: Container(color: const Color(0xFFFFE6F1))), - Positioned( - left: -size.width * 0.40, - bottom: -size.width * 0.45, - 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, + //posição da app bar relativamente ao nome + if (hasScore) + Positioned( + left: 0, + right: 0, + top: kToolbarHeight + 68, + child: Center( + child: _RiskArcGauge(percent: percent), + ), + ), + ], ), ), ), ), + title: Padding( + padding: const EdgeInsets.only(left: 16, right: 10), + 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, + ), + 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, + ), + ), + Text( + shownName, + textAlign: TextAlign.left, + style: const TextStyle( + fontWeight: FontWeight.w900, + color: Colors.white, + fontSize: 19, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + centerTitle: false, + titleSpacing: 0, ), + ], + body: _decoratedBody( + size, + _InicioTab(onQuizClosed: _loadQuizResult), + 0, ), - SafeArea( - top: false, - child: Align( - alignment: Alignment.center, - child: Padding( - padding: EdgeInsets.fromLTRB(16, bodyTopPadding, 16, 16), - child: _index == 0 - ? _InicioTab(onQuizClosed: _loadQuizResult) - : _index == 1 - ? _PerfilTab( - selectedChildIndex: _selectedChildIndex, - onChildSelected: (index, name, scopeId) { - setState(() { - _selectedChildIndex = index; - _selectedChildName = name; - _selectedChildScopeId = scopeId; - }); - _loadQuizResult(); - refreshStats(); - }, - ) - : const SettingsBody(), + ), + bottomNavigationBar: _bottomNav(), + ); + } + + final String title = _index == 1 ? 'Perfil' : 'Configurações'; + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(_collapsedAppBarHeight), + // O gradiente é pintado por este Container de tamanho fixo (a + // própria altura da app bar), em vez de confiar no `flexibleSpace` + // do AppBar — em alguns aparelhos, o `flexibleSpace` de um AppBar + // comum (não Sliver) não recebia o tamanho esperado e a gradiente + // aparecia como cor sólida. Com o Container por baixo e o AppBar + // totalmente transparente por cima, o gradiente é garantido. + child: Container( + decoration: const BoxDecoration(gradient: kAppBarGradient), + child: AppBar( + toolbarHeight: _collapsedAppBarHeight, + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + title: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w900, + color: Colors.white, ), ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), ), - ], + ), ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _index, - onTap: (i) { - if (i == _index) return; - HapticFeedback.selectionClick(); - setState(() => _index = i); - }, - backgroundColor: const Color(0xFFFFE6F1), - selectedItemColor: _teal, - unselectedItemColor: Colors.black54, - type: BottomNavigationBarType.fixed, - items: [ - BottomNavigationBarItem( - icon: AnimatedNavIcon( - icon: Icons.home_rounded, - selected: _index == 0, - ), - label: 'Início', - ), - BottomNavigationBarItem( - icon: AnimatedNavIcon( - icon: Icons.person_rounded, - selected: _index == 1, - ), - label: 'Perfil', - ), - BottomNavigationBarItem( - icon: AnimatedNavIcon( - icon: Icons.settings_rounded, - selected: _index == 2, - ), - label: 'Ajustes', - ), - ], + body: _decoratedBody( + size, + _index == 1 + ? _PerfilTab( + selectedChildIndex: _selectedChildIndex, + onChildSelected: (index, name, scopeId) { + setState(() { + _selectedChildIndex = index; + _selectedChildName = name; + _selectedChildScopeId = scopeId; + }); + _loadQuizResult(); + refreshStats(); + }, + ) + : const SettingsBody(), + 10, ), + bottomNavigationBar: _bottomNav(), ); } } @@ -644,13 +655,13 @@ class _RiskArcGaugePainter extends CustomPainter { ..color = Colors.white.withValues(alpha: 0.72) ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth - ..strokeCap = StrokeCap.butt; + ..strokeCap = StrokeCap.round; final progressPaint = Paint() ..color = const Color(0xFFFF9AD0) ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth - ..strokeCap = StrokeCap.butt; + ..strokeCap = StrokeCap.round; canvas.drawArc(rect, startAngle, sweepAngle, false, backgroundPaint); canvas.drawArc( @@ -721,8 +732,6 @@ class _InicioTab extends StatelessWidget { final state = context.findAncestorStateOfType<_LoggedHomeScreenState>(); final selectedChildName = (state?._selectedChildName ?? '').trim(); final scopeId = state?._selectedChildScopeId; - final featured = state?._continueVideo ?? videoList.first; - final hasWatchedAny = (state?._watchedVideoCount ?? 0) > 0; return Align( alignment: Alignment.topCenter, @@ -761,30 +770,24 @@ class _InicioTab extends StatelessWidget { const SizedBox(height: 20), FadeSlideIn( delay: const Duration(milliseconds: 110), - child: const _HomeSectionLabel('Continuar onde parou'), + child: const _HomeSectionLabel('Vídeos educativos'), ), const SizedBox(height: 8), FadeSlideIn( delay: const Duration(milliseconds: 130), - child: _ContinueWatchingRow( - video: featured, - hasWatchedAny: hasWatchedAny, - onTap: () async { - await showVideoPlayerDialog( - context, - featured, - scopeId: scopeId, - ); - await state?.refreshStats(); - }, - onViewAll: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => VideoScreen(scopeId: scopeId), - ), - ); - await state?.refreshStats(); - }, + child: TapBounce( + scale: 0.97, + child: _VideoLibraryCard( + watchedCount: state?._watchedVideoCount ?? 0, + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VideoScreen(scopeId: scopeId), + ), + ); + await state?.refreshStats(); + }, + ), ), ), const SizedBox(height: 16), @@ -825,6 +828,48 @@ class _InicioTab extends StatelessWidget { } } +/// Faz [child] "respirar" (escala sobe e desce suavemente, em loop) — usado +/// para chamar a atenção para os dois destaques principais da Home (o quiz +/// e o vídeo em destaque), que são o carro-chefe da app. +class _Pulse extends StatefulWidget { + const _Pulse({ + required this.child, + this.minScale = 0.94, + this.maxScale = 1.0, + this.duration = const Duration(milliseconds: 1100), + }); + + final Widget child; + final double minScale; + final double maxScale; + final Duration duration; + + @override + State<_Pulse> createState() => _PulseState(); +} + +class _PulseState extends State<_Pulse> with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.duration, + )..repeat(reverse: true); + late final Animation _scale = Tween( + begin: widget.minScale, + end: widget.maxScale, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition(scale: _scale, child: widget.child); + } +} + /// Pequeno rótulo maiúsculo discreto usado para separar as secções da Home /// ("Para {nome}", "Novo episódio", "Continuar a aprender"). class _HomeSectionLabel extends StatelessWidget { @@ -1140,24 +1185,59 @@ class _HeroQuizCard extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - elevation: 12, - shadowColor: Colors.black.withValues(alpha: 0.22), - borderRadius: BorderRadius.circular(24), + elevation: 18, + shadowColor: const Color(0xFFFF55A7).withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(28), clipBehavior: Clip.antiAlias, color: Colors.transparent, child: Ink( decoration: const BoxDecoration(gradient: kPinkHeroGradient), child: Stack( + clipBehavior: Clip.none, children: [ Positioned( - right: -30, - bottom: -30, + right: -34, + bottom: -34, child: IgnorePointer( child: Opacity( opacity: 0.14, child: Container( - width: 140, - height: 140, + width: 160, + height: 160, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + Positioned( + right: 4, + bottom: 10, + child: IgnorePointer( + child: Opacity( + opacity: 0.16, + child: Transform.rotate( + angle: -0.22, + child: const Icon( + Icons.health_and_safety_rounded, + size: 92, + color: Colors.white, + ), + ), + ), + ), + ), + Positioned( + left: -18, + top: -18, + child: IgnorePointer( + child: Opacity( + opacity: 0.10, + child: Container( + width: 70, + height: 70, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, @@ -1167,70 +1247,100 @@ class _HeroQuizCard extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.22), - borderRadius: BorderRadius.circular(999), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.bolt_rounded, color: Colors.white, size: 14), - SizedBox(width: 4), - Text( - 'Avaliação', - style: TextStyle( + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.bolt_rounded, + color: Colors.white, + size: 14, + ), + SizedBox(width: 4), + Text( + 'Avaliação gratuita', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + _Pulse( + minScale: 0.85, + maxScale: 1.15, + duration: const Duration(milliseconds: 900), + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( color: Colors.white, - fontWeight: FontWeight.w800, - fontSize: 12, + shape: BoxShape.circle, ), ), - ], - ), + ), + ], ), - const SizedBox(height: 12), + const SizedBox(height: 14), const Text( 'Avaliação de saúde oral', style: TextStyle( fontWeight: FontWeight.w900, - fontSize: 19, + fontSize: 22, + height: 1.1, color: Colors.white, ), ), - const SizedBox(height: 4), + const SizedBox(height: 5), Text( - 'Leva menos de 3 minutos a completar', + '23 perguntas rápidas · menos de 3 minutos', style: TextStyle( - color: Colors.white.withValues(alpha: 0.9), + color: Colors.white.withValues(alpha: 0.92), fontWeight: FontWeight.w600, fontSize: 13, ), ), - const SizedBox(height: 16), - SizedBox( - height: 48, - width: double.infinity, - child: FilledButton.icon( - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: const Color(0xFFFF55A7), - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 15, + const SizedBox(height: 18), + _Pulse( + minScale: 0.985, + maxScale: 1.0, + duration: const Duration(milliseconds: 1400), + child: SizedBox( + height: 50, + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFFFF55A7), + shape: const StadiumBorder(), + elevation: 6, + shadowColor: Colors.black.withValues(alpha: 0.25), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15.5, + ), ), + onPressed: onStartQuiz, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Iniciar Quiz'), ), - onPressed: onStartQuiz, - icon: const Icon(Icons.play_arrow_rounded), - label: const Text('Iniciar Quiz'), ), ), ], @@ -1243,118 +1353,146 @@ class _HeroQuizCard extends StatelessWidget { } } -/// Linha compacta que mostra o próximo episódio por assistir (ou o último, -/// se já viu todos) para retomar o progresso de onde a criança parou. Um -/// segundo toque, no rodapé, abre a grelha completa de vídeos. Propositadamente -/// sem preview de vídeo real (sem [VideoThumbnail]/controllers) — evita a -/// contenção de decodificadores que já causou travamentos nesta app. -class _ContinueWatchingRow extends StatelessWidget { - const _ContinueWatchingRow({ - required this.video, - required this.hasWatchedAny, - required this.onTap, - required this.onViewAll, - }); +/// Card de destaque (mesma linguagem visual do card do quiz: gradiente, +/// badge, título, botão branco) que convida a criança/pai a ir ver a +/// biblioteca de vídeos — sem nenhuma miniatura/imagem de vídeo específica, +/// só ícone e texto. Um único toque (no card ou no botão) leva direto à +/// grelha onde se escolhe qual episódio assistir. +class _VideoLibraryCard extends StatelessWidget { + const _VideoLibraryCard({required this.watchedCount, required this.onTap}); - final VideoData video; - final bool hasWatchedAny; + final int watchedCount; final VoidCallback onTap; - final VoidCallback onViewAll; @override Widget build(BuildContext context) { return Material( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TapBounce( - scale: 0.98, - child: InkWell( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(18), - ), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: const Color(0xFF2F9E94), - borderRadius: BorderRadius.circular(13), - ), - child: const Icon( - Icons.play_arrow_rounded, + elevation: 14, + shadowColor: const Color(0xFF2F9E94).withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(28), + clipBehavior: Clip.antiAlias, + color: Colors.transparent, + child: Ink( + decoration: const BoxDecoration(gradient: kGreenButtonGradient), + child: InkWell( + onTap: onTap, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + right: -30, + bottom: -30, + child: IgnorePointer( + child: Opacity( + opacity: 0.14, + child: Container( + width: 150, + height: 150, + decoration: const BoxDecoration( color: Colors.white, - size: 24, + shape: BoxShape.circle, ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + ), + ), + Positioned( + right: 6, + bottom: 6, + child: IgnorePointer( + child: Opacity( + opacity: 0.16, + child: Transform.rotate( + angle: 0.2, + child: const Icon( + Icons.smart_display_rounded, + size: 88, + color: Colors.white, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, children: [ - const Text( - 'Continuar onde parou', - style: TextStyle( - fontWeight: FontWeight.w900, - fontSize: 15, - color: Color(0xFFFF55A7), - ), + Icon( + Icons.play_circle_fill_rounded, + color: Colors.white, + size: 14, ), - const SizedBox(height: 2), + SizedBox(width: 4), Text( - '${video.title} · ${hasWatchedAny ? "continue de onde parou" : "comece a assistir"}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( + 'Biblioteca de vídeos', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.black54, ), ), ], ), ), - const Icon( - Icons.chevron_right_rounded, - color: Color(0xFF2F9E94), - size: 24, + const SizedBox(height: 14), + const Text( + 'Vídeos educativos', + style: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 22, + height: 1.1, + color: Colors.white, + ), + ), + const SizedBox(height: 5), + Text( + watchedCount > 0 + ? '$watchedCount episódio${watchedCount == 1 ? '' : 's'} completo${watchedCount == 1 ? '' : 's'} · ${videoList.length} no total' + : '${videoList.length} episódios sobre saúde oral para toda a família', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.92), + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + const SizedBox(height: 18), + SizedBox( + height: 48, + width: double.infinity, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF2F9E94), + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, + ), + ), + onPressed: onTap, + icon: const Icon(Icons.video_library_rounded), + label: const Text('Ver vídeos'), + ), ), ], ), ), - ), + ], ), - const Divider(height: 1), - TapBounce( - scale: 0.98, - child: InkWell( - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(18), - ), - onTap: onViewAll, - child: const Padding( - padding: EdgeInsets.symmetric(vertical: 10), - child: Text( - 'Ver todos os episódios', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w800, - color: Color(0xFF2F9E94), - ), - ), - ), - ), - ), - ], + ), ), ); } diff --git a/lib/main.dart b/lib/main.dart index 16dcf0e..5d1a209 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,8 +40,16 @@ class MyApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2F9E94)), - scaffoldBackgroundColor: const Color(0xFFFFE2EF), + scaffoldBackgroundColor: const Color(0xFFFAFAF7), useMaterial3: true, + // Sem isto, o Material 3 aplica por padrão uma sobreposição de cor + // (surfaceTintColor) e uma elevação extra ao rolar (scrolledUnderElevation) + // por cima de qualquer app bar — o que "lavava" o gradiente customizado + // das app bars, fazendo-o parecer uma cor sólida em vez de gradiente. + appBarTheme: const AppBarTheme( + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + ), ), localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, diff --git a/lib/quiz/quiz_question_screen.dart b/lib/quiz/quiz_question_screen.dart index 9dd497f..d81382a 100644 --- a/lib/quiz/quiz_question_screen.dart +++ b/lib/quiz/quiz_question_screen.dart @@ -148,15 +148,7 @@ class _QuizQuestionScreenState extends State { clipBehavior: Clip.none, children: [ Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], - ), - ), - ), + child: Container(color: const Color(0xFFFAFAF7)), ), Positioned( left: -size.width * 0.40, diff --git a/lib/quiz/quiz_result.dart b/lib/quiz/quiz_result.dart index f57e557..5bb793c 100644 --- a/lib/quiz/quiz_result.dart +++ b/lib/quiz/quiz_result.dart @@ -88,13 +88,7 @@ class _QuizResultScreenState extends State { return Scaffold( body: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], - ), - ), + color: const Color(0xFFFAFAF7), child: SafeArea( child: Center( child: ConstrainedBox( diff --git a/lib/screens/curiosidade_screen.dart b/lib/screens/curiosidade_screen.dart index 5387ae6..62c1a2c 100644 --- a/lib/screens/curiosidade_screen.dart +++ b/lib/screens/curiosidade_screen.dart @@ -10,38 +10,33 @@ import '../widgets/tap_bounce.dart'; class CuriosidadeScreen extends StatelessWidget { const CuriosidadeScreen({super.key}); - static const Color _teal = Color(0xFF2F9E94); - @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); return Scaffold( - appBar: AppBar( - backgroundColor: _teal, - foregroundColor: Colors.white, - elevation: 0, - flexibleSpace: Container( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: Container( decoration: const BoxDecoration(gradient: kAppBarGradient), - ), - title: const Text( - 'Curiosidades', - style: TextStyle(fontWeight: FontWeight.w900), + child: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + title: const Text( + 'Curiosidades', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), ), ), 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)], - ), - ), - ), + child: Container(color: const Color(0xFFFAFAF7)), ), Positioned( left: -size.width * 0.40, diff --git a/lib/screens/hello_splash_screen.dart b/lib/screens/hello_splash_screen.dart index 217a18b..a0c8bf3 100644 --- a/lib/screens/hello_splash_screen.dart +++ b/lib/screens/hello_splash_screen.dart @@ -76,7 +76,7 @@ class _HelloSplashScreenState extends State with TickerProvid child: Container( width: size.width, height: size.height, - color: const Color(0xFFFFC9DF), + color: const Color(0xFFFAFAF7), child: SafeArea( child: Center( child: Column( @@ -90,7 +90,7 @@ class _HelloSplashScreenState extends State with TickerProvid style: TextStyle( fontSize: 64, fontWeight: FontWeight.w900, - color: Colors.white, + color: const Color(0xFFFF9AD0), height: 1.0, ), ), diff --git a/lib/screens/terms_screen.dart b/lib/screens/terms_screen.dart index 9059d52..dc5efef 100644 --- a/lib/screens/terms_screen.dart +++ b/lib/screens/terms_screen.dart @@ -5,32 +5,30 @@ import '../widgets/app_gradients.dart'; class TermsScreen extends StatelessWidget { const TermsScreen({super.key}); - static const Color _teal = Color(0xFF2F9E94); static const Color _accentPink = Color(0xFFFF55A7); @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - backgroundColor: _teal, - foregroundColor: Colors.white, - elevation: 0, - flexibleSpace: Container( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: Container( decoration: const BoxDecoration(gradient: kAppBarGradient), - ), - title: const Text( - 'Termos de Serviço', - style: TextStyle(fontWeight: FontWeight.w900), + child: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + title: const Text( + 'Termos de Serviço', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), ), ), body: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], - ), - ), + color: const Color(0xFFFAFAF7), child: SafeArea( child: Padding( padding: const EdgeInsets.all(20), diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index e51e8c9..eea6420 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -38,79 +38,79 @@ final List videoList = [ VideoData( id: 1, title: 'Episódio 1', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência do nariz entupido na má oclusão', youtubeId: 'PJ58CZv4ECw', ), VideoData( id: 2, title: 'Episódio 2', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência das alergias sazionais na má oclusão', youtubeId: 'y4_kWmZtAtg', ), VideoData( id: 3, title: 'Episódio 3', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência das Otites frequentes na má oclusão', youtubeId: 'nD75Y5PuKTo', ), VideoData( id: 4, title: 'Episódio 4', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência das Amigdalites recorrentes na má oclusão', youtubeId: 'yvFllWYeuLw', ), VideoData( id: 5, title: 'Episódio 5', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência das Bronquiolites recorrentes na má oclusão', youtubeId: 'DnhUa-T8_Ps', ), VideoData( id: 6, title: 'Episódio 6', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência dos problemas respitatórios na má oclusão', youtubeId: 'zKt_iwkrjvo', ), VideoData( id: 7, title: 'Episódio 7', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência das interrupções respiratórias na má oclusão', youtubeId: 'NpmQ2brap5A', ), VideoData( id: 8, title: 'Episódio 8', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência do ressonar na má oclusão', youtubeId: 'Wj3KYw9pBi0', ), VideoData( id: 9, title: 'Episódio 9', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência de acordar com saliva seca na boca ou na almofada na saúde oral', youtubeId: 'bOm9t61cT_U', ), VideoData( id: 10, title: 'Episódio 10', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a Influência da respiração oral na má oclusão', youtubeId: 'fAitMizbcms', ), VideoData( id: 11, title: 'Episódio 11', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a influência do uso exagerado da chupeta na má oclusão', youtubeId: '6sYoBUjks_I', ), VideoData( id: 12, title: 'Episódio 12', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a influência do uso exagerado da chupeta na má oclusão', youtubeId: 'eznKrErQbHo', ), VideoData( id: 13, title: 'Episódio 13', - description: 'Aprenda sobre saúde bucal neste episódio', + description: 'Qual a influência do hábito de chuchar o dedo na má oclusão', youtubeId: 'VO9CNqHRdeM', ), ]; @@ -236,32 +236,32 @@ class _VideoScreenState extends State { Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); return Scaffold( - appBar: AppBar( - backgroundColor: VideoScreen._teal, - foregroundColor: Colors.white, - surfaceTintColor: VideoScreen._teal, - elevation: 0, - flexibleSpace: Container( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + // O gradiente é pintado por este Container de tamanho fixo, em vez + // de confiar no `flexibleSpace` do AppBar — em alguns aparelhos o + // `flexibleSpace` de um AppBar comum não recebia o tamanho esperado + // e a gradiente aparecia como cor sólida. + child: Container( decoration: const BoxDecoration(gradient: kAppBarGradient), - ), - title: const Text( - 'Videos Educativos', - style: TextStyle(fontWeight: FontWeight.w900), + child: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + title: const Text( + 'Videos Educativos', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), ), ), 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)], - ), - ), - ), + child: Container(color: const Color(0xFFFAFAF7)), ), Positioned( left: -size.width * 0.40, @@ -548,27 +548,84 @@ class _VideoButton extends StatelessWidget { return TapBounce( scale: 0.95, child: Material( - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(16), + elevation: 10, + shadowColor: Colors.black.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(18), color: Colors.white, child: InkWell( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(18), onTap: () => _showVideoPlayer(context, video), child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - height: 80, - decoration: BoxDecoration( - color: const Color(0xFFFFE6F1), - borderRadius: BorderRadius.circular(12), + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: const Color(0xFFFFE6F1), + child: VideoThumbnail(video: video, borderRadius: 0), + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.32), + ], + stops: const [0.55, 1.0], + ), + ), + ), + Center( + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.92), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: VideoScreen._accentPink, + size: 20, + ), + ), + ), + Positioned( + left: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'EP. ${video.id}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 9.5, + ), + ), + ), + ), + ], + ), ), - child: VideoThumbnail(video: video), ), - const SizedBox(height: 10), + const SizedBox(height: 8), Text( video.title, style: const TextStyle( diff --git a/lib/watched_videos_prefs.dart b/lib/watched_videos_prefs.dart index 151e7dc..12fa80e 100644 --- a/lib/watched_videos_prefs.dart +++ b/lib/watched_videos_prefs.dart @@ -27,10 +27,4 @@ class WatchedVideosPrefs { final ids = prefs.getStringList(_key(scopeId)) ?? const []; return ids.length; } - - static Future> getWatchedIds(String scopeId) async { - final prefs = await SharedPreferences.getInstance(); - final ids = prefs.getStringList(_key(scopeId)) ?? const []; - return ids.map((e) => int.tryParse(e) ?? -1).where((e) => e >= 0).toSet(); - } } diff --git a/lib/widgets/app_gradients.dart b/lib/widgets/app_gradients.dart index 40351d9..1fa1380 100644 --- a/lib/widgets/app_gradients.dart +++ b/lib/widgets/app_gradients.dart @@ -1,18 +1,25 @@ import 'package:flutter/material.dart'; -/// Gradiente usado em todas as app bars da aplicação. +/// Gradiente usado em todas as app bars da aplicação. Direção puramente +/// horizontal (não diagonal) de propósito: a app bar retrátil da Home é +/// alta (~190) enquanto as das outras abas são baixas (~56) — um gradiente +/// diagonal mostra proporções de cor bem diferentes consoante a altura, +/// fazendo a Home parecer mais "cheia de cor" que as outras. Na horizontal, +/// a transição depende só da largura (igual em todas as app bars), por +/// isso todas ficam visualmente idênticas. const LinearGradient kAppBarGradient = LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xFF6BB79F), Color(0xFF6BB79F)], -); - -/// Gradiente usado nos botões verdes da aplicação — um toque da cor da app -/// bar (#6BB79F) misturado com o teal original. -const LinearGradient kGreenButtonGradient = LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, - colors: [Color(0xFF2F9E94), Color(0xFF6BB79F)], + colors: [Color(0xFF1C7A6E), Color(0xFF8FD4BB)], +); + +/// Cor sólida (sem gradiente, de propósito) usada nos botões e cards verdes +/// da aplicação — botões de "Entrar"/"Criar conta", "Adicionar criança" e o +/// card "Vídeos educativos". Só as app bars usam gradiente ([kAppBarGradient]); +/// mantém-se como [LinearGradient] com as duas cores iguais para não obrigar +/// a mudar todos os `decoration: gradient: kGreenButtonGradient` existentes. +const LinearGradient kGreenButtonGradient = LinearGradient( + colors: [Color(0xFF2F9E94), Color(0xFF2F9E94)], ); /// Gradiente rosa vivo do card do quiz na Home.