kz_educa/lib/widgets/lesson_detail_sheet.dart

705 lines
22 KiB
Dart
Executable File

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:youtube_player_iframe/youtube_player_iframe.dart' as yt_iframe;
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import 'dart:async';
import '../models/app_models.dart';
import '../services/i18n_service.dart';
import '../services/toast_service.dart';
import '../screens/pdf_viewer_screen.dart';
class LessonDetailSheet extends StatefulWidget {
final QuickLesson lesson;
final bool isCompleted;
final Function(QuickLesson) onMarkComplete;
const LessonDetailSheet({
super.key,
required this.lesson,
required this.isCompleted,
required this.onMarkComplete,
});
@override
State<LessonDetailSheet> createState() => _LessonDetailSheetState();
}
class _LessonDetailSheetState extends State<LessonDetailSheet> with WidgetsBindingObserver {
// Controllers
yt_iframe.YoutubePlayerController? _ytIframeController;
VideoPlayerController? _videoPlayerController;
ChewieController? _chewieController;
StreamSubscription? _ytSubscription;
// State flags
bool _isLoading = true;
bool _useExternalFallback = false;
bool _playerInitialized = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initializePlayer();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (_videoPlayerController != null) {
try {
if (state == AppLifecycleState.paused && _videoPlayerController!.value.isPlaying) {
_videoPlayerController!.pause();
}
} catch (e) {
print('LessonDetailSheet: erro ao pausar VideoPlayer -> $e');
}
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_ytSubscription?.cancel();
_chewieController?.dispose();
_videoPlayerController?.dispose();
super.dispose();
}
// MÉTODO FALLBACK: Adicionada verificação `mounted` antes de setState
void _activateExternalFallback() {
if (!mounted) return;
_ytSubscription?.cancel();
_ytIframeController = null;
_chewieController?.dispose();
_videoPlayerController?.dispose();
_videoPlayerController = null;
_chewieController = null;
// ✅ CORRIGIDO: Só chama setState se o widget ainda estiver montado
setState(() {
_useExternalFallback = true;
_isLoading = false;
});
}
Future<void> _initializePlayer() async {
if (_playerInitialized) return;
_playerInitialized = true;
if (!mounted) return;
final rawUrl = widget.lesson.videoUrl;
if (rawUrl == null || rawUrl.trim().isEmpty) {
print('LessonDetailSheet: videoUrl vazio');
_activateExternalFallback();
return;
}
final videoUrl = rawUrl.trim();
String? youtubeId;
try {
youtubeId = yt_iframe.YoutubePlayerController.convertUrlToId(videoUrl);
print('LessonDetailSheet: convertUrlToId -> $youtubeId for $videoUrl');
} catch (e) {
youtubeId = null;
print('LessonDetailSheet: erro convertUrlToId: $e');
}
if (youtubeId != null && youtubeId.isNotEmpty) {
try {
// ✅ CORRIGIDO: autoPlay é passado no construtor, não dentro de params.
_ytIframeController = yt_iframe.YoutubePlayerController.fromVideoId(
videoId: youtubeId,
autoPlay: kIsWeb, // Correção de erro: movemos para o local correto
params: const yt_iframe.YoutubePlayerParams(
showControls: true,
showFullscreenButton: true,
strictRelatedVideos: true,
playsInline: true,
mute: kIsWeb,
),
);
_ytSubscription?.cancel();
_ytSubscription = _ytIframeController!.stream.listen((event) {
if (event.error != null) {
print('LessonDetailSheet: Youtube Player Runtime Error: ${event.error}. State: ${event.playerState}. Forçando fallback.');
// ✅ CORRIGIDO: Garante que o fallback seja chamado apenas se o widget estiver montado.
Future.microtask(() {
if(mounted) {
_activateExternalFallback();
}
});
}
});
await Future.delayed(const Duration(milliseconds: 200));
if (mounted) {
setState(() {
_isLoading = false;
_useExternalFallback = false;
});
}
} catch (e) {
print('LessonDetailSheet: falha ao inicializar yt iframe controller: $e');
_activateExternalFallback();
}
return;
}
// Tenta VideoPlayer + Chewie
try {
_videoPlayerController = VideoPlayerController.networkUrl(Uri.parse(videoUrl));
await _videoPlayerController!.initialize();
_chewieController = ChewieController(
videoPlayerController: _videoPlayerController!,
autoPlay: true,
looping: false,
allowFullScreen: true,
aspectRatio: _videoPlayerController!.value.isInitialized
? _videoPlayerController!.value.aspectRatio
: 16 / 9,
placeholder: const Center(
child: CircularProgressIndicator(color: Color(0xFFE94560)),
),
errorBuilder: (context, errorMessage) {
print('LessonDetailSheet: Chewie errorBuilder -> $errorMessage');
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Erro ao carregar o vídeo. Tente o botão abaixo.',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
);
},
);
if (mounted) {
setState(() {
_isLoading = false;
_useExternalFallback = false;
});
}
} catch (e) {
print('LessonDetailSheet: falha VideoPlayer/Chewie -> $e');
_videoPlayerController?.dispose();
_videoPlayerController = null;
_chewieController?.dispose();
_chewieController = null;
_activateExternalFallback();
}
}
Widget _buildExternalPlayerFallback() {
final url = widget.lesson.videoUrl;
final i18n = Provider.of<I18nService>(context, listen: false);
return Container(
height: 250,
color: const Color(0xFF1A1A2E),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.videocam_off, color: Color(0xFFE94560), size: 50),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
'O player nativo falhou (pode ser devido a restrições de rede ou incompatibilidade na Web). Abra o vídeo diretamente no navegador.',
style: TextStyle(color: Colors.white, fontSize: 16),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () async {
if (url != null && url.isNotEmpty) {
final uri = Uri.parse(url);
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
ToastService.show(
message: i18n.t('URL de vídeo não disponível.'),
type: ToastType.error, );
}
},
icon: const Icon(Icons.open_in_new),
label: const Text('Abrir no Navegador'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE94560),
foregroundColor: Colors.white,
),
),
],
),
),
);
}
Widget _buildVideoPlayer() {
return Container(
height: 250,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF553C9A), Color(0xFF282A52)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
),
child: _isLoading
? const Center(child: CircularProgressIndicator(color: Color(0xFFE94560)))
: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
child: Builder(
builder: (context) {
if (_ytIframeController != null) {
return yt_iframe.YoutubePlayer(
controller: _ytIframeController!,
aspectRatio: 16 / 9,
);
}
if (_chewieController != null) {
return Chewie(controller: _chewieController!);
}
if (_useExternalFallback) {
return _buildExternalPlayerFallback();
}
return const Center(
child: Text(
'URL de vídeo inválida ou não disponível.',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
);
},
),
),
);
}
Future<void> _downloadFile(String url, String fileName) async {
final i18n = Provider.of<I18nService>(context, listen: false);
if (yt_iframe.YoutubePlayerController.convertUrlToId(url) != null) {
ToastService.show(
message: 'Não é possível fazer o download de vídeos do YouTube diretamente.',
type: ToastType.error,
);
return;
}
if (url.isEmpty) {
ToastService.show(
message: 'A URL do recurso não está disponível.',
type: ToastType.error,
);
return;
}
if (Theme.of(context).platform == TargetPlatform.android ||
Theme.of(context).platform == TargetPlatform.iOS) {
final status = await Permission.storage.request();
if (status.isGranted) {
await FlutterDownloader.enqueue(
url: url,
savedDir: '/storage/emulated/0/Download',
fileName: fileName,
showNotification: true,
openFileFromNotification: true,
);
ToastService.show(message: 'Download iniciado');
} else {
ToastService.show(
message: 'Permissão de armazenamento negada',
type: ToastType.error,
);
}
} else {
try {
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
ToastService.show(message: 'Download iniciado no navegador');
} else {
ToastService.show(
message: 'Não foi possível iniciar o download. Verifique a URL.',
type: ToastType.error,
);
}
} catch (e) {
ToastService.show(
message: 'Ocorreu um erro ao iniciar o download: $e',
type: ToastType.error,
);
}
}
}
// --- Widgets de UI ---
Widget _buildLessonHeader(I18nService i18n) {
return ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
colors: [Color(0xFFE94560), Color(0xFFF07E3F)],
).createShader(bounds),
child: Text(
widget.lesson.titulo ?? '',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
);
}
Widget _buildLessonContent() {
return Text(
widget.lesson.conteudo ?? '',
style: TextStyle(
fontSize: 16,
color: Colors.white.withOpacity(0.8),
),
);
}
// ✅ Mantido o uso de Expanded para garantir que a Row se ajusta
Widget _buildVideoActions(I18nService i18n) {
return Row(
children: [
// Duração: Ocupa 1/3 do espaço
Expanded(
flex: 1,
child: _buildDurationWidget(i18n),
),
const SizedBox(width: 8),
// Download: Ocupa 2/3 do espaço
Expanded(
flex: 2,
child: _buildDownloadVideoButton(i18n),
),
],
);
}
Widget _buildBookSection(I18nService i18n) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Text(
'Recursos Adicionais',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white.withOpacity(0.9),
),
),
const SizedBox(height: 12),
_buildReadBookOnlineButton(i18n),
const SizedBox(height: 16),
_buildDownloadBookButton(i18n),
],
);
}
// ✅ CORREÇÃO FINAL DE LAYOUT: Otimizado para evitar overflow vertical e horizontal
Widget _buildDurationWidget(I18nService i18n) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF553C9A), Color(0xFF282A52)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.access_time_rounded, size: 20, color: Colors.white),
const SizedBox(width: 4),
// Força o texto a quebrar ou a usar '...' se o espaço no Expanded for muito pequeno
Flexible(
child: Text(
'${i18n.t('duration')}: ${widget.lesson.duracao ?? ''}',
style: const TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Widget _buildMarkCompleteButton(I18nService i18n) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: widget.isCompleted
? const LinearGradient(
colors: [Colors.green, Colors.greenAccent],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
)
: const LinearGradient(
colors: [Color(0xFFE94560), Color(0xFFF07E3F)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.4),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: ElevatedButton.icon(
onPressed: widget.isCompleted
? null
: () {
widget.onMarkComplete(widget.lesson);
Navigator.pop(context);
ToastService.show(message: i18n.t('lesson_completed'));
},
icon: widget.isCompleted
? const Icon(Icons.check_circle_rounded, color: Colors.white)
: const Icon(Icons.check_circle_outline_rounded, color: Colors.white),
label: Text(
widget.isCompleted ? i18n.t('completed') : i18n.t('mark_as_complete'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
elevation: 0,
),
),
);
}
Widget _buildDownloadVideoButton(I18nService i18n) {
return InkWell(
onTap: () {
if (widget.lesson.videoUrl != null) {
_downloadFile(widget.lesson.videoUrl!, widget.lesson.titulo ?? 'video');
} else {
ToastService.show(
message: i18n.t('URL do vídeo não disponível.'), type: ToastType.error);
}
},
child: Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: const LinearGradient(
colors: [Color(0xFFE94560), Color(0xFFF07E3F)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
boxShadow: [
BoxShadow(
color: const Color(0xFFE94560).withOpacity(0.4),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_download_rounded, color: Colors.white, size: 24),
const SizedBox(width: 8),
Text(
i18n.t('download_video'),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
Widget _buildReadBookOnlineButton(I18nService i18n) {
return InkWell(
onTap: () {
if (widget.lesson.bookUrl != null && widget.lesson.bookUrl!.isNotEmpty) {
if (kIsWeb) {
launchUrl(Uri.parse(widget.lesson.bookUrl!), mode: LaunchMode.platformDefault);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PdfViewerScreen(pdfUrl: widget.lesson.bookUrl!),
),
);
}
} else {
ToastService.show(
message: i18n.t('URL do livro não disponível.'),
type: ToastType.error,
);
}
},
child: Container(
height: 60,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: const LinearGradient(
colors: [Color(0xFF553C9A), Color(0xFF4D2C8E)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF553C9A).withOpacity(0.4),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Center(
child: Text(
i18n.t('read_book_online'),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
Widget _buildDownloadBookButton(I18nService i18n) {
return InkWell(
onTap: () {
if (widget.lesson.bookUrl != null) {
_downloadFile(widget.lesson.bookUrl!, widget.lesson.titulo ?? 'livro');
} else {
ToastService.show(
message: i18n.t('URL do livro não disponível.'), type: ToastType.error);
}
},
child: Container(
height: 60,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: const LinearGradient(
colors: [Color(0xFF4D2C8E), Color(0xFF553C9A)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF4D2C8E).withOpacity(0.4),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Center(
child: Text(
i18n.t('download_book'),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
// Método build principal
@override
Widget build(BuildContext context) {
final i18n = Provider.of<I18nService>(context);
// O SingleChildScrollView evita que o conteúdo transborde verticalmente
return SingleChildScrollView(
child: Container(
decoration: const BoxDecoration(
color: Color(0xFF1A1A2E),
borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
),
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildVideoPlayer(),
Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLessonHeader(i18n),
const SizedBox(height: 8),
_buildLessonContent(),
const SizedBox(height: 24),
_buildVideoActions(i18n),
const SizedBox(height: 24),
_buildBookSection(i18n),
const SizedBox(height: 32),
_buildMarkCompleteButton(i18n),
],
),
),
],
),
),
);
}
}