This commit is contained in:
Carlos Correia
2026-07-10 23:53:33 +01:00
parent 30068d1501
commit 824fdb8089
12 changed files with 771 additions and 578 deletions

View File

@@ -15,10 +15,36 @@ class BrushingPrefs {
static String _key(String base, String scopeId) => '${base}_$scopeId'; 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<List<DateTime>> _getEntries(String scopeId) async { static Future<List<DateTime>> _getEntries(String scopeId) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getStringList(_key(_kDatesKey, scopeId)) ?? const []; final key = _key(_kDatesKey, scopeId);
return raw.map(DateTime.tryParse).whereType<DateTime>().toList(); final raw = prefs.getStringList(key) ?? const [];
final entries = raw.map(DateTime.tryParse).whereType<DateTime>().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<int> getWeeklyGoal(String scopeId) async { static Future<int> getWeeklyGoal(String scopeId) async {
@@ -51,11 +77,15 @@ class BrushingPrefs {
/// o utilizador). /// o utilizador).
static Future<void> logToday(String scopeId) async { static Future<void> logToday(String scopeId) async {
if (!(await canLogMore(scopeId))) return; 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 prefs = await SharedPreferences.getInstance();
final key = _key(_kDatesKey, scopeId); await prefs.setStringList(
final list = prefs.getStringList(key) ?? <String>[]; _key(_kDatesKey, scopeId),
list.add(DateTime.now().toIso8601String()); entries.map((d) => d.toIso8601String()).toList(),
await prefs.setStringList(key, list); );
} }
/// Já atingiu o limite diário de [maxPerDay] escovagens hoje? /// Já atingiu o limite diário de [maxPerDay] escovagens hoje?
@@ -64,21 +94,9 @@ class BrushingPrefs {
} }
/// Conta quantas escovagens foram registadas na semana atual /// 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<int> getWeekCount(String scopeId) async { static Future<int> getWeekCount(String scopeId) async {
final entries = await _getEntries(scopeId); return (await _getEntries(scopeId)).length;
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;
} }
} }

View File

@@ -166,15 +166,7 @@ class _HomeScreenState extends State<HomeScreen> {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned.fill( Positioned.fill(
child: Container( child: Container(color: const Color(0xFFFAFAF7)),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
),
),
),
), ),
Positioned( Positioned(
left: -size.width * 0.38, left: -size.width * 0.38,

View File

@@ -60,7 +60,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
static const Color _teal = Color(0xFF2F9E94); static const Color _teal = Color(0xFF2F9E94);
static const String _kPendingQuizScopeKey = 'pending_quiz_scope_v1'; 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 _expandedAppBarHeight = 190;
static const double _nameOnlyAppBarHeight = 160; static const double _nameOnlyAppBarHeight = 160;
@@ -77,7 +77,6 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal; int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
bool _brushingDailyLimitReached = false; bool _brushingDailyLimitReached = false;
int? _watchedVideoCount; int? _watchedVideoCount;
VideoData? _continueVideo;
String _cachedUserName = 'Sem nome'; String _cachedUserName = 'Sem nome';
String? _cachedPhotoUrl; String? _cachedPhotoUrl;
@@ -105,7 +104,6 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
_weeklyGoal = BrushingPrefs.defaultWeeklyGoal; _weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
_brushingDailyLimitReached = false; _brushingDailyLimitReached = false;
_watchedVideoCount = null; _watchedVideoCount = null;
_continueVideo = videoList.first;
}); });
return; return;
} }
@@ -114,11 +112,6 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
final goal = await BrushingPrefs.getWeeklyGoal(scope); final goal = await BrushingPrefs.getWeeklyGoal(scope);
final dailyLimitReached = await BrushingPrefs.hasReachedDailyLimit(scope); final dailyLimitReached = await BrushingPrefs.hasReachedDailyLimit(scope);
final watchedCount = await WatchedVideosPrefs.getWatchedCount(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; if (!mounted) return;
setState(() { setState(() {
@@ -126,7 +119,6 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
_weeklyGoal = goal; _weeklyGoal = goal;
_brushingDailyLimitReached = dailyLimitReached; _brushingDailyLimitReached = dailyLimitReached;
_watchedVideoCount = watchedCount; _watchedVideoCount = watchedCount;
_continueVideo = continueVideo;
}); });
} }
@@ -285,6 +277,83 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
return 'Boa noite'; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
@@ -293,79 +362,67 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
final int? maxScore = _lastMaxScore; final int? maxScore = _lastMaxScore;
final bool hasScore = score != null && maxScore != null && maxScore > 0; final bool hasScore = score != null && maxScore != null && maxScore > 0;
final int percent = hasScore ? ((score / maxScore) * 100).round() : 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 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( return Scaffold(
appBar: PreferredSize( body: NestedScrollView(
preferredSize: Size.fromHeight(appBarHeight), headerSliverBuilder: (context, innerBoxIsScrolled) => [
child: AnimatedSize( SliverAppBar(
duration: const Duration(milliseconds: 320), expandedHeight: expandedHeight,
curve: Curves.easeOutCubic, toolbarHeight: kToolbarHeight,
alignment: Alignment.topCenter, pinned: true,
child: SizedBox( elevation: 0,
height: appBarHeight, scrolledUnderElevation: 0,
child: AppBar( backgroundColor: _teal,
toolbarHeight: toolbarHeight, foregroundColor: Colors.white,
clipBehavior: Clip.antiAlias, surfaceTintColor: Colors.transparent,
flexibleSpace: ClipRRect( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(bottom: Radius.circular(40)),
bottom: Radius.circular(_index == 0 ? 40 : 0), ),
flexibleSpace: FlexibleSpaceBar(
titlePadding: EdgeInsets.zero,
background: ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(40),
), ),
child: Container( child: Container(
decoration: const BoxDecoration(gradient: kAppBarGradient), decoration: const BoxDecoration(gradient: kAppBarGradient),
child: _index != 0 child: Stack(
? null
: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Opacity(
opacity: 0.22,
child: Transform.scale(scale: 1.25),
),
if (hasScore) if (hasScore)
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
top: toolbarHeight + 34, top: kToolbarHeight + 34,
child: Center( child: Center(
child: Text( child: Text(
(_selectedChildName ?? '').trim(), (_selectedChildName ?? '').trim(),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: Colors.white.withValues( color: Colors.white.withValues(alpha: 0.92),
alpha: 0.92,
),
fontSize: 14, fontSize: 14,
), ),
), ),
), ),
) )
else if ((_selectedChildName ?? '') else if ((_selectedChildName ?? '').trim().isNotEmpty)
.trim()
.isNotEmpty)
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
top: toolbarHeight, top: kToolbarHeight,
bottom: 0, bottom: 0,
child: Center( child: Center(
child: Text( child: Text(
@@ -373,19 +430,18 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: Colors.white.withValues( color: Colors.white.withValues(alpha: 0.92),
alpha: 0.92,
),
fontSize: 14, fontSize: 14,
), ),
), ),
), ),
), ),
//posição da app bar relativamente ao nome
if (hasScore) if (hasScore)
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
bottom: 4, top: kToolbarHeight + 68,
child: Center( child: Center(
child: _RiskArcGauge(percent: percent), child: _RiskArcGauge(percent: percent),
), ),
@@ -394,14 +450,8 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
), ),
), ),
), ),
title: Align( ),
alignment: _index != 0 title: Padding(
? Alignment.center
: (_selectedChildName ?? '').trim().isEmpty
? Alignment.centerLeft
: Alignment.topLeft,
child: _index == 0
? Padding(
padding: const EdgeInsets.only(left: 16, right: 10), padding: const EdgeInsets.only(left: 16, right: 10),
child: TapBounce( child: TapBounce(
scale: 0.96, scale: 0.96,
@@ -436,8 +486,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Column( Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
@@ -467,8 +516,43 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
), ),
), ),
), ),
) ),
: Text( centerTitle: false,
titleSpacing: 0,
),
],
body: _decoratedBody(
size,
_InicioTab(onQuizClosed: _loadQuizResult),
0,
),
),
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, title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
@@ -476,51 +560,15 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
color: Colors.white, color: Colors.white,
), ),
), ),
), shape: const RoundedRectangleBorder(
centerTitle: _index == 0 ? false : true, borderRadius: BorderRadius.zero,
backgroundColor: _teal,
foregroundColor: Colors.white,
surfaceTintColor: _teal,
elevation: 0,
shape: appBarShape,
), ),
), ),
), ),
), ),
body: Stack( body: _decoratedBody(
clipBehavior: Clip.none, size,
children: [ _index == 1
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,
),
),
),
),
),
),
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( ? _PerfilTab(
selectedChildIndex: _selectedChildIndex, selectedChildIndex: _selectedChildIndex,
onChildSelected: (index, name, scopeId) { onChildSelected: (index, name, scopeId) {
@@ -534,46 +582,9 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
}, },
) )
: const SettingsBody(), : const SettingsBody(),
10,
), ),
), bottomNavigationBar: _bottomNav(),
),
],
),
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',
),
],
),
); );
} }
} }
@@ -644,13 +655,13 @@ class _RiskArcGaugePainter extends CustomPainter {
..color = Colors.white.withValues(alpha: 0.72) ..color = Colors.white.withValues(alpha: 0.72)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = strokeWidth ..strokeWidth = strokeWidth
..strokeCap = StrokeCap.butt; ..strokeCap = StrokeCap.round;
final progressPaint = Paint() final progressPaint = Paint()
..color = const Color(0xFFFF9AD0) ..color = const Color(0xFFFF9AD0)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = strokeWidth ..strokeWidth = strokeWidth
..strokeCap = StrokeCap.butt; ..strokeCap = StrokeCap.round;
canvas.drawArc(rect, startAngle, sweepAngle, false, backgroundPaint); canvas.drawArc(rect, startAngle, sweepAngle, false, backgroundPaint);
canvas.drawArc( canvas.drawArc(
@@ -721,8 +732,6 @@ class _InicioTab extends StatelessWidget {
final state = context.findAncestorStateOfType<_LoggedHomeScreenState>(); final state = context.findAncestorStateOfType<_LoggedHomeScreenState>();
final selectedChildName = (state?._selectedChildName ?? '').trim(); final selectedChildName = (state?._selectedChildName ?? '').trim();
final scopeId = state?._selectedChildScopeId; final scopeId = state?._selectedChildScopeId;
final featured = state?._continueVideo ?? videoList.first;
final hasWatchedAny = (state?._watchedVideoCount ?? 0) > 0;
return Align( return Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
@@ -761,23 +770,16 @@ class _InicioTab extends StatelessWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
FadeSlideIn( FadeSlideIn(
delay: const Duration(milliseconds: 110), delay: const Duration(milliseconds: 110),
child: const _HomeSectionLabel('Continuar onde parou'), child: const _HomeSectionLabel('Vídeos educativos'),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
FadeSlideIn( FadeSlideIn(
delay: const Duration(milliseconds: 130), delay: const Duration(milliseconds: 130),
child: _ContinueWatchingRow( child: TapBounce(
video: featured, scale: 0.97,
hasWatchedAny: hasWatchedAny, child: _VideoLibraryCard(
watchedCount: state?._watchedVideoCount ?? 0,
onTap: () async { onTap: () async {
await showVideoPlayerDialog(
context,
featured,
scopeId: scopeId,
);
await state?.refreshStats();
},
onViewAll: () async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => VideoScreen(scopeId: scopeId), builder: (_) => VideoScreen(scopeId: scopeId),
@@ -787,6 +789,7 @@ class _InicioTab extends StatelessWidget {
}, },
), ),
), ),
),
const SizedBox(height: 16), 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<double> _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 /// Pequeno rótulo maiúsculo discreto usado para separar as secções da Home
/// ("Para {nome}", "Novo episódio", "Continuar a aprender"). /// ("Para {nome}", "Novo episódio", "Continuar a aprender").
class _HomeSectionLabel extends StatelessWidget { class _HomeSectionLabel extends StatelessWidget {
@@ -1140,24 +1185,59 @@ class _HeroQuizCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
elevation: 12, elevation: 18,
shadowColor: Colors.black.withValues(alpha: 0.22), shadowColor: const Color(0xFFFF55A7).withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(28),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
color: Colors.transparent, color: Colors.transparent,
child: Ink( child: Ink(
decoration: const BoxDecoration(gradient: kPinkHeroGradient), decoration: const BoxDecoration(gradient: kPinkHeroGradient),
child: Stack( child: Stack(
clipBehavior: Clip.none,
children: [ children: [
Positioned( Positioned(
right: -30, right: -34,
bottom: -30, bottom: -34,
child: IgnorePointer( child: IgnorePointer(
child: Opacity( child: Opacity(
opacity: 0.14, opacity: 0.14,
child: Container( child: Container(
width: 140, width: 160,
height: 140, 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( decoration: const BoxDecoration(
color: Colors.white, color: Colors.white,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -1167,7 +1247,174 @@ class _HeroQuizCard extends StatelessWidget {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
shape: BoxShape.circle,
),
),
),
],
),
const SizedBox(height: 14),
const Text(
'Avaliação de saúde oral',
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 22,
height: 1.1,
color: Colors.white,
),
),
const SizedBox(height: 5),
Text(
'23 perguntas rápidas · menos de 3 minutos',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.92),
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
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'),
),
),
),
],
),
),
],
),
),
);
}
}
/// 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 int watchedCount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
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,
shape: BoxShape.circle,
),
),
),
),
),
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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1183,10 +1430,14 @@ class _HeroQuizCard extends StatelessWidget {
child: const Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.bolt_rounded, color: Colors.white, size: 14), Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 14,
),
SizedBox(width: 4), SizedBox(width: 4),
Text( Text(
'Avaliação', 'Biblioteca de vídeos',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
@@ -1196,41 +1447,44 @@ class _HeroQuizCard extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
const Text( const Text(
'Avaliação de saúde oral', 'Vídeos educativos',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
fontSize: 19, fontSize: 22,
height: 1.1,
color: Colors.white, color: Colors.white,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 5),
Text( Text(
'Leva menos de 3 minutos a completar', 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( style: TextStyle(
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.92),
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 13, fontSize: 13,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 18),
SizedBox( SizedBox(
height: 48, height: 48,
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: const Color(0xFFFF55A7), foregroundColor: const Color(0xFF2F9E94),
shape: const StadiumBorder(), shape: const StadiumBorder(),
textStyle: const TextStyle( textStyle: const TextStyle(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
fontSize: 15, fontSize: 15,
), ),
), ),
onPressed: onStartQuiz, onPressed: onTap,
icon: const Icon(Icons.play_arrow_rounded), icon: const Icon(Icons.video_library_rounded),
label: const Text('Iniciar Quiz'), label: const Text('Ver vídeos'),
), ),
), ),
], ],
@@ -1239,122 +1493,6 @@ 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,
});
final VideoData video;
final bool hasWatchedAny;
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,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Continuar onde parou',
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFFFF55A7),
),
),
const SizedBox(height: 2),
Text(
'${video.title} · ${hasWatchedAny ? "continue de onde parou" : "comece a assistir"}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
color: Color(0xFF2F9E94),
size: 24,
),
],
),
),
),
),
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),
),
),
),
),
),
],
), ),
); );
} }

View File

@@ -40,8 +40,16 @@ class MyApp extends StatelessWidget {
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2F9E94)), colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2F9E94)),
scaffoldBackgroundColor: const Color(0xFFFFE2EF), scaffoldBackgroundColor: const Color(0xFFFAFAF7),
useMaterial3: true, 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 [ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,

View File

@@ -148,15 +148,7 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned.fill( Positioned.fill(
child: Container( child: Container(color: const Color(0xFFFAFAF7)),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
),
),
),
), ),
Positioned( Positioned(
left: -size.width * 0.40, left: -size.width * 0.40,

View File

@@ -88,13 +88,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
return Scaffold( return Scaffold(
body: Container( body: Container(
decoration: const BoxDecoration( color: const Color(0xFFFAFAF7),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
),
),
child: SafeArea( child: SafeArea(
child: Center( child: Center(
child: ConstrainedBox( child: ConstrainedBox(

View File

@@ -10,38 +10,33 @@ import '../widgets/tap_bounce.dart';
class CuriosidadeScreen extends StatelessWidget { class CuriosidadeScreen extends StatelessWidget {
const CuriosidadeScreen({super.key}); const CuriosidadeScreen({super.key});
static const Color _teal = Color(0xFF2F9E94);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: PreferredSize(
backgroundColor: _teal, preferredSize: const Size.fromHeight(kToolbarHeight),
foregroundColor: Colors.white, child: Container(
elevation: 0,
flexibleSpace: Container(
decoration: const BoxDecoration(gradient: kAppBarGradient), decoration: const BoxDecoration(gradient: kAppBarGradient),
), child: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: const Text( title: const Text(
'Curiosidades', 'Curiosidades',
style: TextStyle(fontWeight: FontWeight.w900), style: TextStyle(fontWeight: FontWeight.w900),
), ),
), ),
),
),
body: Stack( body: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned.fill( Positioned.fill(
child: Container( child: Container(color: const Color(0xFFFAFAF7)),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
),
),
),
), ),
Positioned( Positioned(
left: -size.width * 0.40, left: -size.width * 0.40,

View File

@@ -76,7 +76,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with TickerProvid
child: Container( child: Container(
width: size.width, width: size.width,
height: size.height, height: size.height,
color: const Color(0xFFFFC9DF), color: const Color(0xFFFAFAF7),
child: SafeArea( child: SafeArea(
child: Center( child: Center(
child: Column( child: Column(
@@ -90,7 +90,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with TickerProvid
style: TextStyle( style: TextStyle(
fontSize: 64, fontSize: 64,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: Colors.white, color: const Color(0xFFFF9AD0),
height: 1.0, height: 1.0,
), ),
), ),

View File

@@ -5,32 +5,30 @@ import '../widgets/app_gradients.dart';
class TermsScreen extends StatelessWidget { class TermsScreen extends StatelessWidget {
const TermsScreen({super.key}); const TermsScreen({super.key});
static const Color _teal = Color(0xFF2F9E94);
static const Color _accentPink = Color(0xFFFF55A7); static const Color _accentPink = Color(0xFFFF55A7);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: PreferredSize(
backgroundColor: _teal, preferredSize: const Size.fromHeight(kToolbarHeight),
foregroundColor: Colors.white, child: Container(
elevation: 0,
flexibleSpace: Container(
decoration: const BoxDecoration(gradient: kAppBarGradient), decoration: const BoxDecoration(gradient: kAppBarGradient),
), child: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: const Text( title: const Text(
'Termos de Serviço', 'Termos de Serviço',
style: TextStyle(fontWeight: FontWeight.w900), style: TextStyle(fontWeight: FontWeight.w900),
), ),
), ),
),
),
body: Container( body: Container(
decoration: const BoxDecoration( color: const Color(0xFFFAFAF7),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
),
),
child: SafeArea( child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),

View File

@@ -38,79 +38,79 @@ final List<VideoData> videoList = [
VideoData( VideoData(
id: 1, id: 1,
title: 'Episódio 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', youtubeId: 'PJ58CZv4ECw',
), ),
VideoData( VideoData(
id: 2, id: 2,
title: 'Episódio 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', youtubeId: 'y4_kWmZtAtg',
), ),
VideoData( VideoData(
id: 3, id: 3,
title: 'Episódio 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', youtubeId: 'nD75Y5PuKTo',
), ),
VideoData( VideoData(
id: 4, id: 4,
title: 'Episódio 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', youtubeId: 'yvFllWYeuLw',
), ),
VideoData( VideoData(
id: 5, id: 5,
title: 'Episódio 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', youtubeId: 'DnhUa-T8_Ps',
), ),
VideoData( VideoData(
id: 6, id: 6,
title: 'Episódio 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', youtubeId: 'zKt_iwkrjvo',
), ),
VideoData( VideoData(
id: 7, id: 7,
title: 'Episódio 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', youtubeId: 'NpmQ2brap5A',
), ),
VideoData( VideoData(
id: 8, id: 8,
title: 'Episódio 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', youtubeId: 'Wj3KYw9pBi0',
), ),
VideoData( VideoData(
id: 9, id: 9,
title: 'Episódio 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', youtubeId: 'bOm9t61cT_U',
), ),
VideoData( VideoData(
id: 10, id: 10,
title: 'Episódio 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', youtubeId: 'fAitMizbcms',
), ),
VideoData( VideoData(
id: 11, id: 11,
title: 'Episódio 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', youtubeId: '6sYoBUjks_I',
), ),
VideoData( VideoData(
id: 12, id: 12,
title: 'Episódio 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', youtubeId: 'eznKrErQbHo',
), ),
VideoData( VideoData(
id: 13, id: 13,
title: 'Episódio 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', youtubeId: 'VO9CNqHRdeM',
), ),
]; ];
@@ -236,32 +236,32 @@ class _VideoScreenState extends State<VideoScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: PreferredSize(
backgroundColor: VideoScreen._teal, preferredSize: const Size.fromHeight(kToolbarHeight),
foregroundColor: Colors.white, // O gradiente é pintado por este Container de tamanho fixo, em vez
surfaceTintColor: VideoScreen._teal, // de confiar no `flexibleSpace` do AppBar — em alguns aparelhos o
elevation: 0, // `flexibleSpace` de um AppBar comum não recebia o tamanho esperado
flexibleSpace: Container( // e a gradiente aparecia como cor sólida.
child: Container(
decoration: const BoxDecoration(gradient: kAppBarGradient), decoration: const BoxDecoration(gradient: kAppBarGradient),
), child: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: const Text( title: const Text(
'Videos Educativos', 'Videos Educativos',
style: TextStyle(fontWeight: FontWeight.w900), style: TextStyle(fontWeight: FontWeight.w900),
), ),
), ),
),
),
body: Stack( body: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned.fill( Positioned.fill(
child: Container( child: Container(color: const Color(0xFFFAFAF7)),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)],
),
),
),
), ),
Positioned( Positioned(
left: -size.width * 0.40, left: -size.width * 0.40,
@@ -548,27 +548,84 @@ class _VideoButton extends StatelessWidget {
return TapBounce( return TapBounce(
scale: 0.95, scale: 0.95,
child: Material( child: Material(
elevation: 8, elevation: 10,
shadowColor: Colors.black.withValues(alpha: 0.12), shadowColor: Colors.black.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(18),
color: Colors.white, color: Colors.white,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(18),
onTap: () => _showVideoPlayer(context, video), onTap: () => _showVideoPlayer(context, video),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(10),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( AspectRatio(
height: 80, aspectRatio: 16 / 9,
decoration: BoxDecoration( child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: const Color(0xFFFFE6F1), color: const Color(0xFFFFE6F1),
borderRadius: BorderRadius.circular(12), child: VideoThumbnail(video: video, borderRadius: 0),
), ),
child: VideoThumbnail(video: video), 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],
), ),
const SizedBox(height: 10), ),
),
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,
),
),
),
),
],
),
),
),
const SizedBox(height: 8),
Text( Text(
video.title, video.title,
style: const TextStyle( style: const TextStyle(

View File

@@ -27,10 +27,4 @@ class WatchedVideosPrefs {
final ids = prefs.getStringList(_key(scopeId)) ?? const []; final ids = prefs.getStringList(_key(scopeId)) ?? const [];
return ids.length; return ids.length;
} }
static Future<Set<int>> 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();
}
} }

View File

@@ -1,18 +1,25 @@
import 'package:flutter/material.dart'; 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( 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, begin: Alignment.centerLeft,
end: Alignment.centerRight, 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. /// Gradiente rosa vivo do card do quiz na Home.