From 80ed2b134617c51ca0d31b6c4347d97e8ef03a7a Mon Sep 17 00:00:00 2001 From: 240405 <240405@epvc.pt> Date: Tue, 19 May 2026 21:54:37 +0100 Subject: [PATCH] =?UTF-8?q?Visualiza=C3=A7=C3=A3o=20de=20conteudo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/services/materials_rag_service.dart | 50 +- .../widgets/quick_access_widget.dart | 47 ++ .../pages/content_management_page.dart | 714 ++++++++++++++++++ .../presentation/pages/pdf_viewer_page.dart | 142 ++++ .../pages/teacher_materials_page.dart | 52 +- .../presentation/pages/quiz_list_page.dart | 471 ++---------- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 ++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 13 files changed, 1121 insertions(+), 431 deletions(-) create mode 100644 lib/features/materials/presentation/pages/content_management_page.dart create mode 100644 lib/features/materials/presentation/pages/pdf_viewer_page.dart diff --git a/lib/core/services/materials_rag_service.dart b/lib/core/services/materials_rag_service.dart index 578c5f9..28bff0b 100644 --- a/lib/core/services/materials_rag_service.dart +++ b/lib/core/services/materials_rag_service.dart @@ -64,11 +64,13 @@ class MaterialsRAGService { if (classId == null || enrolledClassIds.contains(classId)) { final fileName = data['fileName'] as String? ?? 'Material'; final teacherId = data['teacherId'] as String?; + final url = data['url'] as String?; result.add({ 'id': doc.id, 'name': fileName, if (classId != null) 'classId': classId, if (teacherId != null) 'teacherId': teacherId, + if (url != null) 'url': url, }); } } @@ -561,66 +563,76 @@ class MaterialsRAGService { /// Formatar texto extraído do PDF para melhor legibilidade static String _formatPDFText(String text) { if (text.isEmpty) return text; - + String formatted = text; - + // Corrigir quebras de linha excessivas formatted = formatted.replaceAll(RegExp(r'\n{3,}'), '\n\n'); - + // Corrigir espaços excessivos formatted = formatted.replaceAll(RegExp(r'[ \t]+'), ' '); - + // Remover espaços no início/fim das linhas formatted = formatted.split('\n').map((line) => line.trim()).join('\n'); - + // Corrigir parágrafos (linhas que terminam com ponto e seguem sem espaço) formatted = formatted.replaceAllMapped( RegExp(r'\.(\n)([A-ZÁÉÍÓÚÀÂÊÔÃÕÇ])'), (match) => '.\n\n${match.group(2)}', ); - + // Corrigir quebras de palavras com hífen no fim da linha formatted = formatted.replaceAllMapped( RegExp(r'([a-zA-Záéíóúàâêôãõç])-\n([a-zA-Záéíóúàâêôãõç])'), (match) => '${match.group(1)}${match.group(2)}', ); - + // Adicionar quebras de parágrafo para títulos (linhas em maiúsculas) formatted = formatted.replaceAllMapped( RegExp(r'\n([A-ZÁÉÍÓÚÀÂÊÔÃÕÇ][A-ZÁÉÍÓÚÀÂÊÔÃÕÇ\s]{10,})\n'), (match) => '\n\n${match.group(1)}\n\n', ); - + // Limpar quebras de linha no início e fim formatted = formatted.trim(); - + return formatted; } /// Obter o texto completo de um PDF específico para pré-visualização - static Future getFullPDFText(String fileName, String teacherId) async { + static Future getFullPDFText( + String fileName, + String teacherId, + ) async { try { // Remover extensão se existir - final cleanFileName = fileName.endsWith('.pdf') ? fileName : '$fileName.pdf'; - + final cleanFileName = fileName.endsWith('.pdf') + ? fileName + : '$fileName.pdf'; + // Usar cache do texto completo se disponível final cacheKey = '${cleanFileName}_preview_v6'; - if (_chunksCache.containsKey(cacheKey) && _chunksCache[cacheKey]!.isNotEmpty) { + if (_chunksCache.containsKey(cacheKey) && + _chunksCache[cacheKey]!.isNotEmpty) { final fullText = _chunksCache[cacheKey]!.first; - Logger.info('Using cached preview text for $cleanFileName: ${fullText.length} chars'); + Logger.info( + 'Using cached preview text for $cleanFileName: ${fullText.length} chars', + ); return fullText; } - + // Extrair texto completo final rawText = await _extractFullText(cleanFileName, teacherId); - + // Formatar texto para melhor legibilidade final formattedText = _formatPDFText(rawText); - + // Guardar em cache _chunksCache[cacheKey] = [formattedText]; - - Logger.info('PDF "$cleanFileName" -> ${formattedText.length} chars extracted and formatted for preview'); + + Logger.info( + 'PDF "$cleanFileName" -> ${formattedText.length} chars extracted and formatted for preview', + ); return formattedText; } catch (e) { Logger.error('Error getting full PDF text for $fileName: $e'); diff --git a/lib/features/dashboard/presentation/widgets/quick_access_widget.dart b/lib/features/dashboard/presentation/widgets/quick_access_widget.dart index 5cae775..576044b 100644 --- a/lib/features/dashboard/presentation/widgets/quick_access_widget.dart +++ b/lib/features/dashboard/presentation/widgets/quick_access_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:go_router/go_router.dart'; import '../../../classes/presentation/pages/join_class_page.dart'; +import '../../../materials/presentation/pages/content_management_page.dart'; import 'dashboard_action_card.dart'; /// Quick access cards for Student Dashboard with horizontal scrollable row @@ -22,6 +23,7 @@ class QuickAccessWidget extends StatelessWidget { Widget build(BuildContext context) { final cards = [ _buildTutorIACard(context), + _buildContentManagementCard(context), _buildQuizCard(context), _buildAchievementsCard(context), ]; @@ -69,6 +71,8 @@ class QuickAccessWidget extends StatelessWidget { SizedBox(width: _scrollCardWidth, child: cards[1]), const SizedBox(width: 12), SizedBox(width: _scrollCardWidth, child: cards[2]), + const SizedBox(width: 12), + SizedBox(width: _scrollCardWidth, child: cards[3]), ], ), ), @@ -114,6 +118,37 @@ class QuickAccessWidget extends StatelessWidget { ); } + Widget _buildContentManagementCard(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(16), + child: + DashboardActionCardSurface( + title: 'Gerenciamento Conteúdo', + subtitle: 'Ver por disciplinas', + icon: Icons.folder_open, + minHeight: _cardMinHeight, + titleFontSize: _titleFontSize, + subtitleFontSize: _subtitleFontSize, + iconSize: _iconSize, + padding: _cardPadding, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ContentManagementPage(), + ), + ); + }, + ) + .animate() + .fadeIn( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ) + .then(delay: const Duration(milliseconds: 150)), + ); + } + Widget _buildQuizCard(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(16), @@ -197,6 +232,18 @@ class QuickAccessWidget extends StatelessWidget { context.go('/ai-tutor'); }, ), + _QuickAccessItem( + title: 'Gerenciamento Conteúdo', + subtitle: 'Ver por disciplinas', + icon: Icons.folder_open, + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ContentManagementPage()), + ); + }, + ), _QuickAccessItem( title: 'Quiz', subtitle: 'Testa os teus conhecimentos', diff --git a/lib/features/materials/presentation/pages/content_management_page.dart b/lib/features/materials/presentation/pages/content_management_page.dart new file mode 100644 index 0000000..5165d98 --- /dev/null +++ b/lib/features/materials/presentation/pages/content_management_page.dart @@ -0,0 +1,714 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:intl/intl.dart'; +import 'package:path/path.dart' as path; +import 'package:url_launcher/url_launcher.dart'; +import '../../../../core/theme/app_theme_extension.dart'; +import 'pdf_viewer_page.dart'; + +/// Página de Gerenciamento de Conteúdo +/// Mostra todos os materiais disponíveis separados por disciplinas (turmas) +class ContentManagementPage extends StatefulWidget { + const ContentManagementPage({super.key}); + + @override + State createState() => _ContentManagementPageState(); +} + +class _ContentManagementPageState extends State { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + Map>> _materialsByClass = {}; + Map _classNames = {}; + bool _isLoading = true; + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + List _filteredClassIds = []; + int _selectedTabIndex = 0; + + @override + void initState() { + super.initState(); + _loadMaterials(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _filterClasses(String query) { + setState(() { + _searchQuery = query.toLowerCase(); + if (_searchQuery.isEmpty) { + _filteredClassIds = _classNames.keys.toList(); + } else { + _filteredClassIds = _classNames.entries + .where((entry) => entry.value.toLowerCase().contains(_searchQuery)) + .map((entry) => entry.key) + .toList(); + } + _selectedTabIndex = 0; + }); + } + + Future _loadMaterials() async { + final currentUser = FirebaseAuth.instance.currentUser; + if (currentUser == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + try { + // Buscar turmas onde o aluno está inscrito + final enrollSnapshot = await _firestore + .collection('enrollments') + .where('studentId', isEqualTo: currentUser.uid) + .get(); + + final enrolledClassIds = enrollSnapshot.docs + .map((doc) => doc.data()['classId'] as String?) + .whereType() + .toSet(); + + if (enrolledClassIds.isEmpty) { + if (mounted) setState(() => _isLoading = false); + return; + } + + // Buscar nomes das turmas + final classNames = {}; + final classDocs = await Future.wait( + enrolledClassIds.map( + (id) => _firestore.collection('classes').doc(id).get(), + ), + ); + for (final doc in classDocs.where((d) => d.exists)) { + classNames[doc.id] = doc.data()?['name'] as String? ?? doc.id; + } + + // Buscar materiais dessas turmas (uma por uma para evitar índice) + final materialsByClass = >>{}; + + for (final classId in enrolledClassIds) { + final materialsSnapshot = await _firestore + .collection('materials') + .where('classId', isEqualTo: classId) + .get(); + + for (final doc in materialsSnapshot.docs) { + final material = doc.data(); + material['id'] = doc.id; + if (classNames.containsKey(classId)) { + materialsByClass.putIfAbsent(classId, () => []).add(material); + } + } + } + + if (mounted) { + setState(() { + _materialsByClass = materialsByClass; + _classNames = classNames; + _filteredClassIds = classNames.keys.toList(); + _isLoading = false; + }); + } + } catch (e) { + print('Error loading materials: $e'); + if (mounted) setState(() => _isLoading = false); + } + } + + Future _viewInApp(String url, String fileName) async { + if (url.isEmpty) { + _showErrorSnackBar('URL do ficheiro não disponível'); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PdfViewerPage(url: url, fileName: fileName), + ), + ); + } + + Future _openFile(String? url, String fileName) async { + if (url == null || url.isEmpty) { + _showErrorSnackBar('URL do ficheiro não disponível'); + return; + } + + try { + final uri = Uri.parse(url); + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + _showErrorSnackBar('Não foi possível abrir o ficheiro'); + } + } catch (e) { + _showErrorSnackBar('Erro ao abrir ficheiro: $e'); + } + } + + Future _deleteMaterial( + String docId, + String fileName, + String? url, + ) async { + // Students cannot delete materials + _showErrorSnackBar('Apenas professores podem eliminar materiais'); + } + + void _showDeleteConfirmation(String docId, String fileName, String? url) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Eliminar Material', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: Text( + 'Tens a certeza que queres eliminar "$fileName"?\nEsta ação não pode ser desfeita.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancelar'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + _deleteMaterial(docId, fileName, url); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text('Eliminar'), + ), + ], + ), + ); + } + + void _showSuccessSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: const Color(0xFF10B981), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 2), + ), + ); + } + + void _showErrorSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final themeExtras = AppThemeExtras.of(context); + + if (_isLoading) { + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: themeExtras.dashboardBackgroundGradient, + stops: themeExtras.dashboardGradientStops, + ), + ), + child: const Center(child: CircularProgressIndicator()), + ), + ); + } + + if (_materialsByClass.isEmpty) { + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: themeExtras.dashboardBackgroundGradient, + stops: themeExtras.dashboardGradientStops, + ), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: cs.surface.withOpacity(0.3), + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.folder_open, + color: cs.onSurfaceVariant, + size: 64, + ), + ) + .animate() + .fadeIn(duration: const Duration(milliseconds: 400)) + .scale(), + const SizedBox(height: 24), + Text( + 'Nenhum material disponível', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ).animate().fadeIn(delay: const Duration(milliseconds: 200)), + const SizedBox(height: 8), + Text( + 'Junta-te a uma disciplina para ver materiais', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ).animate().fadeIn(delay: const Duration(milliseconds: 300)), + ], + ), + ), + ), + ); + } + + final defaultTabController = DefaultTabController( + length: _filteredClassIds.length, + initialIndex: _selectedTabIndex, + child: Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: themeExtras.dashboardBackgroundGradient, + stops: themeExtras.dashboardGradientStops, + ), + ), + child: Column( + children: [ + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Gerenciamento de Conteúdo', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: cs.surface.withOpacity(0.8), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: cs.outline.withOpacity(0.2), + width: 1, + ), + ), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Procurar turmas...', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + prefixIcon: Icon( + Icons.search, + color: cs.onSurfaceVariant, + ), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: Icon( + Icons.clear, + color: cs.onSurfaceVariant, + ), + onPressed: () { + _searchController.clear(); + _filterClasses(''); + }, + ) + : null, + filled: true, + fillColor: Colors.transparent, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: _filterClasses, + ), + ), + ], + ), + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: cs.surface.withOpacity(0.8), + borderRadius: BorderRadius.circular(16), + ), + child: TabBar( + isScrollable: _filteredClassIds.length > 3, + dividerColor: Colors.transparent, + indicatorSize: TabBarIndicatorSize.tab, + indicator: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(12), + ), + labelColor: cs.onPrimary, + unselectedLabelColor: cs.onSurfaceVariant, + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + tabs: _filteredClassIds + .map( + (classId) => Tab( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text(_classNames[classId] ?? classId), + ), + ), + ) + .toList(), + ), + ), + const SizedBox(height: 16), + Expanded( + child: _filteredClassIds.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: cs.surface.withOpacity(0.3), + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.search_off, + color: cs.onSurfaceVariant, + size: 64, + ), + ) + .animate() + .fadeIn( + duration: const Duration(milliseconds: 400), + ) + .scale(), + const SizedBox(height: 24), + Text( + 'Nenhuma turma encontrada', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ).animate().fadeIn( + delay: const Duration(milliseconds: 200), + ), + const SizedBox(height: 8), + Text( + 'Tenta uma pesquisa diferente', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ).animate().fadeIn( + delay: const Duration(milliseconds: 300), + ), + ], + ), + ) + : TabBarView( + children: _filteredClassIds.map((classId) { + final materials = _materialsByClass[classId] ?? []; + if (materials.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: cs.surface.withOpacity(0.3), + borderRadius: BorderRadius.circular( + 20, + ), + ), + child: Icon( + Icons.folder_open, + color: cs.onSurfaceVariant, + size: 48, + ), + ) + .animate() + .fadeIn( + duration: const Duration( + milliseconds: 400, + ), + ) + .scale(), + const SizedBox(height: 20), + Text( + 'Nenhum material nesta turma', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).animate().fadeIn( + delay: const Duration(milliseconds: 200), + ), + ], + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: materials.length, + separatorBuilder: (_, __) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final material = materials[index]; + return _buildMaterialCard(material, cs) + .animate() + .fadeIn( + delay: Duration(milliseconds: 50 * index), + duration: const Duration(milliseconds: 300), + ) + .slideY( + begin: 0.1, + end: 0, + delay: Duration(milliseconds: 50 * index), + ); + }, + ); + }).toList(), + ), + ), + ], + ), + ), + ), + ); + + return defaultTabController; + } + + Widget _buildMaterialCard(Map material, ColorScheme cs) { + final fileName = material['fileName'] ?? 'Ficheiro sem nome'; + final url = material['url'] as String?; + final createdAt = material['createdAt'] as Timestamp?; + + final extension = path.extension(fileName).toLowerCase(); + IconData iconData; + Color iconColor; + + if (extension == '.pdf') { + iconData = Icons.picture_as_pdf; + iconColor = Colors.red; + } else if (extension == '.jpg' || + extension == '.jpeg' || + extension == '.png') { + iconData = Icons.image; + iconColor = Colors.blue; + } else { + iconData = Icons.insert_drive_file; + iconColor = const Color(0xFF82C9BD); + } + + String formattedDate = 'Data desconhecida'; + if (createdAt != null) { + formattedDate = DateFormat('dd/MM/yyyy HH:mm').format(createdAt.toDate()); + } + + return Container( + decoration: BoxDecoration( + color: cs.surface.withOpacity(0.8), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outline.withOpacity(0.15), width: 1), + boxShadow: [ + BoxShadow( + color: cs.shadow.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: url != null ? () => _openFile(url, fileName) : null, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: iconColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: iconColor.withOpacity(0.3), + width: 1, + ), + ), + child: Icon(iconData, color: iconColor, size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: cs.onSurface, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 12, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + formattedDate, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + if (url != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.primary.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.primary.withOpacity(0.3), + width: 1, + ), + ), + child: IconButton( + icon: Icon( + Icons.visibility_outlined, + color: cs.primary, + size: 22, + ), + tooltip: 'Ver na app', + onPressed: () => _viewInApp(url, fileName), + ), + ), + const SizedBox(width: 8), + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.secondary.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.secondary.withOpacity(0.3), + width: 1, + ), + ), + child: IconButton( + icon: Icon( + Icons.download_outlined, + color: cs.secondary, + size: 22, + ), + tooltip: 'Transferir', + onPressed: () => _openFile(url, fileName), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/materials/presentation/pages/pdf_viewer_page.dart b/lib/features/materials/presentation/pages/pdf_viewer_page.dart new file mode 100644 index 0000000..ef3117d --- /dev/null +++ b/lib/features/materials/presentation/pages/pdf_viewer_page.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import '../../../../core/theme/app_theme_extension.dart'; + +class PdfViewerPage extends StatefulWidget { + final String url; + final String fileName; + + const PdfViewerPage({super.key, required this.url, required this.fileName}); + + @override + State createState() => _PdfViewerPageState(); +} + +class _PdfViewerPageState extends State { + late final WebViewController _webViewController; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (String url) { + setState(() { + _isLoading = true; + }); + }, + onPageFinished: (String url) { + setState(() { + _isLoading = false; + }); + }, + ), + ) + ..loadRequest( + Uri.parse( + 'https://docs.google.com/gview?embedded=true&url=${Uri.encodeComponent(widget.url)}', + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final themeExtras = AppThemeExtras.of(context); + + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: themeExtras.dashboardBackgroundGradient, + stops: themeExtras.dashboardGradientStops, + ), + ), + child: Column( + children: [ + SafeArea( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surface.withOpacity(0.8), + boxShadow: [ + BoxShadow( + color: cs.shadow.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.fileName, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + 'Visualização de documento', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ), + Expanded( + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: cs.shadow.withOpacity(0.1), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + WebViewWidget(controller: _webViewController), + if (_isLoading) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/materials/presentation/pages/teacher_materials_page.dart b/lib/features/materials/presentation/pages/teacher_materials_page.dart index d311f24..18a2d27 100644 --- a/lib/features/materials/presentation/pages/teacher_materials_page.dart +++ b/lib/features/materials/presentation/pages/teacher_materials_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:path/path.dart' as path; +import 'package:url_launcher/url_launcher.dart'; import '../../../../core/services/auth_service.dart'; @@ -825,6 +826,31 @@ class _TeacherMaterialsPageState extends State { ); } + Future _openFile(String? url, String fileName) async { + if (url == null || url.isEmpty) { + _showErrorSnackBar('URL do ficheiro não disponível'); + return; + } + + try { + print('Opening file: $fileName'); + print('URL: $url'); + final uri = Uri.parse(url); + + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + _showErrorSnackBar('Não foi possível abrir o ficheiro'); + } + } catch (e) { + print('Error opening file: $e'); + _showErrorSnackBar('Erro ao abrir ficheiro: $e'); + } + } + Widget _buildMaterialCard({ required String docId, required String fileName, @@ -893,14 +919,24 @@ class _TeacherMaterialsPageState extends State { fontSize: 13, ), ), - trailing: IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.red), - tooltip: 'Eliminar', - onPressed: () => _showDeleteConfirmation( - docId: docId, - fileName: fileName, - url: url, - ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility_outlined, color: Colors.blue), + tooltip: 'Ver', + onPressed: () => _openFile(url, fileName), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + tooltip: 'Eliminar', + onPressed: () => _showDeleteConfirmation( + docId: docId, + fileName: fileName, + url: url, + ), + ), + ], ), ), ); diff --git a/lib/features/quiz/presentation/pages/quiz_list_page.dart b/lib/features/quiz/presentation/pages/quiz_list_page.dart index f79e214..c10fec0 100644 --- a/lib/features/quiz/presentation/pages/quiz_list_page.dart +++ b/lib/features/quiz/presentation/pages/quiz_list_page.dart @@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../../core/services/materials_rag_service.dart'; import '../../../../core/services/rag_ai_service.dart'; import '../../../../core/services/gamification_service.dart'; import '../../../../core/utils/logger.dart'; import '../../../../core/theme/app_theme_extension.dart'; +import '../../../materials/presentation/pages/pdf_viewer_page.dart'; class QuizListPage extends StatefulWidget { const QuizListPage({super.key}); @@ -742,390 +744,46 @@ class _QuizListPageState extends State dynamic _jsonDecode(String s) => jsonDecode(s); - Future _previewPDF(Map mat, String name) async { - final matId = mat['id']!; - final matName = name.replaceAll('.pdf', '').replaceAll('_', ' '); - - // Mostrar loading - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - content: Row( - children: [ - CircularProgressIndicator(), - const SizedBox(width: 16), - Text('A carregar PDF...'), - ], - ), - ), - ); + Future _openFile(String? url, String fileName) async { + if (url == null || url.isEmpty) { + _showSnack('URL do ficheiro não disponível'); + return; + } try { - // Obter o texto completo do PDF usando o método existente - final teacherId = mat['teacherId']; - if (teacherId == null) { - Navigator.of(context).pop(); - _showSnack( - 'Erro: não foi possível identificar o professor do material.', - ); - return; - } + print('Opening file: $fileName'); + print('URL: $url'); + final uri = Uri.parse(url); - final fullText = await MaterialsRAGService.getFullPDFText( - matName, - teacherId, + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, ); - Navigator.of(context).pop(); // Fechar loading - - if (fullText.isEmpty) { - _showSnack('Não foi possível carregar o conteúdo do PDF.'); - return; + if (!launched) { + _showSnack('Não foi possível abrir o ficheiro'); } - - // Mostrar o conteúdo em um diálogo scrollável melhorado - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => Container( - height: MediaQuery.of(context).size.height * 0.90, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 20, - offset: const Offset(0, -4), - ), - ], - ), - child: Column( - children: [ - // Handle bar - Container( - width: 40, - height: 4, - margin: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - - // Header melhorado - Container( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.8), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: const BorderRadius.vertical( - top: Radius.circular(24), - ), - ), - child: Column( - children: [ - Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - ), - ), - child: Icon( - Icons.picture_as_pdf, - color: Colors.white, - size: 24, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - matName, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - 'Pré-visualização do conteúdo completo', - style: TextStyle( - fontSize: 13, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - ], - ), - ), - Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - ), - child: IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon( - Icons.close, - color: Colors.white, - size: 20, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // Stats bar - Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Icon( - Icons.description, - color: Colors.white.withValues(alpha: 0.9), - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${(fullText.length / 1000).toStringAsFixed(1)}K caracteres • ${fullText.split('\n').length} linhas', - style: TextStyle( - fontSize: 12, - color: Colors.white.withValues(alpha: 0.9), - fontWeight: FontWeight.w500, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'PDF', - style: TextStyle( - fontSize: 10, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ], - ), - ), - - // Content area melhorado - Expanded( - child: Container( - margin: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.1), - ), - ), - child: Column( - children: [ - // Content header - Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(16), - ), - ), - child: Row( - children: [ - Icon( - Icons.text_fields, - color: Theme.of(context).colorScheme.primary, - size: 18, - ), - const SizedBox(width: 8), - Text( - 'Conteúdo do Material', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'Selecionável', - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - - // Text content - Expanded( - child: Container( - padding: const EdgeInsets.all(20), - child: SingleChildScrollView( - child: SelectableText( - fullText, - style: TextStyle( - fontSize: 14, - height: 1.7, - color: Theme.of(context).colorScheme.onSurface, - fontFamily: 'Roboto', - letterSpacing: 0.3, - ), - ), - ), - ), - ), - ], - ), - ), - ), - - // Footer melhorado - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(24), - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.info_outline, - color: Theme.of(context).colorScheme.primary, - size: 16, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Texto extraído automaticamente do PDF', - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - 'Formatação otimizada para melhor legibilidade', - style: TextStyle( - fontSize: 11, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.8), - ), - ), - ], - ), - ), - Icon( - Icons.keyboard_arrow_up, - color: Theme.of(context).colorScheme.onSurfaceVariant, - size: 16, - ), - ], - ), - ), - ], - ), - ), - ); } catch (e) { - Logger.error('Error previewing PDF: $e'); - Navigator.of(context).pop(); // Fechar loading - _showSnack('Erro ao carregar o PDF. Tenta novamente.'); + print('Error opening file: $e'); + _showSnack('Erro ao abrir ficheiro: $e'); } } + Future _previewPDF(Map mat, String name) async { + final url = mat['url']; + if (url == null || url.isEmpty) { + _showSnack('URL do ficheiro não disponível'); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PdfViewerPage(url: url, fileName: name), + ), + ); + } + void _showInteractiveQuiz( String title, List<_QuizQuestion> questions, { @@ -1676,37 +1334,42 @@ class _QuizListPageState extends State ], ), ), - // Action indicator - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: isGenerating - ? cs.primary.withValues(alpha: 0.1) - : cs.surfaceContainerHighest.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isGenerating - ? cs.primary.withValues(alpha: 0.2) - : cs.outline.withValues(alpha: 0.12), - ), - ), - child: isGenerating - ? Center( - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), - ), - ) - : Icon( - Icons.chevron_right, - color: cs.onSurfaceVariant, - size: 22, + // Action indicator with view button + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isGenerating + ? cs.primary.withValues(alpha: 0.1) + : cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isGenerating + ? cs.primary.withValues(alpha: 0.2) + : cs.outline.withValues(alpha: 0.12), ), + ), + child: isGenerating + ? Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + ) + : Icon( + Icons.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + ), + ), + ], ), ], ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 85a2413..3ccd551 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = @@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7aea3ec..fbedf4a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cc9c405..aaba5df 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -19,6 +19,7 @@ import google_sign_in_ios import local_auth_darwin import shared_preferences_foundation import sqflite_darwin +import url_launcher_macos import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -36,5 +37,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 2161b40..8748a41 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1673,6 +1673,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b1b42d8..097ca46 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: http: ^1.1.2 connectivity_plus: ^5.0.2 retry: ^3.1.2 + url_launcher: ^6.2.5 # Local Storage shared_preferences: ^2.2.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0b79c72..483856d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -15,6 +15,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { CloudFirestorePluginCApiRegisterWithRegistrar( @@ -35,4 +36,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("LocalAuthPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 1c81bcf..d861582 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows local_auth_windows permission_handler_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST