diff --git a/assets/videos/episodio_08.mp4 b/assets/videos/episodio_08.mp4 deleted file mode 100644 index a426bf4..0000000 Binary files a/assets/videos/episodio_08.mp4 and /dev/null differ diff --git a/assets/videos/episodio_09.mp4 b/assets/videos/episodio_09.mp4 deleted file mode 100644 index 3c3943f..0000000 Binary files a/assets/videos/episodio_09.mp4 and /dev/null differ diff --git a/assets/videos/episodio_10.mp4 b/assets/videos/episodio_10.mp4 deleted file mode 100644 index b41b73e..0000000 Binary files a/assets/videos/episodio_10.mp4 and /dev/null differ diff --git a/lib/auth_gate.dart b/lib/auth_gate.dart index 5bc609e..68d14a5 100644 --- a/lib/auth_gate.dart +++ b/lib/auth_gate.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -7,9 +9,53 @@ import 'logged_home.dart'; final ValueNotifier forceHomeScreen = ValueNotifier(false); -class AuthGate extends StatelessWidget { +class AuthGate extends StatefulWidget { const AuthGate({super.key}); + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + String? _validatedUserId; + String? _validatingUserId; + + /// Confirma que a sessão ativa ainda corresponde a um perfil existente na + /// base de dados. Sessões do Supabase Auth sobrevivem mesmo que os dados + /// da conta tenham sido apagados (ex.: "Apagar dados da conta" ou remoção + /// manual na base de dados) — sem esta verificação, essa conta "fantasma" + /// continuaria a conseguir entrar. + Future _validateSession(String userId) async { + if (_validatingUserId == userId) return; + _validatingUserId = userId; + try { + final profile = await supabase + .from('profiles') + .select('id') + .eq('id', userId) + .maybeSingle(); + + if (!mounted) return; + + if (profile == null) { + unawaited( + supabase + .from('children') + .delete() + .eq('owner_id', userId) + .catchError((_) => >[]), + ); + await supabase.auth.signOut(); + } else { + setState(() => _validatedUserId = userId); + } + } catch (_) { + // Falha de rede/consulta: não força logout, tenta novamente depois. + } finally { + _validatingUserId = null; + } + } + @override Widget build(BuildContext context) { return ValueListenableBuilder( @@ -21,10 +67,16 @@ class AuthGate extends StatelessWidget { builder: (context, snapshot) { final user = snapshot.data?.session?.user; + if (user != null && user.id != _validatedUserId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _validateSession(user.id); + }); + } + final Widget child; if (snapshot.connectionState == ConnectionState.waiting) { child = const SizedBox.shrink(); - } else if (forcedHome || user == null) { + } else if (forcedHome || user == null || user.id != _validatedUserId) { child = const HomeScreen(key: ValueKey('home_screen')); } else { child = const LoggedHomeScreen(key: ValueKey('logged_home_screen')); diff --git a/lib/home_screen.dart b/lib/home_screen.dart index a40a0f8..d27c982 100644 --- a/lib/home_screen.dart +++ b/lib/home_screen.dart @@ -6,6 +6,7 @@ import 'package:lottie/lottie.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'main.dart' show supabase; +import 'widgets/app_gradients.dart'; import 'widgets/entrance.dart'; import 'widgets/tap_bounce.dart'; @@ -15,6 +16,12 @@ const Color _pink = Color(0xFFFF55A7); /// Nomes só podem ter letras (incluindo acentuadas) e espaços — sem números. final RegExp _namePattern = RegExp(r"^[a-zA-ZÀ-ÖØ-öø-ÿ' -]+$"); +/// A conta existe no Supabase Auth mas os dados (perfil) já não existem na +/// base de dados — tratada como conta inexistente para efeitos de login. +class _AccountNotFoundException implements Exception { + const _AccountNotFoundException(); +} + class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -50,11 +57,10 @@ class _HomeScreenState extends State { required String name, required String email, }) async { - await supabase.from('profiles').upsert({ - 'id': uid, - 'name': name, - 'email': email, - }).timeout(const Duration(seconds: 20)); + await supabase + .from('profiles') + .upsert({'id': uid, 'name': name, 'email': email}) + .timeout(const Duration(seconds: 20)); } Future _submit() async { @@ -66,10 +72,34 @@ class _HomeScreenState extends State { final password = _passwordController.text; if (_isLogin) { - await supabase.auth.signInWithPassword( + final result = await supabase.auth.signInWithPassword( email: email, password: password, ); + + final user = result.user; + if (user != null) { + final profile = await supabase + .from('profiles') + .select('id') + .eq('id', user.id) + .maybeSingle(); + + if (profile == null) { + // A conta existe no Auth mas os dados foram apagados (ex.: via + // "Apagar dados da conta" ou diretamente na base de dados). + // Trata como inexistente: limpa qualquer resquício e bloqueia. + unawaited( + supabase + .from('children') + .delete() + .eq('owner_id', user.id) + .catchError((_) => >[]), + ); + await supabase.auth.signOut(); + throw const _AccountNotFoundException(); + } + } } else { final name = _nameController.text.trim(); final response = await supabase.auth @@ -81,14 +111,19 @@ class _HomeScreenState extends State { throw StateError('Usuário não encontrado após criar conta.'); } - unawaited( - _persistRegistrationData( - uid: user.id, - name: name, - email: email, - ).catchError((_) {}), - ); + // Precisa de terminar antes de navegar: o AuthGate só mostra a app + // depois de confirmar que existe um perfil na base de dados. + await _persistRegistrationData(uid: user.id, name: name, email: email); } + } on _AccountNotFoundException { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Esta conta não existe mais. Verifique o email ou crie uma nova conta.', + ), + ), + ); } on AuthException catch (e) { if (!mounted) return; ScaffoldMessenger.of( @@ -412,56 +447,59 @@ class _AuthForm extends StatelessWidget { ), const SizedBox(height: 20), TapBounce( - child: SizedBox( - height: 50, - child: FilledButton( - style: - FilledButton.styleFrom( - backgroundColor: _teal, - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w800, - fontSize: 15, - ), - ).copyWith( - animationDuration: const Duration(milliseconds: 180), - splashFactory: InkSparkle.splashFactory, - overlayColor: WidgetStateProperty.resolveWith(( - states, - ) { - if (states.contains(WidgetState.pressed)) { - return Colors.white.withValues(alpha: 0.14); - } - if (states.contains(WidgetState.hovered) || - states.contains(WidgetState.focused)) { - return Colors.white.withValues(alpha: 0.08); - } - return null; - }), - ), - onPressed: loading ? null : onSubmit, - child: loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: Colors.white, - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text(isLogin ? 'Entrar' : 'Criar Conta'), - const SizedBox(width: 8), - const Icon( - Icons.arrow_forward_rounded, - size: 18, + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: const BoxDecoration(gradient: kGreenButtonGradient), + child: SizedBox( + height: 50, + child: FilledButton( + style: + FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 15, ), - ], - ), + ).copyWith( + animationDuration: const Duration(milliseconds: 180), + splashFactory: InkSparkle.splashFactory, + overlayColor: WidgetStateProperty.resolveWith( + (states) { + if (states.contains(WidgetState.pressed)) { + return Colors.white.withValues(alpha: 0.14); + } + if (states.contains(WidgetState.hovered) || + states.contains(WidgetState.focused)) { + return Colors.white.withValues(alpha: 0.08); + } + return null; + }, + ), + ), + onPressed: loading ? null : onSubmit, + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + color: Colors.white, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text(isLogin ? 'Entrar' : 'Criar Conta'), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward_rounded, size: 18), + ], + ), + ), + ), ), ), ), diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 4cc6e20..549f51c 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -34,9 +34,9 @@ class _LoggedHomeScreenState extends State static const Color _teal = Color(0xFF2F9E94); static const String _kPendingQuizScopeKey = 'pending_quiz_scope_v1'; - static const double _collapsedAppBarHeight = 104; - static const double _expandedAppBarHeight = 180; - static const double _nameOnlyAppBarHeight = 130; + static const double _collapsedAppBarHeight = 80; + static const double _expandedAppBarHeight = 190; + static const double _nameOnlyAppBarHeight = 160; int _index = 0; @@ -233,7 +233,7 @@ class _LoggedHomeScreenState extends State : 'Configurações'; final ShapeBorder appBarShape = _index == 0 ? const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)), + borderRadius: BorderRadius.vertical(bottom: Radius.circular(40)), ) : const RoundedRectangleBorder(borderRadius: BorderRadius.zero); @@ -255,66 +255,72 @@ class _LoggedHomeScreenState extends State clipBehavior: Clip.antiAlias, flexibleSpace: ClipRRect( borderRadius: BorderRadius.vertical( - bottom: Radius.circular(_index == 0 ? 24 : 0), + bottom: Radius.circular(_index == 0 ? 40 : 0), ), child: Container( - decoration: const BoxDecoration(gradient: kAppBarGradient), - child: _index != 0 - ? null - : Stack( - fit: StackFit.expand, - children: [ - Opacity( - opacity: 0.22, - child: Transform.scale(scale: 1.25), + decoration: const BoxDecoration(gradient: kAppBarGradient), + child: _index != 0 + ? null + : Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: 0.22, + child: Transform.scale(scale: 1.25), + ), + if (hasScore) + Positioned( + left: 0, + right: 0, + top: toolbarHeight + 18, + child: Center( + child: Text( + (_selectedChildName ?? '').trim(), + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w800, + color: Colors.white.withValues( + alpha: 0.92, + ), + fontSize: 14, + ), + ), + ), + ) + else if ((_selectedChildName ?? '') + .trim() + .isNotEmpty) + Positioned( + left: 0, + right: 0, + top: toolbarHeight, + bottom: 0, + child: Center( + child: Text( + _selectedChildName!.trim(), + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.w800, + color: Colors.white.withValues( + alpha: 0.92, + ), + fontSize: 14, + ), + ), + ), + ), + if (hasScore) + Positioned( + left: 0, + right: 0, + bottom: 6, + child: Center( + child: _RiskArcGauge(percent: percent), + ), + ), + ], ), - if (hasScore) - Positioned( - left: 0, - right: 0, - top: toolbarHeight + 26, - child: Center( - child: Text( - (_selectedChildName ?? '').trim(), - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: FontWeight.w800, - color: Colors.white.withValues(alpha: 0.92), - fontSize: 14, - ), - ), - ), - ) - else if ((_selectedChildName ?? '').trim().isNotEmpty) - Positioned( - left: 0, - right: 0, - top: toolbarHeight, - bottom: 0, - child: Center( - child: Text( - _selectedChildName!.trim(), - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: FontWeight.w800, - color: Colors.white.withValues(alpha: 0.92), - fontSize: 14, - ), - ), - ), - ), - if (hasScore) - Positioned( - left: 0, - right: 0, - bottom: 12, - child: Center( - child: _RiskArcGauge(percent: percent), - ), - ), - ], - ), - ), + ), ), title: Align( alignment: _index == 0 ? Alignment.topLeft : Alignment.center, @@ -338,8 +344,9 @@ class _LoggedHomeScreenState extends State children: [ CircleAvatar( radius: 20, - backgroundColor: Colors.white - .withValues(alpha: 0.25), + backgroundColor: Colors.white.withValues( + alpha: 0.25, + ), backgroundImage: (_cachedPhotoUrl ?? '').isNotEmpty ? NetworkImage(_cachedPhotoUrl!) @@ -1132,31 +1139,53 @@ class _PerfilTabState extends State<_PerfilTab> { ), ), const SizedBox(height: 14), - SizedBox( - height: 46, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle(fontWeight: FontWeight.w900), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: kGreenButtonGradient, + ), + child: SizedBox( + height: 46, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + onPressed: () => + Navigator.of(ctx).pop(ImageSource.camera), + child: const Text('Câmera'), + ), ), - onPressed: () => Navigator.of(ctx).pop(ImageSource.camera), - child: const Text('Câmera'), ), ), const SizedBox(height: 10), - SizedBox( - height: 46, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle(fontWeight: FontWeight.w900), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: kGreenButtonGradient, + ), + child: SizedBox( + height: 46, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + onPressed: () => + Navigator.of(ctx).pop(ImageSource.gallery), + child: const Text('Galeria'), + ), ), - onPressed: () => Navigator.of(ctx).pop(ImageSource.gallery), - child: const Text('Galeria'), ), ), const SizedBox(height: 8), @@ -1193,22 +1222,17 @@ class _PerfilTabState extends State<_PerfilTab> { final path = '$uid/profile.jpg'; await supabase.storage .from('photos') - .upload( - path, - file, - fileOptions: const FileOptions(upsert: true), - ); + .upload(path, file, fileOptions: const FileOptions(upsert: true)); final publicUrl = supabase.storage.from('photos').getPublicUrl(path); final url = '$publicUrl?t=${DateTime.now().millisecondsSinceEpoch}'; - await supabase.from('profiles').upsert({ - 'id': uid, - 'photo_url': url, - }); + await supabase.from('profiles').upsert({'id': uid, 'photo_url': url}); await _loadPerfilData(); if (context.mounted) { - context.findAncestorStateOfType<_LoggedHomeScreenState>()?.updateCachedPhoto(url); + context + .findAncestorStateOfType<_LoggedHomeScreenState>() + ?.updateCachedPhoto(url); } } catch (e) { if (!context.mounted) return; @@ -1239,16 +1263,21 @@ class _PerfilTabState extends State<_PerfilTab> { if (!context.mounted) return; try { - await supabase.from('children').delete().eq('id', childId); + final deleted = await supabase + .from('children') + .delete() + .eq('id', childId) + .select('id'); + + if (deleted.isEmpty) { + throw StateError('Sem permissão para remover esta criança.'); + } + widget.onChildSelected(0, null, null); await _loadPerfilData(); - messenger.showSnackBar( - const SnackBar(content: Text('Criança removida')), - ); + messenger.showSnackBar(const SnackBar(content: Text('Criança removida'))); } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text('Erro ao remover: $e')), - ); + messenger.showSnackBar(SnackBar(content: Text('Erro ao remover: $e'))); } } @@ -1270,10 +1299,7 @@ class _PerfilTabState extends State<_PerfilTab> { if (result == null) return; - final childMap = { - ...result, - 'owner_id': uid, - }; + final childMap = {...result, 'owner_id': uid}; setState(() => _addingChild = true); try { @@ -1365,419 +1391,410 @@ class _PerfilTabState extends State<_PerfilTab> { return Align( alignment: Alignment.topCenter, child: Padding( - padding: const EdgeInsets.only(top: 10), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FadeSlideIn( - child: Material( - elevation: 10, - color: Colors.white, - borderRadius: BorderRadius.circular(20), - shadowColor: Colors.black.withValues(alpha: 0.16), - child: Padding( - padding: const EdgeInsets.all(18), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - TapBounce( - scale: 0.92, - child: InkWell( - borderRadius: BorderRadius.circular(40), - onTap: _updatingPhoto - ? null - : () => _pickAndUploadProfilePhoto( - context, - uid, - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: 76, - height: 76, - decoration: BoxDecoration( - color: const Color(0xFFFFE6F1), - shape: BoxShape.circle, - border: Border.all( - color: const Color( - 0xFF2F9E94, - ).withValues(alpha: 0.35), - width: 2, - ), - ), - clipBehavior: Clip.antiAlias, - child: Stack( - fit: StackFit.expand, - children: [ - if (photoUrl.isNotEmpty) - Image.network( - photoUrl, - fit: BoxFit.cover, - ) - else - const Icon( - Icons.person_rounded, - size: 42, - color: Color(0xFF2F9E94), - ), - if (_updatingPhoto) - Container( - color: Colors.black.withValues( - alpha: 0.25, - ), - child: const Center( - child: SizedBox( - width: 22, - height: 22, - child: - CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ), - ), - ], - ), + padding: const EdgeInsets.only(top: 10), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FadeSlideIn( + child: Material( + elevation: 10, + color: Colors.white, + borderRadius: BorderRadius.circular(20), + shadowColor: Colors.black.withValues(alpha: 0.16), + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TapBounce( + scale: 0.92, + child: InkWell( + borderRadius: BorderRadius.circular(40), + onTap: _updatingPhoto + ? null + : () => _pickAndUploadProfilePhoto( + context, + uid, + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 76, + height: 76, + decoration: BoxDecoration( + color: const Color(0xFFFFE6F1), + shape: BoxShape.circle, + border: Border.all( + color: const Color( + 0xFF2F9E94, + ).withValues(alpha: 0.35), + width: 2, ), - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 26, - height: 26, - decoration: const BoxDecoration( - color: Color(0xFFFF55A7), - shape: BoxShape.circle, + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + if (photoUrl.isNotEmpty) + Image.network( + photoUrl, + fit: BoxFit.cover, + ) + else + const Icon( + Icons.person_rounded, + size: 42, + color: Color(0xFF2F9E94), ), - child: const Icon( - Icons.camera_alt_rounded, - size: 14, - color: Colors.white, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - profileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w900, - color: Color(0xFFFF55A7), - ), - ), - if (profileEmail.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - profileEmail, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, + if (_updatingPhoto) + Container( color: Colors.black.withValues( - alpha: 0.55, + alpha: 0.25, + ), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ), ), ), - ), ], - ], + ), ), - ), - ], + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 26, + height: 26, + decoration: const BoxDecoration( + color: Color(0xFFFF55A7), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.camera_alt_rounded, + size: 14, + color: Colors.white, + ), + ), + ), + ], + ), ), ), - ), - ), - const SizedBox(height: 22), - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 10), - child: Row( - children: [ - const Text( - 'Meus filhos', - style: TextStyle( - color: Color(0xFF2F9E94), - fontWeight: FontWeight.w900, - fontSize: 15, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: const Color( - 0xFF2F9E94, - ).withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - '${children.length}', - style: const TextStyle( - color: Color(0xFF2F9E94), - fontWeight: FontWeight.w900, - fontSize: 12, - ), - ), - ), - ], - ), - ), - if (children.isEmpty) - Container( - padding: const EdgeInsets.all(18), - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Colors.black.withValues(alpha: 0.08), - ), - ), - child: Row( + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: const Color(0xFFFFE6F1), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.child_care_rounded, + Text( + profileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, color: Color(0xFFFF55A7), ), ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Nenhuma criança adicionada ainda.', + if (profileEmail.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + profileEmail, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( - color: Colors.black.withValues( - alpha: 0.62, - ), + fontSize: 13, fontWeight: FontWeight.w600, + color: Colors.black.withValues( + alpha: 0.55, + ), ), ), - ), + ], ], ), - ) - else - ...children.asMap().entries.map((entry) { - final i = entry.key; - final c = entry.value; - final childId = (c['id'] ?? '').toString().trim(); - final childName = (c['name'] ?? '') - .toString() - .trim(); - final childAge = c['age']; - final childGender = (c['gender'] ?? '') - .toString() - .trim(); - final scopeId = '${uid}_$childId'; - - final title = childName.isNotEmpty - ? childName - : 'Criança ${i + 1}'; - final subtitle = [ - if (childAge != null) 'Idade: $childAge', - if (childGender.isNotEmpty) - 'Gênero: $childGender', - ].join(' • '); - final bool selected = i == selectedIndex; - - return FadeSlideIn( - delay: Duration( - milliseconds: 60 * i.clamp(0, 6), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 12), - child: TapBounce( - scale: 0.97, - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => widget.onChildSelected( - i, - childName.isEmpty ? null : childName, - scopeId, - ), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: selected - ? const Color(0xFFFFE6F1) - : Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: selected - ? const Color( - 0xFF2F9E94, - ).withValues(alpha: 0.45) - : Colors.black.withValues( - alpha: 0.10, - ), - width: selected ? 1.6 : 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues( - alpha: 0.06, - ), - blurRadius: 14, - offset: const Offset(0, 8), - ), - ], - ), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontWeight: FontWeight.w900, - ), - ), - if (subtitle.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(subtitle), - ], - ], - ), - ), - const SizedBox(width: 10), - FutureBuilder<(int?, int?)>( - future: _loadScoreForScope(scopeId), - builder: (context, snap) { - final tuple = snap.data; - final s = tuple?.$1; - final m = tuple?.$2; - final text = - (s == null || m == null || m <= 0) - ? '--' - : '${(((s / m) * 100).round()).clamp(0, 100)}%'; - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - decoration: BoxDecoration( - color: const Color( - 0xFF2F9E94, - ).withValues(alpha: 0.10), - borderRadius: - BorderRadius.circular(999), - ), - child: Text( - text, - style: const TextStyle( - fontWeight: FontWeight.w900, - color: Color(0xFF2F9E94), - ), - ), - ); - }, - ), - const SizedBox(width: 6), - IconButton( - onPressed: () => _confirmDeleteChild( - context, - childId: childId, - childName: title, - ), - icon: const Icon( - Icons.delete_outline_rounded, - color: Color(0xFFFF55A7), - ), - tooltip: 'Remover', - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - ), - ), - ); - }), - TapBounce( - child: SizedBox( - height: 48, - child: FilledButton.icon( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w800, - ), - ), - onPressed: _addingChild - ? null - : () => _addAnotherChild(context, uid), - icon: const Icon(Icons.add_rounded), - label: const Text('Adicionar criança'), - ), ), - ), - const SizedBox(height: 22), - TapBounce( - child: SizedBox( - height: 46, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFFF55A7), - side: const BorderSide( - color: Color(0xFFFF55A7), - width: 1.4, - ), - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w800, - ), - ), - onPressed: () async { - await supabase.auth.signOut(); - }, - icon: const Icon(Icons.logout_rounded), - label: const Text('Sair'), - ), - ), - ), - const SizedBox(height: 12), - ], + ], + ), ), ), ), - ), - ); + const SizedBox(height: 22), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 10), + child: Row( + children: [ + const Text( + 'Meus filhos', + style: TextStyle( + color: Color(0xFF2F9E94), + fontWeight: FontWeight.w900, + fontSize: 15, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color( + 0xFF2F9E94, + ).withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${children.length}', + style: const TextStyle( + color: Color(0xFF2F9E94), + fontWeight: FontWeight.w900, + fontSize: 12, + ), + ), + ), + ], + ), + ), + if (children.isEmpty) + Container( + padding: const EdgeInsets.all(18), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.black.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFFFE6F1), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.child_care_rounded, + color: Color(0xFFFF55A7), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Nenhuma criança adicionada ainda.', + style: TextStyle( + color: Colors.black.withValues(alpha: 0.62), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ) + else + ...children.asMap().entries.map((entry) { + final i = entry.key; + final c = entry.value; + final childId = (c['id'] ?? '').toString().trim(); + final childName = (c['name'] ?? '').toString().trim(); + final childAge = c['age']; + final childGender = (c['gender'] ?? '').toString().trim(); + final scopeId = '${uid}_$childId'; + + final title = childName.isNotEmpty + ? childName + : 'Criança ${i + 1}'; + final subtitle = [ + if (childAge != null) 'Idade: $childAge', + if (childGender.isNotEmpty) 'Gênero: $childGender', + ].join(' • '); + final bool selected = i == selectedIndex; + + return FadeSlideIn( + delay: Duration(milliseconds: 60 * i.clamp(0, 6)), + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TapBounce( + scale: 0.97, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => widget.onChildSelected( + i, + childName.isEmpty ? null : childName, + scopeId, + ), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: selected + ? const Color(0xFFFFE6F1) + : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: selected + ? const Color( + 0xFF2F9E94, + ).withValues(alpha: 0.45) + : Colors.black.withValues(alpha: 0.10), + width: selected ? 1.6 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 14, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(subtitle), + ], + ], + ), + ), + const SizedBox(width: 10), + FutureBuilder<(int?, int?)>( + future: _loadScoreForScope(scopeId), + builder: (context, snap) { + final tuple = snap.data; + final s = tuple?.$1; + final m = tuple?.$2; + final text = + (s == null || m == null || m <= 0) + ? '--' + : '${(((s / m) * 100).round()).clamp(0, 100)}%'; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: const Color( + 0xFF2F9E94, + ).withValues(alpha: 0.10), + borderRadius: BorderRadius.circular( + 999, + ), + ), + child: Text( + text, + style: const TextStyle( + fontWeight: FontWeight.w900, + color: Color(0xFF2F9E94), + ), + ), + ); + }, + ), + const SizedBox(width: 6), + IconButton( + onPressed: () => _confirmDeleteChild( + context, + childId: childId, + childName: title, + ), + icon: const Icon( + Icons.delete_outline_rounded, + color: Color(0xFFFF55A7), + ), + tooltip: 'Remover', + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ), + ), + ), + ); + }), + TapBounce( + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: kGreenButtonGradient, + ), + child: SizedBox( + height: 48, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + ), + ), + onPressed: _addingChild + ? null + : () => _addAnotherChild(context, uid), + icon: const Icon(Icons.add_rounded), + label: const Text('Adicionar criança'), + ), + ), + ), + ), + ), + const SizedBox(height: 22), + TapBounce( + child: SizedBox( + height: 46, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFFF55A7), + side: const BorderSide( + color: Color(0xFFFF55A7), + width: 1.4, + ), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontWeight: FontWeight.w800), + ), + onPressed: () async { + await supabase.auth.signOut(); + }, + icon: const Icon(Icons.logout_rounded), + label: const Text('Sair'), + ), + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ), + ); } } - class _AddChildSheet extends StatefulWidget { const _AddChildSheet(); @@ -1869,8 +1886,8 @@ class _AddChildSheetState extends State<_AddChildSheet> { if (raw.isEmpty) return 'Informe a idade'; final age = int.tryParse(raw); if (age == null) return 'Idade inválida'; - if (age < 0 || age > 17) { - return 'Idade deve ser entre 0 e 17 anos'; + if (age < 1 || age > 17) { + return 'Idade deve ser entre 1 e 17 anos'; } return null; }, @@ -1916,19 +1933,27 @@ class _AddChildSheetState extends State<_AddChildSheet> { const SizedBox(width: 10), Expanded( child: TapBounce( - child: SizedBox( - height: 44, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w900, + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: kGreenButtonGradient, + ), + child: SizedBox( + height: 44, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + onPressed: _submit, + child: const Text('Adicionar'), ), ), - onPressed: _submit, - child: const Text('Adicionar'), ), ), ), diff --git a/lib/quiz/quiz_result.dart b/lib/quiz/quiz_result.dart index 58f9a84..f57e557 100644 --- a/lib/quiz/quiz_result.dart +++ b/lib/quiz/quiz_result.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'dart:async'; import '../main.dart' show supabase; +import '../widgets/app_gradients.dart'; import '../widgets/entrance.dart'; import '../widgets/tap_bounce.dart'; import 'quiz_prefs.dart'; @@ -173,8 +174,9 @@ class _QuizResultScreenState extends State { Text( '${clamped.toInt()}/${widget.maxScore}', style: TextStyle( - color: Colors.black - .withValues(alpha: 0.60), + color: Colors.black.withValues( + alpha: 0.60, + ), fontWeight: FontWeight.w800, ), ), @@ -229,25 +231,35 @@ class _QuizResultScreenState extends State { ), Center( child: TapBounce( - child: SizedBox( - width: 260, - height: 46, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w900, + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: kGreenButtonGradient, + ), + child: SizedBox( + width: 260, + height: 46, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + onPressed: () async { + await _saveResultFuture; + if (!context.mounted) return; + Navigator.of( + context, + ).popUntil((r) => r.isFirst); + }, + child: const Text('Avançar'), + ), ), ), - onPressed: () async { - await _saveResultFuture; - if (!context.mounted) return; - Navigator.of(context).popUntil((r) => r.isFirst); - }, - child: const Text('Avançar'), - ), ), ), ), diff --git a/lib/screens/curiosidade_screen.dart b/lib/screens/curiosidade_screen.dart index 4f0d028..5387ae6 100644 --- a/lib/screens/curiosidade_screen.dart +++ b/lib/screens/curiosidade_screen.dart @@ -38,10 +38,7 @@ class CuriosidadeScreen extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Color(0xFFFFE6F1), - Color(0xFFFFC9DF), - ], + colors: [Color(0xFFFFE6F1), Color(0xFFFFC9DF)], ), ), ), @@ -175,7 +172,9 @@ class _CuriosityTopicTile extends StatelessWidget { decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.82), borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.black.withValues(alpha: 0.08)), + border: Border.all( + color: Colors.black.withValues(alpha: 0.08), + ), ), child: Text( description, @@ -188,19 +187,27 @@ class _CuriosityTopicTile extends StatelessWidget { ), const SizedBox(height: 14), TapBounce( - child: SizedBox( - height: 44, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2F9E94), - foregroundColor: Colors.white, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontWeight: FontWeight.w900, + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: kGreenButtonGradient, + ), + child: SizedBox( + height: 44, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontWeight: FontWeight.w900, + ), + ), + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Fechar'), ), ), - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Fechar'), ), ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 0e5a127..d804901 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -47,7 +47,23 @@ class _SettingsBodyState extends State { setState(() => _deletingAccount = true); try { await supabase.from('children').delete().eq('owner_id', uid); - await supabase.from('profiles').delete().eq('id', uid); + + // O Supabase não avisa quando uma política de RLS bloqueia silenciosamente + // uma operação: sem `.select()` para devolver as linhas apagadas não há + // como distinguir "0 linhas existiam" de "sem permissão para apagar". + final deletedProfile = await supabase + .from('profiles') + .delete() + .eq('id', uid) + .select('id'); + + if (deletedProfile.isEmpty) { + throw StateError( + 'A base de dados recusou apagar o perfil (sem política de RLS ' + 'para DELETE). Os dados não foram removidos.', + ); + } + await supabase.auth.signOut(); if (!mounted) return; Navigator.of(context).popUntil((route) => route.isFirst); diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index 0057e91..0a837c7 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,8 +12,9 @@ import '../widgets/entrance.dart'; import '../widgets/tap_bounce.dart'; // Video data structure - easily editable for future updates. -// Episódios 1-7 tocam via YouTube (não listado); preencha youtubeId ao subir -// cada vídeo. Episódios 8-13 continuam embutidos no app (assets/videos). +// Episódios 1-10 tocam via YouTube (não listado). Episódios 11-13 ainda não +// foram subidos ao YouTube e continuam embutidos no app (assets/videos) até +// lá — depois de subidos, troque videoPath por youtubeId e apague o mp4. class VideoData { final int id; final String title; @@ -35,61 +37,61 @@ final List videoList = [ id: 1, title: 'Episódio 1', description: 'Aprenda sobre saúde bucal neste episódio', - youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) + youtubeId: 'PJ58CZv4ECw', ), VideoData( id: 2, title: 'Episódio 2', description: 'Aprenda sobre saúde bucal neste episódio', - youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) + youtubeId: 'y4_kWmZtAtg', ), VideoData( id: 3, title: 'Episódio 3', description: 'Aprenda sobre saúde bucal neste episódio', - youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) + youtubeId: 'nD75Y5PuKTo', ), VideoData( id: 4, title: 'Episódio 4', description: 'Aprenda sobre saúde bucal neste episódio', - youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) + youtubeId: 'yvFllWYeuLw', ), VideoData( id: 5, title: 'Episódio 5', description: 'Aprenda sobre saúde bucal neste episódio', - youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) + youtubeId: 'DnhUa-T8_Ps', ), VideoData( id: 6, title: 'Episódio 6', description: 'Aprenda sobre saúde bucal neste episódio', - youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) + youtubeId: 'zKt_iwkrjvo', ), VideoData( id: 7, title: 'Episódio 7', description: 'Aprenda sobre saúde bucal neste episódio', - youtubeId: '', // TODO: colar o ID do vídeo do YouTube (não listado) + youtubeId: 'NpmQ2brap5A', ), VideoData( id: 8, title: 'Episódio 8', description: 'Aprenda sobre saúde bucal neste episódio', - videoPath: 'assets/videos/episodio_08.mp4', + youtubeId: 'Wj3KYw9pBi0', ), VideoData( id: 9, title: 'Episódio 9', description: 'Aprenda sobre saúde bucal neste episódio', - videoPath: 'assets/videos/episodio_09.mp4', + youtubeId: 'bOm9t61cT_U', ), VideoData( id: 10, title: 'Episódio 10', description: 'Aprenda sobre saúde bucal neste episódio', - videoPath: 'assets/videos/episodio_10.mp4', + youtubeId: 'fAitMizbcms', ), VideoData( id: 11, @@ -162,9 +164,8 @@ Future showVideoPlayerDialog(BuildContext context, VideoData video) { ).showSnackBar(const SnackBar(content: Text('Vídeo ainda não disponível'))); return Future.value(); } - return showDialog( - context: context, - builder: (context) => _YoutubePlayerDialog(video: video), + return Navigator.of(context).push( + MaterialPageRoute(builder: (context) => _YoutubePlayerPage(video: video)), ); } return showDialog( @@ -577,16 +578,26 @@ class _VideoButton extends StatelessWidget { } } -class _YoutubePlayerDialog extends StatefulWidget { - const _YoutubePlayerDialog({required this.video}); +/// Página cheia (não diálogo) para o player do YouTube. O botão de tela cheia +/// do próprio player força a rotação para paisagem via +/// `SystemChrome.setPreferredOrientations`; um `Dialog` de largura fixa não +/// se adapta a essa rotação e causa overflow gráfico. +/// +/// Em paisagem/tela cheia, o vídeo é recortado ("cover", como Instagram/ +/// TikTok) para preencher o ecrã todo sem barras pretas — em vez de manter +/// a proporção 16:9 do YouTube e sobrar espaço vazio quando o ecrã tem uma +/// proporção mais larga que 16:9 (ex.: a maioria dos telemóveis atuais). +class _YoutubePlayerPage extends StatefulWidget { + const _YoutubePlayerPage({required this.video}); final VideoData video; @override - State<_YoutubePlayerDialog> createState() => _YoutubePlayerDialogState(); + State<_YoutubePlayerPage> createState() => _YoutubePlayerPageState(); } -class _YoutubePlayerDialogState extends State<_YoutubePlayerDialog> { +class _YoutubePlayerPageState extends State<_YoutubePlayerPage> + with WidgetsBindingObserver { late final YoutubePlayerController _controller; @override @@ -596,48 +607,110 @@ class _YoutubePlayerDialogState extends State<_YoutubePlayerDialog> { initialVideoId: widget.video.youtubeId!, flags: const YoutubePlayerFlags(autoPlay: true, mute: false), ); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeMetrics() { + final isLandscape = + PlatformDispatcher.instance.views.first.physicalSize.width > + PlatformDispatcher.instance.views.first.physicalSize.height; + if (isLandscape == _controller.value.isFullScreen) return; + _controller.updateValue(_controller.value.copyWith(isFullScreen: isLandscape)); + if (isLandscape) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } else { + SystemChrome.restoreSystemUIOverlays(); + } } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + SystemChrome.restoreSystemUIOverlays(); _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); - return Dialog( - backgroundColor: Colors.transparent, - insetPadding: const EdgeInsets.all(16), - child: Container( - decoration: BoxDecoration( - color: VideoScreen._accentPink.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: VideoScreen._accentPink, width: 3), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(21), - child: SizedBox( - width: size.width * 0.9, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - YoutubePlayer(controller: _controller), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - color: VideoScreen._accentPink.withValues(alpha: 0.15), - alignment: Alignment.centerRight, - child: IconButton( - icon: const Icon(Icons.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, _) { + return PopScope( + canPop: !value.isFullScreen, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _controller.toggleFullScreenMode(); + }, + child: Scaffold( + backgroundColor: Colors.black, + appBar: value.isFullScreen + ? null + : AppBar( + backgroundColor: VideoScreen._teal, + foregroundColor: Colors.white, + elevation: 0, + title: Text( + widget.video.title, + style: const TextStyle(fontWeight: FontWeight.w900), + ), ), - ), - ], + body: value.isFullScreen + ? _CoverYoutubePlayer(controller: _controller) + : Center( + child: AspectRatio( + aspectRatio: 16 / 9, + child: YoutubePlayer(controller: _controller), + ), + ), + ), + ); + }, + ); + } +} + +/// Preenche todo o espaço disponível recortando o vídeo (mantém a proporção +/// 16:9 real do YouTube, mas amplia e corta o excesso nas laterais ou em +/// cima/baixo em vez de deixar barras pretas). O player é sempre desenhado +/// no seu tamanho real (nunca reduzido a uma caixa minúscula), por isso o +/// recorte fica nítido, sem perda de qualidade. +class _CoverYoutubePlayer extends StatelessWidget { + const _CoverYoutubePlayer({required this.controller}); + + final YoutubePlayerController controller; + + static const double _videoAspectRatio = 16 / 9; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final parentAspectRatio = constraints.maxWidth / constraints.maxHeight; + final double boxWidth; + final double boxHeight; + if (parentAspectRatio > _videoAspectRatio) { + boxWidth = constraints.maxWidth; + boxHeight = boxWidth / _videoAspectRatio; + } else { + boxHeight = constraints.maxHeight; + boxWidth = boxHeight * _videoAspectRatio; + } + return ClipRect( + child: OverflowBox( + maxWidth: boxWidth, + maxHeight: boxHeight, + child: SizedBox( + width: boxWidth, + height: boxHeight, + child: YoutubePlayer( + controller: controller, + aspectRatio: _videoAspectRatio, + ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/widgets/app_gradients.dart b/lib/widgets/app_gradients.dart index 274337f..c5e0029 100644 --- a/lib/widgets/app_gradients.dart +++ b/lib/widgets/app_gradients.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; -/// Gradiente usado em todas as app bars da aplicação — um destaque em verde -/// vivo concentrado no canto superior direito, dissolvendo rapidamente para -/// o teal da marca no resto da barra. +/// Gradiente usado em todas as app bars da aplicação. const LinearGradient kAppBarGradient = LinearGradient( begin: Alignment.topRight, end: Alignment.bottomLeft, - colors: [Color(0xFF31C679), Color(0xFF2F9E94)], - stops: [0.0, 0.55], + 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)], ); diff --git a/pubspec.yaml b/pubspec.yaml index bd98eaa..f3177ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,9 +71,6 @@ flutter: - lottie/ - assets/Check-theeth.png - assets/mockup_images/ - - assets/videos/episodio_08.mp4 - - assets/videos/episodio_09.mp4 - - assets/videos/episodio_10.mp4 - assets/videos/episodio_11.mp4 - assets/videos/episodio_12.mp4 - assets/videos/episodio_13.mp4