Munça da UI de inicio | escovagem semanal adicionada | quantidade de videos assistidos | todos os videos na nuvem | possibilidade de continuar o video de onde parou

This commit is contained in:
Carlos Correia
2026-07-09 15:31:59 +01:00
parent 67b580778a
commit 9fb2840529
14 changed files with 1584 additions and 381 deletions

View File

@@ -33,34 +33,29 @@ class _SettingsBodyState extends State<SettingsBody> {
context,
title: 'Apagar dados da conta',
message:
'Isso remove permanentemente seu perfil, crianças cadastradas e '
'fotos. Essa ação não pode ser desfeita. Deseja continuar?',
'Isso remove permanentemente a sua conta, perfil, crianças '
'cadastradas e fotos — incluindo o login, permitindo criar uma '
'nova conta com o mesmo e-mail depois. Essa ação não pode ser '
'desfeita. Deseja continuar?',
confirmLabel: 'Apagar',
confirmColor: _accentPink,
);
if (confirmed != true) return;
final uid = supabase.auth.currentUser?.id;
if (uid == null) return;
setState(() => _deletingAccount = true);
try {
await supabase.from('children').delete().eq('owner_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) {
// A remoção de `auth.users` exige a service role key, que o app nunca
// deve carregar — por isso corre numa Edge Function (server-side); ver
// supabase/functions/delete-account. Sem isto, apagar só as linhas de
// `profiles`/`children` deixava o e-mail "ocupado" no Supabase Auth,
// impedindo criar uma nova conta com o mesmo e-mail.
final response = await supabase.functions.invoke('delete-account');
final data = response.data;
final errorMessage = (data is Map) ? data['error']?.toString() : null;
if (response.status != 200 || errorMessage != null) {
throw StateError(
'A base de dados recusou apagar o perfil (sem política de RLS '
'para DELETE). Os dados não foram removidos.',
errorMessage ?? 'Erro ao apagar conta (status ${response.status})',
);
}

View File

@@ -7,6 +7,7 @@ import 'package:lottie/lottie.dart';
import 'package:video_player/video_player.dart';
import 'package:youtube_player_flutter/youtube_player_flutter.dart';
import '../watched_videos_prefs.dart';
import '../widgets/app_gradients.dart';
import '../widgets/entrance.dart';
import '../widgets/tap_bounce.dart';
@@ -97,19 +98,19 @@ final List<VideoData> videoList = [
id: 11,
title: 'Episódio 11',
description: 'Aprenda sobre saúde bucal neste episódio',
videoPath: 'assets/videos/episodio_11.mp4',
youtubeId: '6sYoBUjks_I',
),
VideoData(
id: 12,
title: 'Episódio 12',
description: 'Aprenda sobre saúde bucal neste episódio',
videoPath: 'assets/videos/episodio_12.mp4',
youtubeId: 'eznKrErQbHo',
),
VideoData(
id: 13,
title: 'Episódio 13',
description: 'Aprenda sobre saúde bucal neste episódio',
videoPath: 'assets/videos/episodio_13.mp4',
youtubeId: 'VO9CNqHRdeM',
),
];
@@ -156,7 +157,20 @@ void _evictAllVideoControllers() {
}
}
Future<void> showVideoPlayerDialog(BuildContext context, VideoData video) {
/// Regista o episódio como assistido (localmente, por criança). Chamado
/// quando um player deteta que o vídeo chegou ao fim — sem [scopeId] (nenhuma
/// criança selecionada) não há onde guardar, por isso não faz nada.
void markVideoWatched(String? scopeId, int videoId) {
final scope = (scopeId ?? '').trim();
if (scope.isEmpty) return;
WatchedVideosPrefs.markWatched(scope, videoId);
}
Future<void> showVideoPlayerDialog(
BuildContext context,
VideoData video, {
String? scopeId,
}) {
if (video.youtubeId != null) {
if (video.youtubeId!.isEmpty) {
ScaffoldMessenger.of(
@@ -165,17 +179,23 @@ Future<void> showVideoPlayerDialog(BuildContext context, VideoData video) {
return Future.value();
}
return Navigator.of(context).push<void>(
MaterialPageRoute(builder: (context) => _YoutubePlayerPage(video: video)),
MaterialPageRoute(
builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId),
),
);
}
return showDialog<void>(
context: context,
builder: (context) => _VideoPlayerDialog(video: video),
builder: (context) => _VideoPlayerDialog(video: video, scopeId: scopeId),
);
}
class VideoScreen extends StatefulWidget {
const VideoScreen({super.key});
const VideoScreen({super.key, this.scopeId});
/// Identifica a criança selecionada (`'${uid}_${childId}'`), usado para
/// guardar localmente quais episódios ela já assistiu até ao fim.
final String? scopeId;
static const Color _teal = Color(0xFF2F9E94);
static const Color _accentPink = Color(0xFFFF55A7);
@@ -329,6 +349,7 @@ class _VideoScreenState extends State<VideoScreen> {
),
child: _VideoButton(
video: _filteredVideos[index],
scopeId: widget.scopeId,
),
);
},
@@ -509,9 +530,10 @@ class _VideoThumbnailState extends State<VideoThumbnail> {
}
class _VideoButton extends StatelessWidget {
const _VideoButton({required this.video});
const _VideoButton({required this.video, this.scopeId});
final VideoData video;
final String? scopeId;
void _showVideoPlayer(BuildContext context, VideoData video) {
if (video.youtubeId == null) {
@@ -519,7 +541,7 @@ class _VideoButton extends StatelessWidget {
// em dialog, que precisa dos seus próprios decoders de vídeo/áudio.
_evictAllVideoControllers();
}
showVideoPlayerDialog(context, video);
showVideoPlayerDialog(context, video, scopeId: scopeId);
}
@override
@@ -588,9 +610,10 @@ class _VideoButton extends StatelessWidget {
/// 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});
const _YoutubePlayerPage({required this.video, this.scopeId});
final VideoData video;
final String? scopeId;
@override
State<_YoutubePlayerPage> createState() => _YoutubePlayerPageState();
@@ -599,6 +622,7 @@ class _YoutubePlayerPage extends StatefulWidget {
class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
with WidgetsBindingObserver {
late final YoutubePlayerController _controller;
bool _markedWatched = false;
@override
void initState() {
@@ -607,9 +631,18 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
initialVideoId: widget.video.youtubeId!,
flags: const YoutubePlayerFlags(autoPlay: true, mute: false),
);
_controller.addListener(_onControllerValueChanged);
WidgetsBinding.instance.addObserver(this);
}
void _onControllerValueChanged() {
if (_markedWatched) return;
if (_controller.value.playerState == PlayerState.ended) {
_markedWatched = true;
markVideoWatched(widget.scopeId, widget.video.id);
}
}
@override
void didChangeMetrics() {
final isLandscape =
@@ -628,6 +661,7 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
void dispose() {
WidgetsBinding.instance.removeObserver(this);
SystemChrome.restoreSystemUIOverlays();
_controller.removeListener(_onControllerValueChanged);
_controller.dispose();
super.dispose();
}
@@ -716,9 +750,10 @@ class _CoverYoutubePlayer extends StatelessWidget {
}
class _VideoPlayerDialog extends StatefulWidget {
const _VideoPlayerDialog({required this.video});
const _VideoPlayerDialog({required this.video, this.scopeId});
final VideoData video;
final String? scopeId;
@override
State<_VideoPlayerDialog> createState() => _VideoPlayerDialogState();
@@ -727,6 +762,7 @@ class _VideoPlayerDialog extends StatefulWidget {
class _VideoPlayerDialogState extends State<_VideoPlayerDialog> {
late VideoPlayerController _controller;
bool _isInitialized = false;
bool _markedWatched = false;
@override
void initState() {
@@ -738,6 +774,7 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> {
_controller = VideoPlayerController.asset(widget.video.videoPath!);
try {
await _controller.initialize();
_controller.addListener(_onControllerValueChanged);
if (mounted) {
setState(() {
_isInitialized = true;
@@ -755,8 +792,19 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> {
}
}
void _onControllerValueChanged() {
if (_markedWatched) return;
final value = _controller.value;
if (!value.isInitialized || value.duration == Duration.zero) return;
if (value.position >= value.duration - const Duration(milliseconds: 300)) {
_markedWatched = true;
markVideoWatched(widget.scopeId, widget.video.id);
}
}
@override
void dispose() {
_controller.removeListener(_onControllerValueChanged);
_controller.dispose();
super.dispose();
}
@@ -802,6 +850,8 @@ class _VideoPlayerDialogState extends State<_VideoPlayerDialog> {
_VideoControls(
controller: _controller,
videoPath: widget.video.videoPath!,
videoId: widget.video.id,
scopeId: widget.scopeId,
onClose: () => Navigator.of(context).pop(),
),
],
@@ -817,11 +867,15 @@ class _VideoControls extends StatefulWidget {
const _VideoControls({
required this.controller,
required this.videoPath,
required this.videoId,
required this.onClose,
this.scopeId,
});
final VideoPlayerController controller;
final String videoPath;
final int videoId;
final String? scopeId;
final VoidCallback onClose;
@override
@@ -926,6 +980,8 @@ class _VideoControlsState extends State<_VideoControls> {
MaterialPageRoute(
builder: (context) => _FullscreenVideoPlayer(
videoPath: widget.videoPath,
videoId: widget.videoId,
scopeId: widget.scopeId,
),
fullscreenDialog: true,
),
@@ -949,9 +1005,15 @@ class _VideoControlsState extends State<_VideoControls> {
}
class _FullscreenVideoPlayer extends StatefulWidget {
const _FullscreenVideoPlayer({required this.videoPath});
const _FullscreenVideoPlayer({
required this.videoPath,
required this.videoId,
this.scopeId,
});
final String videoPath;
final int videoId;
final String? scopeId;
@override
State<_FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState();
@@ -960,6 +1022,7 @@ class _FullscreenVideoPlayer extends StatefulWidget {
class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> {
late VideoPlayerController _controller;
bool _isInitialized = false;
bool _markedWatched = false;
@override
void initState() {
@@ -994,6 +1057,16 @@ class _FullscreenVideoPlayerState extends State<_FullscreenVideoPlayer> {
}
void _onControllerUpdate() {
if (!_markedWatched) {
final value = _controller.value;
if (value.isInitialized &&
value.duration != Duration.zero &&
value.position >=
value.duration - const Duration(milliseconds: 300)) {
_markedWatched = true;
markVideoWatched(widget.scopeId, widget.videoId);
}
}
if (mounted) {
setState(() {});
}