Tags añadidos

This commit is contained in:
Abraham
2026-01-12 18:40:28 -08:00
parent a9214e9eac
commit d1271a5578
4 changed files with 305 additions and 1 deletions

View File

@@ -158,4 +158,11 @@ class MediaFileModel {
String? get posterUrl => metadataJson?['poster_url'];
String? get posterFileName => metadataJson?['poster_file_name'];
int? get fileSizeBytesFromMetadata => metadataJson?['file_size_bytes'];
// Tags from metadata_json
List<String> get tags =>
(metadataJson?['tags'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[];
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:pluto_grid/pluto_grid.dart';
import 'package:provider/provider.dart';
import 'package:image_picker/image_picker.dart';
import 'package:nethive_neo/providers/videos_provider.dart';
import 'package:nethive_neo/models/media/media_models.dart';
import 'package:nethive_neo/theme/theme.dart';
@@ -319,6 +321,45 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
type: PlutoColumnType.text(),
width: 150,
),
PlutoColumn(
title: 'Etiquetas',
field: 'tags',
type: PlutoColumnType.text(),
width: 180,
renderer: (rendererContext) {
final video =
rendererContext.row.cells['video']?.value as MediaFileModel?;
if (video == null || video.tags.isEmpty) return const SizedBox();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: video.tags.take(3).map((tag) {
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)],
),
borderRadius: BorderRadius.circular(12),
),
child: Text(
tag,
style: const TextStyle(
color: Color(0xFF0B0B0D),
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
);
}).toList(),
),
);
},
),
PlutoColumn(
title: 'Acciones',
field: 'actions',
@@ -616,10 +657,14 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
final titleController = TextEditingController(text: video.title);
final descriptionController =
TextEditingController(text: video.fileDescription);
final tagsController = TextEditingController(text: video.tags.join(', '));
MediaCategoryModel? selectedCategory = provider.categories
.where((cat) => cat.mediaCategoriesId == video.mediaCategoryFk)
.firstOrNull;
Uint8List? newPosterBytes;
String? newPosterFileName;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
@@ -699,6 +744,105 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
setDialogState(() => selectedCategory = value);
},
),
const Gap(16),
TextFormField(
controller: tagsController,
decoration: InputDecoration(
labelText: 'Etiquetas (Tags)',
hintText: 'Ej: deportes, fútbol, entrenamiento',
helperText: 'Separa las etiquetas con comas o espacios',
prefixIcon: Icon(
Icons.label,
color: AppTheme.of(context).primaryColor,
),
filled: true,
fillColor: AppTheme.of(context).tertiaryBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const Gap(24),
// Sección de portada
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Portada Actual',
style: AppTheme.of(context).bodyText1.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).primaryText,
fontWeight: FontWeight.w600,
),
),
const Gap(12),
if (video.posterUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
video.posterUrl!,
height: 120,
width: double.infinity,
fit: BoxFit.cover,
),
)
else
Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
color: AppTheme.of(context).tertiaryBackground,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.video_library,
size: 48,
color: AppTheme.of(context).secondaryText,
),
),
const Gap(12),
ElevatedButton.icon(
onPressed: () async {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1920,
imageQuality: 85,
);
if (image != null) {
final bytes = await image.readAsBytes();
setDialogState(() {
newPosterBytes = bytes;
newPosterFileName = image.name;
});
}
},
icon: const Icon(Icons.image),
label: Text(newPosterBytes != null
? 'Portada Seleccionada'
: 'Cambiar Portada'),
style: ElevatedButton.styleFrom(
backgroundColor: newPosterBytes != null
? AppTheme.of(context).alternate
: AppTheme.of(context).primaryColor,
foregroundColor: const Color(0xFF0B0B0D),
),
),
if (newPosterBytes != null) ...[
const Gap(12),
Text(
'Nueva portada: $newPosterFileName',
style: AppTheme.of(context).bodyText2.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).alternate,
fontSize: 12,
),
),
],
],
),
],
),
),
@@ -739,6 +883,29 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
);
}
// Actualizar tags
final newTags = tagsController.text
.split(RegExp(r'[,\s]+'))
.map((tag) => tag.trim())
.where((tag) => tag.isNotEmpty)
.toList();
if (newTags.join(',') != video.tags.join(',')) {
await provider.updateVideoTags(
video.mediaFileId,
newTags,
);
}
// Actualizar portada si se seleccionó una nueva
if (newPosterBytes != null && newPosterFileName != null) {
await provider.updateVideoPoster(
video.mediaFileId,
newPosterBytes!,
newPosterFileName!,
);
}
Navigator.pop(context, true);
},
style: ElevatedButton.styleFrom(

View File

@@ -24,6 +24,7 @@ class PremiumUploadDialog extends StatefulWidget {
class _PremiumUploadDialogState extends State<PremiumUploadDialog> {
final titleController = TextEditingController();
final descriptionController = TextEditingController();
final tagsController = TextEditingController();
MediaCategoryModel? selectedCategory;
Uint8List? selectedVideo;
String? videoFileName;
@@ -36,6 +37,7 @@ class _PremiumUploadDialogState extends State<PremiumUploadDialog> {
void dispose() {
titleController.dispose();
descriptionController.dispose();
tagsController.dispose();
_videoController?.dispose();
super.dispose();
}
@@ -84,12 +86,23 @@ class _PremiumUploadDialogState extends State<PremiumUploadDialog> {
setState(() => isUploading = true);
// Procesar tags: separar por comas o espacios
List<String>? tags;
if (tagsController.text.isNotEmpty) {
tags = tagsController.text
.split(RegExp(r'[,\s]+')) // Separar por comas o espacios
.map((tag) => tag.trim())
.where((tag) => tag.isNotEmpty)
.toList();
}
final success = await widget.provider.uploadVideo(
title: titleController.text,
description: descriptionController.text.isEmpty
? null
: descriptionController.text,
categoryId: selectedCategory!.mediaCategoriesId,
tags: tags,
);
if (!mounted) return;
@@ -285,6 +298,23 @@ class _PremiumUploadDialogState extends State<PremiumUploadDialog> {
_buildLabel('Categoría *'),
const Gap(8),
_buildCategoryDropdown(),
const Gap(20),
_buildLabel('Etiquetas (Tags)'),
const Gap(4),
Text(
'Separa las etiquetas con comas o espacios',
style: AppTheme.of(context).bodyText2.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).tertiaryText,
fontSize: 12,
),
),
const Gap(8),
_buildTextField(
controller: tagsController,
hintText: 'Ej: deportes, fútbol, entrenamiento',
prefixIcon: Icons.label,
),
],
);
}

View File

@@ -144,6 +144,7 @@ class VideosProvider extends ChangeNotifier {
: '-'),
'createdAt': PlutoCell(
value: media.createdAt?.toString().split('.')[0] ?? '-'),
'tags': PlutoCell(value: media.tags.join(', ')),
'actions': PlutoCell(value: media),
},
),
@@ -246,6 +247,7 @@ class VideosProvider extends ChangeNotifier {
String? description,
int? categoryId,
int? durationSeconds,
List<String>? tags,
}) async {
if (webVideoBytes == null || videoName == null) {
errorMessage = 'No hay video seleccionado';
@@ -291,6 +293,7 @@ class VideosProvider extends ChangeNotifier {
'file_size_bytes': webVideoBytes!.length, // Peso del video
if (posterUrlUploaded != null) 'poster_url': posterUrlUploaded,
if (posterUrlUploaded != null) 'poster_file_name': posterName,
if (tags != null && tags.isNotEmpty) 'tags': tags,
};
await supabaseML.from('media_files').insert({
@@ -483,6 +486,102 @@ class VideosProvider extends ChangeNotifier {
}
}
/// Update video tags
Future<bool> updateVideoTags(int mediaFileId, List<String> tags) async {
try {
// Get current metadata
final response = await supabaseML
.from('media_files')
.select('metadata_json')
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId)
.single();
final metadata = response['metadata_json'] as Map<String, dynamic>? ?? {};
metadata['tags'] = tags;
await updateVideoMetadata(mediaFileId, metadata);
return true;
} catch (e) {
errorMessage = 'Error actualizando tags: $e';
notifyListeners();
print('Error en updateVideoTags: $e');
return false;
}
}
/// Update video poster
Future<bool> updateVideoPoster(
int mediaFileId, Uint8List posterBytes, String posterName) async {
try {
isLoading = true;
notifyListeners();
// Upload new poster to storage
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = '${timestamp}_$posterName';
final posterPath = 'imagenes/$fileName';
await supabaseML.storage.from('energymedia').uploadBinary(
posterPath,
posterBytes,
fileOptions: const FileOptions(
cacheControl: '3600',
upsert: false,
),
);
// Get public URL
final newPosterUrl =
supabaseML.storage.from('energymedia').getPublicUrl(posterPath);
// Get current metadata and old poster URL
final response = await supabaseML
.from('media_files')
.select('metadata_json')
.eq('media_file_id', mediaFileId)
.eq('organization_fk', organizationId)
.single();
final metadata = response['metadata_json'] as Map<String, dynamic>? ?? {};
final oldPosterUrl = metadata['poster_url'] as String?;
// Delete old poster from storage if exists
if (oldPosterUrl != null && oldPosterUrl.isNotEmpty) {
try {
final uri = Uri.parse(oldPosterUrl);
final pathSegments = uri.pathSegments;
final bucketIndex = pathSegments.indexOf('energymedia');
if (bucketIndex != -1 && bucketIndex < pathSegments.length - 1) {
final oldPosterPath =
pathSegments.sublist(bucketIndex + 1).join('/');
await supabaseML.storage
.from('energymedia')
.remove([oldPosterPath]);
}
} catch (e) {
print('Error eliminando poster antiguo: $e');
}
}
// Update metadata with new poster info
metadata['poster_url'] = newPosterUrl;
metadata['poster_file_name'] = posterName;
await updateVideoMetadata(mediaFileId, metadata);
isLoading = false;
notifyListeners();
return true;
} catch (e) {
errorMessage = 'Error actualizando poster: $e';
isLoading = false;
notifyListeners();
print('Error en updateVideoPoster: $e');
return false;
}
}
// ========== DELETE METHODS ==========
/// Delete video and its storage files
@@ -645,7 +744,7 @@ class VideosProvider extends ChangeNotifier {
'video': PlutoCell(value: media),
'thumbnail': PlutoCell(value: media.fileUrl),
'title': PlutoCell(value: media.title ?? media.fileName),
'fileName': PlutoCell(value: media.fileName),
'file_description': PlutoCell(value: media.fileDescription),
'category':
PlutoCell(value: _getCategoryName(media.mediaCategoryFk)),
'reproducciones': PlutoCell(value: media.reproducciones),
@@ -655,6 +754,7 @@ class VideosProvider extends ChangeNotifier {
: '-'),
'createdAt': PlutoCell(
value: media.createdAt?.toString().split('.')[0] ?? '-'),
'tags': PlutoCell(value: media.tags.join(', ')),
'actions': PlutoCell(value: media),
},
),