CKT 1.0.2 | otorrinolaringologista

This commit is contained in:
Carlos Correia
2026-07-16 12:58:46 +01:00
parent a8af2c6845
commit f5884d8c36
12 changed files with 611 additions and 53 deletions

View File

@@ -34,18 +34,16 @@ const List<_CreditSection> _kCreditSections = [
),
]),
_CreditSection(CreditsStrings.developmentSection, [
_CreditPerson('Carlos Correia', CreditsStrings.developerRole),
_CreditPerson('Fábio Ceia', CreditsStrings.developerRole),
_CreditPerson('Ruben Grandra', CreditsStrings.developerRole),
_CreditPerson('Dinis Maria', CreditsStrings.developerRole),
_CreditPerson('Carlos Eduardo Correia', CreditsStrings.developerRole),
_CreditPerson('Fábio Oliveira Ceia', CreditsStrings.developerRole),
_CreditPerson('Dinis Maria Grulha Filipe', CreditsStrings.developerRole),
_CreditPerson('Rúben Silva Gandra', CreditsStrings.developerRole),
]),
_CreditSection(CreditsStrings.advisorsSection, [
_CreditPerson('Augusta Pureza Alves Silveira', CreditsStrings.advisorRole),
_CreditPerson('Cristina Lopes Cardoso Silva', CreditsStrings.advisorRole),
_CreditPerson(
'João Carlos Rodrigues L. Miranda',
CreditsStrings.advisorRole,
),
_CreditPerson('Augusta Pureza Alves Silveira', CreditsStrings.medRole),
_CreditPerson('Cristina Lopes Cardoso Silva', CreditsStrings.medRole),
_CreditPerson('João Carlos Rodrigues L. Miranda',CreditsStrings.infRole,),
_CreditPerson('Tiago Órfão)', CreditsStrings.otoringoRole),
]),
_CreditSection(CreditsStrings.institutionSection, [
_CreditPerson('Universidade Fernando Pessoa', ''),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../colors/app_colors.dart';
import '../main.dart' show supabase;
import '../strings/common_strings.dart';
import '../strings/settings_strings.dart';
import '../widgets/app_dialogs.dart';
import '../widgets/entrance.dart';
@@ -41,9 +42,29 @@ class _SettingsBodyState extends State<SettingsBody> {
);
if (confirmed != true) return;
if (!mounted) return;
final password = await _promptPassword(context);
if (password == null || password.isEmpty) return;
if (!mounted) return;
final email = (supabase.auth.currentUser?.email ?? '').trim();
if (email.isEmpty) return;
setState(() => _deletingAccount = true);
try {
// Reautentica com a palavra-passe introduzida antes de apagar nada —
// sem esta verificação, qualquer pessoa com o telemóvel desbloqueado
// (sessão já iniciada) conseguia apagar a conta sem confirmar que é
// mesmo o dono.
try {
await supabase.auth.signInWithPassword(email: email, password: password);
} catch (_) {
if (mounted) showPillSnackBar(context, SettingsStrings.wrongPassword);
return;
}
if (!mounted) return;
// 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
@@ -146,7 +167,7 @@ class _SettingsBodyState extends State<SettingsBody> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SectionLabel(SettingsStrings.dangerZone),
_SectionLabel(SettingsStrings.userData),
_SettingsCard(
children: [
_ActionTile(
@@ -261,3 +282,91 @@ class _ActionTile extends StatelessWidget {
);
}
}
/// Pede a palavra-passe atual antes de uma ação irreversível (apagar dados
/// da conta) — devolve a palavra-passe introduzida, ou `null` se cancelado.
/// Só valida que o campo não está vazio; a palavra-passe em si é validada
/// depois via `signInWithPassword`, pelo chamador.
Future<String?> _promptPassword(BuildContext context) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) {
var obscure = true;
String? errorText;
return StatefulBuilder(
builder: (ctx, setState) {
void submit() {
if (controller.text.isEmpty) {
setState(() => errorText = SettingsStrings.passwordRequired);
return;
}
Navigator.of(ctx).pop(controller.text);
}
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
title: const Text(
SettingsStrings.confirmPasswordTitle,
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w900, color: _accentPink),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
SettingsStrings.confirmPasswordMessage,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.black.withValues(alpha: 0.72)),
),
const SizedBox(height: 14),
TextField(
controller: controller,
obscureText: obscure,
autofocus: true,
onSubmitted: (_) => submit(),
decoration: InputDecoration(
labelText: SettingsStrings.password,
errorText: errorText,
suffixIcon: IconButton(
icon: Icon(
obscure
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
),
onPressed: () => setState(() => obscure = !obscure),
),
),
),
],
),
actionsAlignment: MainAxisAlignment.center,
actions: [
TapBounce(
child: TextButton(
style: TextButton.styleFrom(foregroundColor: _teal),
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text(CommonStrings.cancel),
),
),
TapBounce(
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: _accentPink,
foregroundColor: Colors.white,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
),
onPressed: submit,
child: const Text(SettingsStrings.confirm),
),
),
],
);
},
);
},
);
}

View File

@@ -194,6 +194,11 @@ Future<void> showVideoPlayerDialog(
class VideoScreen extends StatefulWidget {
const VideoScreen({super.key, this.scopeId});
/// Nome de rota usado para identificar esta tela na pilha de navegação —
/// permite que o botão de voltar de um vídeo (mesmo vários "Próximos"
/// adentro) volte diretamente para aqui, em vez de vídeo a vídeo.
static const String routeName = 'video_library';
/// Identifica a criança selecionada (`'${uid}_${childId}'`), usado para
/// guardar localmente quais episódios ela já assistiu até ao fim.
final String? scopeId;
@@ -717,15 +722,32 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
super.dispose();
}
/// Volta diretamente para a grelha de vídeos ([VideoScreen]), mesmo que
/// se tenha chegado aqui através de vários vídeos "Próximos" seguidos —
/// em vez de um pop normal, que voltaria vídeo a vídeo.
void _returnToLibrary() {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil(
(route) => route.settings.name == VideoScreen.routeName,
);
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<YoutubePlayerValue>(
valueListenable: _controller,
builder: (context, value, _) {
return PopScope(
canPop: !value.isFullScreen,
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _controller.toggleFullScreenMode();
if (didPop) return;
if (value.isFullScreen) {
_controller.toggleFullScreenMode();
} else {
_returnToLibrary();
}
},
child: Scaffold(
backgroundColor: value.isFullScreen
@@ -745,6 +767,10 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: _returnToLibrary,
),
title: Text(
widget.video.title,
style: const TextStyle(fontWeight: FontWeight.w900),
@@ -823,6 +849,9 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
onTap: () =>
Navigator.of(context).push(
MaterialPageRoute<void>(
settings: const RouteSettings(
name: VideoScreen.routeName,
),
builder: (_) => VideoScreen(
scopeId: widget.scopeId,
),