Atulização do quiz | Porcentagem na base de dados | Telas de Contrato com o usuario
This commit is contained in:
317
lib/screens/privacy_gate_screen.dart
Normal file
317
lib/screens/privacy_gate_screen.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
import '../privacy_gate_prefs.dart';
|
||||
import '../terms_gate_prefs.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
import '../widgets/privacy_content.dart';
|
||||
|
||||
/// Ecrã de bloqueio mostrado logo após o cadastro (primeira vez), antes do
|
||||
/// [TermsGateScreen] e antes de entrar na app. Só avança para o próximo
|
||||
/// passo se o utilizador consentir os três itens de privacidade; se
|
||||
/// recusar (botão ou seta de voltar), a conta acabada de criar é removida
|
||||
/// (perfil apagado + sessão terminada) e volta-se ao login.
|
||||
class PrivacyGateScreen extends StatefulWidget {
|
||||
const PrivacyGateScreen({
|
||||
super.key,
|
||||
required this.userId,
|
||||
required this.onAccepted,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final VoidCallback onAccepted;
|
||||
|
||||
@override
|
||||
State<PrivacyGateScreen> createState() => _PrivacyGateScreenState();
|
||||
}
|
||||
|
||||
class _PrivacyGateScreenState extends State<PrivacyGateScreen> {
|
||||
final Set<String> _accepted = {};
|
||||
bool _declining = false;
|
||||
|
||||
bool get _allAccepted => _accepted.length == kPrivacyConsentItems.length;
|
||||
|
||||
void _toggle(String id, bool value) {
|
||||
setState(() {
|
||||
if (value) {
|
||||
_accepted.add(id);
|
||||
} else {
|
||||
_accepted.remove(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _acceptAll() {
|
||||
setState(() {
|
||||
_accepted.addAll(kPrivacyConsentItems.map((e) => e.id));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _decline() async {
|
||||
if (_declining) return;
|
||||
setState(() => _declining = true);
|
||||
try {
|
||||
await supabase
|
||||
.from('profiles')
|
||||
.delete()
|
||||
.eq('id', widget.userId)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
} catch (_) {
|
||||
// Mesmo que a limpeza do perfil falhe, termina a sessão de qualquer
|
||||
// forma — o AuthGate trata contas sem perfil como inexistentes.
|
||||
}
|
||||
await PrivacyGatePrefs.clearPendingUid();
|
||||
// A conta vai ser apagada — não faz sentido deixar o utilizador cair
|
||||
// no ecrã de Termos logo a seguir com uma sessão já sem perfil.
|
||||
await TermsGatePrefs.clearPendingUid();
|
||||
await supabase.auth.signOut();
|
||||
// Não faz setState depois disto: o widget é removido da árvore assim
|
||||
// que o AuthGate reage à sessão terminada.
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _decline();
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(color: const Color(0xFFFAFAF7)),
|
||||
),
|
||||
Positioned(
|
||||
left: -size.width * 0.38,
|
||||
bottom: -size.width * 0.38,
|
||||
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(
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: IconButton(
|
||||
onPressed: _declining ? null : _decline,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
// Só o cabeçalho e a lista de itens rolam — os botões
|
||||
// ficam fixos, fora do SingleChildScrollView, tal como
|
||||
// no TermsGateScreen.
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 4),
|
||||
child: Column(
|
||||
children: [
|
||||
const FadeSlideIn(child: PrivacyHeader()),
|
||||
const SizedBox(height: 22),
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 80),
|
||||
child: _PrivacyConsentList(
|
||||
accepted: _accepted,
|
||||
onChanged: _declining ? null : _toggle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
|
||||
child: Column(
|
||||
children: [
|
||||
TapBounce(
|
||||
child: TextButton(
|
||||
onPressed: _declining ? null : _acceptAll,
|
||||
child: const Text(
|
||||
'Aceitar tudo',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
color: kPrivacyTeal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdvanceButton(
|
||||
enabled: _allAccepted && !_declining,
|
||||
onPressed: widget.onAccepted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_declining)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: kPrivacyTeal),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivacyConsentList extends StatelessWidget {
|
||||
const _PrivacyConsentList({required this.accepted, required this.onChanged});
|
||||
|
||||
final Set<String> accepted;
|
||||
final void Function(String id, bool value)? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
for (var i = 0; i < kPrivacyConsentItems.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 14),
|
||||
_PrivacyConsentRow(
|
||||
item: kPrivacyConsentItems[i],
|
||||
checked: accepted.contains(kPrivacyConsentItems[i].id),
|
||||
onChanged: onChanged == null
|
||||
? null
|
||||
: (v) => onChanged!(kPrivacyConsentItems[i].id, v),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivacyConsentRow extends StatelessWidget {
|
||||
const _PrivacyConsentRow({
|
||||
required this.item,
|
||||
required this.checked,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final PrivacyConsentItem item;
|
||||
final bool checked;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TapBounce(
|
||||
scale: 0.98,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onChanged == null ? null : () => onChanged!(!checked),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
// IgnorePointer: o Checkbox tem um tap target mínimo de
|
||||
// 48x48 que competia na arena de gestos com o InkWell da
|
||||
// linha toda — o mesmo problema já resolvido no
|
||||
// TermsGateScreen. Aqui serve só para o visual; quem trata
|
||||
// o toque é sempre o InkWell à volta de toda a linha.
|
||||
child: IgnorePointer(
|
||||
child: Checkbox(
|
||||
value: checked,
|
||||
onChanged: onChanged == null ? null : (_) {},
|
||||
activeColor: kPrivacyTeal,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.text,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.45,
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdvanceButton extends StatelessWidget {
|
||||
const _AdvanceButton({required this.enabled, required this.onPressed});
|
||||
|
||||
final bool enabled;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TapBounce(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: kPrivacyTeal,
|
||||
disabledBackgroundColor: kPrivacyTeal.withValues(alpha: 0.35),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
onPressed: enabled ? onPressed : null,
|
||||
child: const Text('Avançar'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
244
lib/screens/terms_gate_screen.dart
Normal file
244
lib/screens/terms_gate_screen.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
|
||||
import '../main.dart' show supabase;
|
||||
import '../terms_gate_prefs.dart';
|
||||
import '../widgets/entrance.dart';
|
||||
import '../widgets/tap_bounce.dart';
|
||||
import '../widgets/terms_content.dart';
|
||||
|
||||
/// Ecrã de bloqueio mostrado logo após o cadastro (primeira vez), antes de
|
||||
/// entrar na app. Só avança para a Home se o utilizador aceitar os Termos;
|
||||
/// se recusar (botão ou seta de voltar), a conta acabada de criar é
|
||||
/// removida (perfil apagado + sessão terminada) e volta-se ao login.
|
||||
class TermsGateScreen extends StatefulWidget {
|
||||
const TermsGateScreen({
|
||||
super.key,
|
||||
required this.userId,
|
||||
required this.onAccepted,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final VoidCallback onAccepted;
|
||||
|
||||
@override
|
||||
State<TermsGateScreen> createState() => _TermsGateScreenState();
|
||||
}
|
||||
|
||||
class _TermsGateScreenState extends State<TermsGateScreen> {
|
||||
bool _accepted = false;
|
||||
bool _declining = false;
|
||||
|
||||
Future<void> _decline() async {
|
||||
if (_declining) return;
|
||||
setState(() => _declining = true);
|
||||
try {
|
||||
await supabase
|
||||
.from('profiles')
|
||||
.delete()
|
||||
.eq('id', widget.userId)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
} catch (_) {
|
||||
// Mesmo que a limpeza do perfil falhe, termina a sessão de qualquer
|
||||
// forma — o AuthGate trata contas sem perfil como inexistentes.
|
||||
}
|
||||
await TermsGatePrefs.clearPendingUid();
|
||||
await supabase.auth.signOut();
|
||||
// Não faz setState depois disto: o widget é removido da árvore assim
|
||||
// que o AuthGate reage à sessão terminada.
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _decline();
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(color: const Color(0xFFFAFAF7)),
|
||||
),
|
||||
Positioned(
|
||||
left: -size.width * 0.38,
|
||||
bottom: -size.width * 0.38,
|
||||
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(
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: IconButton(
|
||||
onPressed: _declining ? null : _decline,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
// O checkbox e o botão ficam fixos, fora do
|
||||
// SingleChildScrollView — só o texto dos termos rola. Além
|
||||
// de ser o padrão comum em ecrãs de Termos (ação sempre
|
||||
// visível, sem precisar de chegar ao fundo do texto), evita
|
||||
// que a ação de aceitar dependa de o scroll estar
|
||||
// completamente parado.
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 4, 24, 4),
|
||||
child: Column(
|
||||
children: [
|
||||
const FadeSlideIn(child: TermsHeader()),
|
||||
const SizedBox(height: 22),
|
||||
FadeSlideIn(
|
||||
delay: const Duration(milliseconds: 80),
|
||||
child: const TermsBody(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 20),
|
||||
child: Column(
|
||||
children: [
|
||||
_AcceptCheckboxRow(
|
||||
accepted: _accepted,
|
||||
onChanged: _declining
|
||||
? null
|
||||
: (v) => setState(() => _accepted = v),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_AdvanceButton(
|
||||
enabled: _accepted && !_declining,
|
||||
onPressed: widget.onAccepted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_declining)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: kTermsPink),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AcceptCheckboxRow extends StatelessWidget {
|
||||
const _AcceptCheckboxRow({required this.accepted, required this.onChanged});
|
||||
|
||||
final bool accepted;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TapBounce(
|
||||
scale: 0.98,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onChanged == null ? null : () => onChanged!(!accepted),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
// IgnorePointer: o Checkbox tem um tap target mínimo de
|
||||
// 48x48 que, espremido neste SizedBox de 24x24 dentro do
|
||||
// InkWell da linha toda, competia na arena de gestos e
|
||||
// acabava por engolir o toque sem disparar nada. Aqui serve
|
||||
// só para o visual (marcado/desmarcado); quem trata o toque
|
||||
// é sempre o InkWell à volta de toda a linha.
|
||||
child: IgnorePointer(
|
||||
child: Checkbox(
|
||||
value: accepted,
|
||||
onChanged: onChanged == null ? null : (_) {},
|
||||
activeColor: kTermsPink,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Aceitar tudo',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
color: kTermsPink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdvanceButton extends StatelessWidget {
|
||||
const _AdvanceButton({required this.enabled, required this.onPressed});
|
||||
|
||||
final bool enabled;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TapBounce(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: kTermsPink,
|
||||
disabledBackgroundColor: kTermsPink.withValues(alpha: 0.35),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
onPressed: enabled ? onPressed : null,
|
||||
child: const Text('Avançar'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/app_gradients.dart';
|
||||
import '../widgets/terms_content.dart';
|
||||
|
||||
class TermsScreen extends StatelessWidget {
|
||||
const TermsScreen({super.key});
|
||||
|
||||
static const Color _accentPink = Color(0xFFFF55A7);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -30,41 +29,13 @@ class TermsScreen extends StatelessWidget {
|
||||
body: Container(
|
||||
color: const Color(0xFFFAFAF7),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Termos de Serviço',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: _accentPink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const SingleChildScrollView(
|
||||
child: Text(
|
||||
'Conteúdo dos Termos de Serviço em breve.\n\n'
|
||||
'Este espaço será preenchido com os termos de uso e '
|
||||
'condições do Check-Teeth Kids.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const TermsHeader(),
|
||||
const SizedBox(height: 22),
|
||||
const TermsBody(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -319,7 +319,10 @@ class _VideoScreenState extends State<VideoScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Video grid
|
||||
// Lista vertical (um vídeo abaixo do outro, rolando para
|
||||
// baixo), mas cada card em si é horizontal — miniatura à
|
||||
// esquerda, título/descrição à direita — em vez da antiga
|
||||
// grelha de 2 colunas com cards verticais.
|
||||
Expanded(
|
||||
child: _filteredVideos.isEmpty
|
||||
? Center(
|
||||
@@ -332,15 +335,10 @@ class _VideoScreenState extends State<VideoScreen> {
|
||||
),
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
: ListView.separated(
|
||||
itemCount: _filteredVideos.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return FadeSlideIn(
|
||||
delay: Duration(
|
||||
@@ -546,112 +544,127 @@ class _VideoButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TapBounce(
|
||||
scale: 0.95,
|
||||
scale: 0.97,
|
||||
child: Material(
|
||||
elevation: 10,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: () => _showVideoPlayer(context, video),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: const Color(0xFFFFE6F1),
|
||||
child: VideoThumbnail(video: video, borderRadius: 0),
|
||||
),
|
||||
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],
|
||||
),
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: () => _showVideoPlayer(context, video),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: const Color(0xFFFFE6F1),
|
||||
child: VideoThumbnail(video: video, borderRadius: 0),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
DecoratedBox(
|
||||
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,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.32),
|
||||
],
|
||||
stops: const [0.55, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
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(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
video.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 15,
|
||||
color: VideoScreen._teal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
video.description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
video.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 14,
|
||||
color: VideoScreen._teal,
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
video.description,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -733,24 +746,73 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
|
||||
if (!didPop) _controller.toggleFullScreenMode();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: value.isFullScreen
|
||||
? Colors.black
|
||||
: const Color(0xFFFAFAF7),
|
||||
appBar: value.isFullScreen
|
||||
? null
|
||||
: AppBar(
|
||||
backgroundColor: VideoScreen._teal,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
widget.video.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w900),
|
||||
: 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: Text(
|
||||
widget.video.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w900),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Em vez de centrar o vídeo num quadro 16:9 no meio de um ecrã
|
||||
// preto (o que sobrava como barras pretas em cima/baixo no
|
||||
// modo retrato), o vídeo fica encostado ao topo, a preencher a
|
||||
// largura toda, com o título e a descrição do episódio logo
|
||||
// abaixo — sem nenhum espaço preto sobrando. O modo tela cheia
|
||||
// (paisagem) continua a recortar o vídeo para preencher tudo.
|
||||
body: value.isFullScreen
|
||||
? _CoverYoutubePlayer(controller: _controller)
|
||||
: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: YoutubePlayer(controller: _controller),
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: YoutubePlayer(controller: _controller),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.video.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 19,
|
||||
color: VideoScreen._teal,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.video.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user