diff --git a/assets/images/favicon.png b/assets/images/favicon.png index acc8b9a..a7c99b3 100644 Binary files a/assets/images/favicon.png and b/assets/images/favicon.png differ diff --git a/assets/images/logo_nh.png b/assets/images/logo_nh.png index 3921a07..3379624 100644 Binary files a/assets/images/logo_nh.png and b/assets/images/logo_nh.png differ diff --git a/assets/images/logo_nh1.png b/assets/images/logo_nh1.png new file mode 100644 index 0000000..3921a07 Binary files /dev/null and b/assets/images/logo_nh1.png differ diff --git a/assets/images/logo_nh_b.png b/assets/images/logo_nh_b.png index a65a3f1..5e05526 100644 Binary files a/assets/images/logo_nh_b.png and b/assets/images/logo_nh_b.png differ diff --git a/assets/images/logo_nh_b1.png b/assets/images/logo_nh_b1.png new file mode 100644 index 0000000..a65a3f1 Binary files /dev/null and b/assets/images/logo_nh_b1.png differ diff --git a/lib/helpers/constants.dart b/lib/helpers/constants.dart index cf2d779..d153b71 100644 --- a/lib/helpers/constants.dart +++ b/lib/helpers/constants.dart @@ -12,7 +12,7 @@ const String apiGatewayUrl = 'https://cbl.cbluna-dev.com/uapi/lu/api'; const String n8nUrl = 'https://u-n8n.cbluna-dev.com/webhook'; const bearerApiGateway = "Basic YWlyZmxvdzpjYiF1bmEyMDIz"; -const int organizationId = 10; +const int organizationId = 17; const String lectoresUrl = 'https://lectoresurbanos.com/'; const themeId = String.fromEnvironment('themeId', defaultValue: '2'); diff --git a/lib/models/media/media_file_model.dart b/lib/models/media/media_file_model.dart index 20ff4d5..e8e45d6 100644 --- a/lib/models/media/media_file_model.dart +++ b/lib/models/media/media_file_model.dart @@ -153,4 +153,9 @@ class MediaFileModel { DateTime? get lastViewedAt => metadataJson?['last_viewed_at'] != null ? DateTime.tryParse(metadataJson!['last_viewed_at']) : null; + + // Poster information from metadata_json + String? get posterUrl => metadataJson?['poster_url']; + String? get posterFileName => metadataJson?['poster_file_name']; + int? get fileSizeBytesFromMetadata => metadataJson?['file_size_bytes']; } diff --git a/lib/pages/login_page/widgets/login_form.dart b/lib/pages/login_page/widgets/login_form.dart index bbea5d4..cdc8de5 100644 --- a/lib/pages/login_page/widgets/login_form.dart +++ b/lib/pages/login_page/widgets/login_form.dart @@ -145,35 +145,16 @@ class _LoginFormState extends State with TickerProviderStateMixin { margin: const EdgeInsets.only(bottom: 50), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - Color(0xFF3B82F6), - Color(0xFF10B981) - ], - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: const Color(0xFF3B82F6) - .withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: Image.asset( - 'assets/images/favicon.png', - fit: BoxFit.contain, - ), + SizedBox( + width: 75, + height: 75, + child: Image.asset( + 'assets/images/favicon.png', + fit: BoxFit.contain, ), ), const SizedBox(width: 16), @@ -181,9 +162,9 @@ class _LoginFormState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Bienvenido a NetHive', + 'Bienvenido a EnergyMedia', style: GoogleFonts.inter( - fontSize: 28, + fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white, ), @@ -200,17 +181,6 @@ class _LoginFormState extends State with TickerProviderStateMixin { ), ], ), - const SizedBox(height: 20), - Container( - width: 60, - height: 3, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF10B981), Color(0xFF3B82F6)], - ), - borderRadius: BorderRadius.circular(2), - ), - ), ], ), ) @@ -262,7 +232,7 @@ class _LoginFormState extends State with TickerProviderStateMixin { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: 'admin@nethive.com', + hintText: 'admin@energymedia.com', hintStyle: GoogleFonts.inter( color: isMobile ? Colors.white.withOpacity(0.6) diff --git a/lib/pages/videos/gestor_videos_page.dart b/lib/pages/videos/gestor_videos_page.dart index 339f696..36cf511 100644 --- a/lib/pages/videos/gestor_videos_page.dart +++ b/lib/pages/videos/gestor_videos_page.dart @@ -251,6 +251,9 @@ class _GestorVideosPageState extends State { rendererContext.row.cells['video']?.value as MediaFileModel?; if (video == null) return const SizedBox(); + // Obtener poster desde metadata_json + final posterUrl = video.posterUrl; + return Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( @@ -259,9 +262,9 @@ class _GestorVideosPageState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: video.fileUrl != null + child: posterUrl != null && posterUrl.isNotEmpty ? Image.network( - video.fileUrl!, + posterUrl, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Icon( Icons.video_library, @@ -285,8 +288,8 @@ class _GestorVideosPageState extends State { width: 250, ), PlutoColumn( - title: 'Archivo', - field: 'fileName', + title: 'Descripción', + field: 'file_description', type: PlutoColumnType.text(), width: 200, ), @@ -381,28 +384,37 @@ class _GestorVideosPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (video.fileUrl != null) - ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - child: AspectRatio( - aspectRatio: 16 / 9, - child: Image.network( - video.fileUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - color: AppTheme.of(context).tertiaryBackground, - child: Icon( - Icons.video_library, - size: 64, - color: AppTheme.of(context).tertiaryText, - ), - ), - ), - ), + // Mostrar poster si existe, sino mostrar placeholder + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), ), + child: AspectRatio( + aspectRatio: 16 / 9, + child: video.posterUrl != null && video.posterUrl!.isNotEmpty + ? Image.network( + video.posterUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: AppTheme.of(context).tertiaryBackground, + child: Icon( + Icons.video_library, + size: 64, + color: AppTheme.of(context).tertiaryText, + ), + ), + ) + : Container( + color: AppTheme.of(context).tertiaryBackground, + child: Icon( + Icons.video_library, + size: 64, + color: AppTheme.of(context).tertiaryText, + ), + ), + ), + ), Padding( padding: const EdgeInsets.all(16), child: Column( @@ -586,7 +598,7 @@ class _GestorVideosPageState extends State { .where((cat) => cat.mediaCategoriesId == video.mediaCategoryFk) .firstOrNull; - await showDialog( + final result = await showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( @@ -671,7 +683,7 @@ class _GestorVideosPageState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(context, false), child: Text( 'Cancelar', style: TextStyle( @@ -681,8 +693,6 @@ class _GestorVideosPageState extends State { ), ElevatedButton( onPressed: () async { - Navigator.pop(context); - // Actualizar campos if (titleController.text != video.title) { await provider.updateVideoTitle( @@ -707,16 +717,7 @@ class _GestorVideosPageState extends State { ); } - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Video actualizado exitosamente'), - backgroundColor: Colors.green, - ), - ); - - await _loadData(); + Navigator.pop(context, true); }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.of(context).primaryColor, @@ -728,6 +729,20 @@ class _GestorVideosPageState extends State { ), ), ); + + // Manejar resultado después de cerrar el diálogo + if (result == true) { + await _loadData(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Video actualizado exitosamente'), + backgroundColor: Colors.green, + ), + ); + } } Future _deleteVideo( diff --git a/lib/pages/videos/videos_layout.dart b/lib/pages/videos/videos_layout.dart index d79c567..18ca214 100644 --- a/lib/pages/videos/videos_layout.dart +++ b/lib/pages/videos/videos_layout.dart @@ -63,6 +63,9 @@ class _VideosLayoutState extends State { } Widget _buildHeader(bool isMobile) { + final isDark = AppTheme.themeMode == ThemeMode.dark; + final isLightBackground = !isDark; + return Container( padding: EdgeInsets.all(isMobile ? 16 : 24), decoration: BoxDecoration( @@ -82,26 +85,15 @@ class _VideosLayoutState extends State { color: AppTheme.of(context).primaryText, onPressed: () => _scaffoldKey.currentState?.openDrawer(), ), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - gradient: AppTheme.of(context).primaryGradient, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.energy_savings_leaf, - color: Color(0xFF0B0B0D), - size: 24, - ), - ), - const Gap(12), - Text( - 'EnergyMedia', - style: AppTheme.of(context).title2.override( - fontFamily: 'Poppins', - color: AppTheme.of(context).primaryText, - fontWeight: FontWeight.bold, - ), + // Logo de EnergyMedia + Image.asset( + isMobile + ? 'assets/images/favicon.png' + : isLightBackground + ? 'assets/images/logo_nh.png' + : 'assets/images/logo_nh_b.png', + height: isMobile ? 32 : 75, + fit: BoxFit.contain, ), const Spacer(), Text( @@ -154,29 +146,13 @@ class _VideosLayoutState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.energy_savings_leaf, - color: Color(0xFF0B0B0D), - size: 32, - ), + // Logo de EnergyMedia + Image.asset( + 'assets/images/logo_nh_b.png', + height: 50, + fit: BoxFit.contain, ), - const Gap(16), - Text( - 'EnergyMedia', - style: AppTheme.of(context).title2.override( - fontFamily: 'Poppins', - color: const Color(0xFF0B0B0D), - fontWeight: FontWeight.bold, - fontSize: 24, - ), - ), - const Gap(4), + const Gap(12), Text( 'Content Manager', style: AppTheme.of(context).bodyText2.override( @@ -436,28 +412,13 @@ class _VideosLayoutState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.energy_savings_leaf, - color: Color(0xFF0B0B0D), - size: 32, - ), + // Logo de EnergyMedia + Image.asset( + 'assets/images/logo_nh.png', + height: 45, + fit: BoxFit.contain, ), const Gap(12), - Text( - 'EnergyMedia', - style: AppTheme.of(context).title2.override( - fontFamily: 'Poppins', - color: const Color(0xFF0B0B0D), - fontWeight: FontWeight.bold, - ), - ), - const Gap(4), Text( 'Content Manager', style: AppTheme.of(context).bodyText2.override( diff --git a/lib/providers/videos_provider.dart b/lib/providers/videos_provider.dart index 1b94566..fc3d81f 100644 --- a/lib/providers/videos_provider.dart +++ b/lib/providers/videos_provider.dart @@ -131,24 +131,42 @@ class VideosProvider extends ChangeNotifier { videosRows.add( PlutoRow( cells: { - 'id': PlutoCell(value: media.mediaFileId), - 'thumbnail': - PlutoCell(value: media.fileUrl), // Para mostrar thumbnail + 'video': PlutoCell(value: media), // Objeto completo para renderers + 'thumbnail': PlutoCell(value: media.fileUrl), 'title': PlutoCell(value: media.title ?? media.fileName), - 'description': PlutoCell(value: media.fileDescription ?? ''), + 'file_description': PlutoCell(value: media.fileDescription), 'category': PlutoCell(value: _getCategoryName(media.mediaCategoryFk)), 'reproducciones': PlutoCell(value: media.reproducciones), - 'duration': PlutoCell(value: media.seconds ?? 0), - 'size': PlutoCell(value: _formatFileSize(media.fileSizeBytes)), - 'created_at': PlutoCell(value: media.createdAt), - 'actions': PlutoCell(value: media.mediaFileId), + 'duration': PlutoCell( + value: media.seconds != null + ? _formatDuration(media.seconds!) + : '-'), + 'createdAt': PlutoCell( + value: media.createdAt?.toString().split('.')[0] ?? '-'), + 'actions': PlutoCell(value: media), }, ), ); } } + /// Format duration in seconds to human readable + String _formatDuration(int seconds) { + final duration = Duration(seconds: seconds); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final secs = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '${hours}h ${minutes}m'; + } else if (minutes > 0) { + return '${minutes}m ${secs}s'; + } else { + return '${secs}s'; + } + } + /// Get category name by ID String _getCategoryName(int? categoryId) { if (categoryId == null) return 'Sin categoría'; @@ -258,21 +276,24 @@ class VideosProvider extends ChangeNotifier { .from('energymedia') .getPublicUrl(videoStoragePath!); - // 3. Upload poster if exists - int? posterFileId; + // 3. Upload poster if exists (solo storage, no DB) + String? posterUrlUploaded; if (webPosterBytes != null && posterName != null) { - posterFileId = await _uploadPoster(); + posterUrlUploaded = await _uploadPoster(); } - // 4. Create media_files record + // 4. Create media_files record (UN SOLO REGISTRO con poster en metadata_json) final metadataJson = { 'uploaded_at': DateTime.now().toIso8601String(), 'reproducciones': 0, 'original_file_name': videoName, 'duration_seconds': durationSeconds, + 'file_size_bytes': webVideoBytes!.length, // Peso del video + if (posterUrlUploaded != null) 'poster_url': posterUrlUploaded, + if (posterUrlUploaded != null) 'poster_file_name': posterName, }; - final response = await supabaseML.from('media_files').insert({ + await supabaseML.from('media_files').insert({ 'file_name': fileName, 'title': title, 'file_description': description, @@ -288,16 +309,7 @@ class VideosProvider extends ChangeNotifier { 'seconds': durationSeconds, 'is_public_file': true, 'uploaded_by_user_id': currentUser?.id, - }).select(); - - // 5. Create poster relationship if exists - if (posterFileId != null && response.isNotEmpty) { - final mediaFileId = response[0]['media_file_id']; - await supabaseML.from('media_posters').insert({ - 'media_file_id': mediaFileId, - 'poster_file_id': posterFileId, - }); - } + }); // Clean up _clearUploadState(); @@ -317,8 +329,9 @@ class VideosProvider extends ChangeNotifier { } } - /// Upload poster image (internal helper) - Future _uploadPoster() async { + /// Upload poster image to storage only (NO database record) + /// Returns the public URL of the uploaded poster + Future _uploadPoster() async { if (webPosterBytes == null || posterName == null) return null; try { @@ -326,6 +339,7 @@ class VideosProvider extends ChangeNotifier { final fileName = '${timestamp}_$posterName'; posterStoragePath = 'imagenes/$fileName'; + // Solo subir al storage, NO crear registro en media_files await supabaseML.storage.from('energymedia').uploadBinary( posterStoragePath!, webPosterBytes!, @@ -335,26 +349,12 @@ class VideosProvider extends ChangeNotifier { ), ); + // Obtener URL pública del poster posterUrl = supabaseML.storage .from('energymedia') .getPublicUrl(posterStoragePath!); - // Create media_files record for poster - final response = await supabaseML.from('media_files').insert({ - 'file_name': fileName, - 'title': 'Poster', - 'file_type': 'image', - 'mime_type': _getMimeType(posterFileExtension), - 'file_extension': posterFileExtension, - 'file_size_bytes': webPosterBytes!.length, - 'file_url': posterUrl, - 'storage_path': posterStoragePath, - 'organization_fk': organizationId, - 'is_public_file': true, - 'uploaded_by_user_id': currentUser?.id, - }).select(); - - return response[0]['media_file_id'] as int; + return posterUrl; // Retornar solo la URL, no el ID } catch (e) { print('Error en _uploadPoster: $e'); return null; @@ -500,23 +500,30 @@ class VideosProvider extends ChangeNotifier { .single(); final storagePath = response['storage_path'] as String?; + final metadataJson = response['metadata_json'] as Map?; - // Delete from storage if path exists + // Delete video from storage if path exists if (storagePath != null) { await supabaseML.storage.from('energymedia').remove([storagePath]); } - // Delete associated posters - final posters = await supabaseML - .from('media_posters') - .select('poster_file_id') - .eq('media_file_id', mediaFileId); + // Delete poster from storage if exists in metadata_json + if (metadataJson != null && metadataJson['poster_url'] != null) { + final posterUrl = metadataJson['poster_url'] as String; + // Extraer el path del storage desde la URL + // URL format: https://xxx.supabase.co/storage/v1/object/public/energymedia/imagenes/filename.png + final uri = Uri.parse(posterUrl); + final pathSegments = uri.pathSegments; - for (var poster in posters) { - await _deletePosterFile(poster['poster_file_id']); + // Encontrar el índice después de 'energymedia' y construir el path + final bucketIndex = pathSegments.indexOf('energymedia'); + if (bucketIndex != -1 && bucketIndex < pathSegments.length - 1) { + final posterPath = pathSegments.sublist(bucketIndex + 1).join('/'); + await supabaseML.storage.from('energymedia').remove([posterPath]); + } } - // Delete database record (cascade will delete posters relationship) + // Delete database record await supabaseML .from('media_files') .delete() @@ -537,30 +544,6 @@ class VideosProvider extends ChangeNotifier { } } - /// Delete poster file (internal helper) - Future _deletePosterFile(int posterFileId) async { - try { - final response = await supabaseML - .from('media_files') - .select('storage_path') - .eq('media_file_id', posterFileId) - .single(); - - final storagePath = response['storage_path'] as String?; - - if (storagePath != null) { - await supabaseML.storage.from('energymedia').remove([storagePath]); - } - - await supabaseML - .from('media_files') - .delete() - .eq('media_file_id', posterFileId); - } catch (e) { - print('Error en _deletePosterFile: $e'); - } - } - // ========== ANALYTICS METHODS ========== /// Increment view count @@ -659,17 +642,20 @@ class VideosProvider extends ChangeNotifier { videosRows.add( PlutoRow( cells: { - 'id': PlutoCell(value: media.mediaFileId), + 'video': PlutoCell(value: media), 'thumbnail': PlutoCell(value: media.fileUrl), 'title': PlutoCell(value: media.title ?? media.fileName), - 'description': PlutoCell(value: media.fileDescription ?? ''), + 'fileName': PlutoCell(value: media.fileName), 'category': PlutoCell(value: _getCategoryName(media.mediaCategoryFk)), 'reproducciones': PlutoCell(value: media.reproducciones), - 'duration': PlutoCell(value: media.seconds ?? 0), - 'size': PlutoCell(value: _formatFileSize(media.fileSizeBytes)), - 'created_at': PlutoCell(value: media.createdAt), - 'actions': PlutoCell(value: media.mediaFileId), + 'duration': PlutoCell( + value: media.seconds != null + ? _formatDuration(media.seconds!) + : '-'), + 'createdAt': PlutoCell( + value: media.createdAt?.toString().split('.')[0] ?? '-'), + 'actions': PlutoCell(value: media), }, ), );