Atualização de gauge

This commit is contained in:
Carlos Correia
2026-07-14 18:23:03 +01:00
parent 4fcf3a475a
commit 5b25d9bef4
22 changed files with 973 additions and 1441 deletions

View File

@@ -1,15 +1,14 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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/liquid_waves_background.dart';
import '../widgets/pill_snackbar.dart';
import '../widgets/tap_bounce.dart';
@@ -62,7 +61,8 @@ final List<VideoData> videoList = [
VideoData(
id: 5,
title: 'Episódio 5',
description: 'Qual a Influência das Bronquiolites recorrentes na má oclusão',
description:
'Qual a Influência das Bronquiolites recorrentes na má oclusão',
youtubeId: 'DnhUa-T8_Ps',
),
VideoData(
@@ -74,7 +74,8 @@ final List<VideoData> videoList = [
VideoData(
id: 7,
title: 'Episódio 7',
description: 'Qual a Influência das interrupções respiratórias na má oclusão',
description:
'Qual a Influência das interrupções respiratórias na má oclusão',
youtubeId: 'NpmQ2brap5A',
),
VideoData(
@@ -86,7 +87,8 @@ final List<VideoData> videoList = [
VideoData(
id: 9,
title: 'Episódio 9',
description: 'Qual a Influência de acordar com saliva seca na boca ou na almofada na saúde oral',
description:
'Qual a Influência de acordar com saliva seca na boca ou na almofada na saúde oral',
youtubeId: 'bOm9t61cT_U',
),
VideoData(
@@ -179,7 +181,8 @@ Future<void> showVideoPlayerDialog(
}
return Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => _YoutubePlayerPage(video: video, scopeId: scopeId),
builder: (context) =>
_YoutubePlayerPage(video: video, scopeId: scopeId),
),
);
}
@@ -234,7 +237,6 @@ class _VideoScreenState extends State<VideoScreen> {
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
@@ -260,30 +262,8 @@ class _VideoScreenState extends State<VideoScreen> {
body: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Container(color: const Color(0xFFFAFAF7)),
),
Positioned(
left: -size.width * 0.40,
bottom: -size.width * 0.45,
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,
),
),
),
),
),
),
Positioned.fill(child: Container(color: const Color(0xFFFAFAF7))),
const LiquidWavesBackground(),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -341,9 +321,7 @@ class _VideoScreenState extends State<VideoScreen> {
const SizedBox(height: 12),
itemBuilder: (context, index) {
return FadeSlideIn(
delay: Duration(
milliseconds: 40 * (index % 8),
),
delay: Duration(milliseconds: 40 * (index % 8)),
child: _VideoButton(
video: _filteredVideos[index],
scopeId: widget.scopeId,
@@ -561,70 +539,73 @@ class _VideoButton extends StatelessWidget {
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),
),
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],
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(
@@ -718,7 +699,9 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
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));
_controller.updateValue(
_controller.value.copyWith(isFullScreen: isLandscape),
);
if (isLandscape) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
} else {
@@ -782,12 +765,21 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: YoutubePlayer(controller: _controller),
DecoratedBox(
decoration: const BoxDecoration(
gradient: kAppBarGradient,
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: YoutubePlayer(controller: _controller),
),
),
Padding(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
child: _VideoProgressRow(controller: _controller),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -809,6 +801,65 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
height: 1.4,
),
),
if (_nextVideos.isNotEmpty) ...[
const SizedBox(height: 24),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text(
'Próximos',
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15,
color: VideoScreen._accentPink,
),
),
if (_hasMoreVideos)
TapBounce(
child: InkWell(
borderRadius: BorderRadius.circular(
999,
),
onTap: () =>
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => VideoScreen(
scopeId: widget.scopeId,
),
),
),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: Text(
'Ver mais',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 13,
color: VideoScreen._teal,
),
),
),
),
),
],
),
const SizedBox(height: 12),
for (
var i = 0;
i < _visibleNextVideos.length;
i++
) ...[
if (i > 0) const SizedBox(height: 10),
_VideoButton(
video: _visibleNextVideos[i],
scopeId: widget.scopeId,
),
],
],
],
),
),
@@ -820,6 +871,85 @@ class _YoutubePlayerPageState extends State<_YoutubePlayerPage>
},
);
}
static const int _maxNextVideos = 4;
List<VideoData> get _nextVideos =>
videoList.where((v) => v.id > widget.video.id).toList();
List<VideoData> get _visibleNextVideos =>
_nextVideos.take(_maxNextVideos).toList();
bool get _hasMoreVideos => _nextVideos.length > _maxNextVideos;
}
/// Barra de progresso do vídeo (posição atual / duração total), separada do
/// player — a mesma que aparece por baixo do vídeo no design de referência.
class _VideoProgressRow extends StatelessWidget {
const _VideoProgressRow({required this.controller});
final YoutubePlayerController controller;
String _format(Duration d) {
final minutes = d.inMinutes.remainder(60).toString();
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<YoutubePlayerValue>(
valueListenable: controller,
builder: (context, value, _) {
final duration = value.metaData.duration;
final position = value.position;
final progress = duration.inMilliseconds > 0
? (position.inMilliseconds / duration.inMilliseconds).clamp(
0.0,
1.0,
)
: 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: progress,
minHeight: 5,
backgroundColor: Colors.black.withValues(alpha: 0.10),
valueColor: const AlwaysStoppedAnimation(
VideoScreen._accentPink,
),
),
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(position),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.black.withValues(alpha: 0.55),
),
),
Text(
_format(duration),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.black.withValues(alpha: 0.55),
),
),
],
),
],
);
},
);
}
}
/// Preenche todo o espaço disponível recortando o vídeo (mantém a proporção
@@ -1059,8 +1189,7 @@ class _VideoControlsState extends State<_VideoControls> {
color: Colors.white,
),
tooltip: 'Retroceder 10s',
onPressed: () =>
_seekBy(const Duration(seconds: -10)),
onPressed: () => _seekBy(const Duration(seconds: -10)),
),
IconButton(
icon: Icon(