MVP I
This commit is contained in:
@@ -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<List<DateTime>> _getEntries(String scopeId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getStringList(_key(_kDatesKey, scopeId)) ?? const [];
|
||||
return raw.map(DateTime.tryParse).whereType<DateTime>().toList();
|
||||
final key = _key(_kDatesKey, scopeId);
|
||||
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 {
|
||||
@@ -51,11 +77,15 @@ class BrushingPrefs {
|
||||
/// o utilizador).
|
||||
static Future<void> 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) ?? <String>[];
|
||||
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<int> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,15 +166,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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,
|
||||
|
||||
@@ -60,7 +60,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
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<LoggedHomeScreen>
|
||||
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<LoggedHomeScreen>
|
||||
_weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
|
||||
_brushingDailyLimitReached = false;
|
||||
_watchedVideoCount = null;
|
||||
_continueVideo = videoList.first;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -114,11 +112,6 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
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<LoggedHomeScreen>
|
||||
_weeklyGoal = goal;
|
||||
_brushingDailyLimitReached = dailyLimitReached;
|
||||
_watchedVideoCount = watchedCount;
|
||||
_continueVideo = continueVideo;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,6 +277,83 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
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,79 +362,67 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
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),
|
||||
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)),
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding: EdgeInsets.zero,
|
||||
background: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(40),
|
||||
),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(gradient: kAppBarGradient),
|
||||
child: _index != 0
|
||||
? null
|
||||
: Stack(
|
||||
child: 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,
|
||||
top: kToolbarHeight + 34,
|
||||
child: Center(
|
||||
child: Text(
|
||||
(_selectedChildName ?? '').trim(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.92,
|
||||
),
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if ((_selectedChildName ?? '')
|
||||
.trim()
|
||||
.isNotEmpty)
|
||||
else if ((_selectedChildName ?? '').trim().isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: toolbarHeight,
|
||||
top: kToolbarHeight,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
@@ -373,19 +430,18 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.92,
|
||||
),
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
//posição da app bar relativamente ao nome
|
||||
if (hasScore)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 4,
|
||||
top: kToolbarHeight + 68,
|
||||
child: Center(
|
||||
child: _RiskArcGauge(percent: percent),
|
||||
),
|
||||
@@ -394,14 +450,8 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Align(
|
||||
alignment: _index != 0
|
||||
? Alignment.center
|
||||
: (_selectedChildName ?? '').trim().isEmpty
|
||||
? Alignment.centerLeft
|
||||
: Alignment.topLeft,
|
||||
child: _index == 0
|
||||
? Padding(
|
||||
),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 10),
|
||||
child: TapBounce(
|
||||
scale: 0.96,
|
||||
@@ -436,8 +486,7 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
@@ -476,51 +560,15 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
centerTitle: _index == 0 ? false : true,
|
||||
backgroundColor: _teal,
|
||||
foregroundColor: Colors.white,
|
||||
surfaceTintColor: _teal,
|
||||
elevation: 0,
|
||||
shape: appBarShape,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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
|
||||
body: _decoratedBody(
|
||||
size,
|
||||
_index == 1
|
||||
? _PerfilTab(
|
||||
selectedChildIndex: _selectedChildIndex,
|
||||
onChildSelected: (index, name, scopeId) {
|
||||
@@ -534,46 +582,9 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
||||
},
|
||||
)
|
||||
: const SettingsBody(),
|
||||
10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
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,23 +770,16 @@ 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,
|
||||
child: TapBounce(
|
||||
scale: 0.97,
|
||||
child: _VideoLibraryCard(
|
||||
watchedCount: state?._watchedVideoCount ?? 0,
|
||||
onTap: () async {
|
||||
await showVideoPlayerDialog(
|
||||
context,
|
||||
featured,
|
||||
scopeId: scopeId,
|
||||
);
|
||||
await state?.refreshStats();
|
||||
},
|
||||
onViewAll: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => VideoScreen(scopeId: scopeId),
|
||||
@@ -787,6 +789,7 @@ class _InicioTab extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
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
|
||||
/// ("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,7 +1247,174 @@ 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: [
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1183,10 +1430,14 @@ class _HeroQuizCard extends StatelessWidget {
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.bolt_rounded, color: Colors.white, size: 14),
|
||||
Icon(
|
||||
Icons.play_circle_fill_rounded,
|
||||
color: Colors.white,
|
||||
size: 14,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'Avaliação',
|
||||
'Biblioteca de vídeos',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
@@ -1196,41 +1447,44 @@ class _HeroQuizCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'Avaliação de saúde oral',
|
||||
'Vídeos educativos',
|
||||
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',
|
||||
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.9),
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 18),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFFFF55A7),
|
||||
foregroundColor: const Color(0xFF2F9E94),
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
onPressed: onStartQuiz,
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: const Text('Iniciar Quiz'),
|
||||
onPressed: onTap,
|
||||
icon: const Icon(Icons.video_library_rounded),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -148,15 +148,7 @@ class _QuizQuestionScreenState extends State<QuizQuestionScreen> {
|
||||
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,
|
||||
|
||||
@@ -88,13 +88,7 @@ class _QuizResultScreenState extends State<QuizResultScreen> {
|
||||
|
||||
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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -76,7 +76,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> 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<HelloSplashScreen> with TickerProvid
|
||||
style: TextStyle(
|
||||
fontSize: 64,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
color: const Color(0xFFFF9AD0),
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -38,79 +38,79 @@ final List<VideoData> 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<VideoScreen> {
|
||||
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),
|
||||
),
|
||||
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(
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(
|
||||
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(
|
||||
video.title,
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -27,10 +27,4 @@ class WatchedVideosPrefs {
|
||||
final ids = prefs.getStringList(_key(scopeId)) ?? const [];
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user