Videos na nuvem | atualização de desing (Appbar) | mudança no quiz

This commit is contained in:
Carlos Correia
2026-07-08 22:23:38 +01:00
parent a7b6d35026
commit 67b580778a
12 changed files with 885 additions and 660 deletions

View File

@@ -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'),
),
),
),

View File

@@ -47,7 +47,23 @@ class _SettingsBodyState extends State<SettingsBody> {
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);

View File

@@ -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<VideoData> 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<void> showVideoPlayerDialog(BuildContext context, VideoData video) {
).showSnackBar(const SnackBar(content: Text('Vídeo ainda não disponível')));
return Future.value();
}
return showDialog<void>(
context: context,
builder: (context) => _YoutubePlayerDialog(video: video),
return Navigator.of(context).push<void>(
MaterialPageRoute(builder: (context) => _YoutubePlayerPage(video: video)),
);
}
return showDialog<void>(
@@ -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<YoutubePlayerValue>(
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,
),
),
),
),
),
);
},
);
}
}