Diseño mejorado

This commit is contained in:
Abraham
2026-01-12 23:27:56 -08:00
parent d1271a5578
commit 40a9be5936
7 changed files with 1849 additions and 546 deletions

View File

@@ -1,8 +1,6 @@
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';
@@ -10,6 +8,9 @@ import 'package:nethive_neo/helpers/globals.dart';
import 'package:nethive_neo/widgets/premium_button.dart';
import 'package:nethive_neo/pages/videos/widgets/premium_upload_dialog.dart';
import 'package:nethive_neo/pages/videos/widgets/video_player_dialog.dart';
import 'package:nethive_neo/pages/videos/widgets/gestor_videos_widgets/empty_state_widget.dart';
import 'package:nethive_neo/pages/videos/widgets/gestor_videos_widgets/edit_video_dialog.dart';
import 'package:nethive_neo/pages/videos/widgets/gestor_videos_widgets/delete_video_dialog.dart';
import 'package:gap/gap.dart';
class GestorVideosPage extends StatefulWidget {
@@ -69,7 +70,34 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
Expanded(
child: Padding(
padding: const EdgeInsets.all(24),
child: _buildPlutoGrid(provider),
child: Container(
decoration: BoxDecoration(
color: AppTheme.of(context).secondaryBackground,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.of(context).primaryColor.withOpacity(0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppTheme.of(context).primaryColor.withOpacity(0.08),
blurRadius: 24,
offset: const Offset(0, 8),
spreadRadius: 0,
),
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 12,
offset: const Offset(0, 4),
spreadRadius: 0,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: _buildPlutoGrid(provider),
),
),
),
),
],
@@ -82,7 +110,9 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
_buildToolbar(provider, true),
Expanded(
child: provider.mediaFiles.isEmpty
? _buildEmptyState()
? EmptyStateWidget(
onUploadPressed: () => _showUploadDialog(provider),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: provider.mediaFiles.length,
@@ -245,7 +275,7 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
title: 'Vista Previa',
field: 'thumbnail',
type: PlutoColumnType.text(),
width: 120,
width: 150,
enableColumnDrag: false,
enableSorting: false,
enableContextMenu: false,
@@ -258,28 +288,63 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
final posterUrl = video.posterUrl;
return Container(
margin: const EdgeInsets.all(4),
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(10),
color: AppTheme.of(context).tertiaryBackground,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: posterUrl != null && posterUrl.isNotEmpty
? Image.network(
posterUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.video_library,
size: 32,
color: AppTheme.of(context).tertiaryText,
borderRadius: BorderRadius.circular(10),
child: Stack(
children: [
posterUrl != null && posterUrl.isNotEmpty
? Image.network(
posterUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) =>
_buildThumbnailPlaceholder(),
)
: _buildThumbnailPlaceholder(),
// Overlay con icono de play
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.3),
],
),
),
child: Center(
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
shape: BoxShape.circle,
),
child: Icon(
Icons.play_arrow_rounded,
size: 16,
color: AppTheme.of(context).primaryColor,
),
),
),
)
: Icon(
Icons.video_library,
size: 32,
color: AppTheme.of(context).tertiaryText,
),
),
],
),
),
);
},
@@ -288,44 +353,119 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
title: 'Título',
field: 'title',
type: PlutoColumnType.text(),
width: 250,
width: 350,
),
PlutoColumn(
title: 'Descripción',
field: 'file_description',
type: PlutoColumnType.text(),
width: 200,
width: 400,
),
PlutoColumn(
title: 'Categoría',
field: 'category',
type: PlutoColumnType.text(),
width: 150,
width: 160,
renderer: (rendererContext) {
final category = rendererContext.cell.value?.toString() ?? '';
if (category.isEmpty) return const SizedBox();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppTheme.of(context).primaryColor,
AppTheme.of(context).primaryColor.withOpacity(0.7),
],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppTheme.of(context).primaryColor.withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Text(
category.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
);
},
),
PlutoColumn(
title: 'Reproducciones',
field: 'reproducciones',
type: PlutoColumnType.number(),
width: 120,
width: 160,
textAlign: PlutoColumnTextAlign.center,
renderer: (rendererContext) {
final count = rendererContext.cell.value ?? 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: AppTheme.of(context).success.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppTheme.of(context).success.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.visibility_rounded,
size: 14,
color: AppTheme.of(context).success,
),
const SizedBox(width: 6),
Text(
count.toString(),
style: TextStyle(
color: AppTheme.of(context).success,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
},
),
PlutoColumn(
title: 'Duración',
field: 'duration',
type: PlutoColumnType.text(),
width: 100,
width: 180,
),
PlutoColumn(
title: 'Fecha de Creación',
field: 'createdAt',
type: PlutoColumnType.text(),
width: 150,
width: 180,
),
PlutoColumn(
title: 'Etiquetas',
field: 'tags',
type: PlutoColumnType.text(),
width: 180,
width: 250,
renderer: (rendererContext) {
final video =
rendererContext.row.cells['video']?.value as MediaFileModel?;
@@ -364,7 +504,7 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
title: 'Acciones',
field: 'actions',
type: PlutoColumnType.text(),
width: 140,
width: 160,
enableColumnDrag: false,
enableSorting: false,
enableContextMenu: false,
@@ -376,23 +516,25 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.play_circle_outline, size: 20),
color: const Color(0xFF4EC9F5),
_buildActionButton(
icon: Icons.play_arrow_rounded,
color: AppTheme.of(context).primaryColor,
tooltip: 'Reproducir',
onPressed: () => _playVideo(video),
),
IconButton(
icon: const Icon(Icons.edit, size: 20),
color: const Color(0xFFFFB733),
const SizedBox(width: 8),
_buildActionButton(
icon: Icons.edit_rounded,
color: AppTheme.of(context).secondaryColor,
tooltip: 'Editar',
onPressed: () => _editVideo(video, provider),
onPressed: () => _editVideoDialog(video, provider),
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
color: const Color(0xFFFF2D2D),
const SizedBox(width: 8),
_buildActionButton(
icon: Icons.delete_rounded,
color: AppTheme.of(context).error,
tooltip: 'Eliminar',
onPressed: () => _deleteVideo(video, provider),
onPressed: () => _deleteVideoDialog(video, provider),
),
],
);
@@ -409,6 +551,57 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
},
configuration: PlutoGridConfiguration(
style: plutoGridStyleConfig(context),
columnSize: const PlutoGridColumnSizeConfig(
autoSizeMode: PlutoAutoSizeMode.none,
),
),
);
}
Widget _buildActionButton({
required IconData icon,
required Color color,
required String tooltip,
required VoidCallback onPressed,
}) {
return Tooltip(
message: tooltip,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(10),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withOpacity(0.2),
width: 1,
),
),
child: Icon(
icon,
size: 18,
color: color,
),
),
),
),
);
}
Widget _buildThumbnailPlaceholder() {
return Container(
color: AppTheme.of(context).tertiaryBackground,
child: Center(
child: Icon(
Icons.video_library_rounded,
size: 28,
color: AppTheme.of(context).tertiaryText,
),
),
);
}
@@ -535,7 +728,7 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
),
),
TextButton.icon(
onPressed: () => _editVideo(video, provider),
onPressed: () => _editVideoDialog(video, provider),
icon: const Icon(Icons.edit, size: 18),
label: const Text('Editar'),
style: TextButton.styleFrom(
@@ -543,7 +736,7 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
),
),
TextButton.icon(
onPressed: () => _deleteVideo(video, provider),
onPressed: () => _deleteVideoDialog(video, provider),
icon: const Icon(Icons.delete, size: 18),
label: const Text('Eliminar'),
style: TextButton.styleFrom(
@@ -560,56 +753,6 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.video_library_outlined,
size: 80,
color: AppTheme.of(context).tertiaryText,
),
const Gap(16),
Text(
'No hay videos disponibles',
style: AppTheme.of(context).title2.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).primaryText,
fontWeight: FontWeight.bold,
),
),
const Gap(8),
Text(
'Sube tu primer video para comenzar',
style: AppTheme.of(context).bodyText1.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).tertiaryText,
),
),
const Gap(24),
ElevatedButton.icon(
onPressed: () {
final provider =
Provider.of<VideosProvider>(context, listen: false);
_showUploadDialog(provider);
},
icon: const Icon(Icons.upload_file),
label: const Text('Subir Video'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.of(context).primaryColor,
foregroundColor: const Color(0xFF0B0B0D),
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
),
),
],
),
);
}
Future<void> _showUploadDialog(VideosProvider provider) async {
await showDialog(
context: context,
@@ -653,271 +796,9 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
);
}
Future<void> _editVideo(MediaFileModel video, VideosProvider provider) async {
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(
builder: (context, setDialogState) => AlertDialog(
backgroundColor: AppTheme.of(context).secondaryBackground,
title: Row(
children: [
Icon(
Icons.edit,
color: AppTheme.of(context).primaryColor,
),
const Gap(12),
Expanded(
child: Text(
'Editar Video',
style: AppTheme.of(context).title2.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).primaryText,
fontWeight: FontWeight.bold,
),
),
),
],
),
content: SizedBox(
width: 500,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: titleController,
decoration: InputDecoration(
labelText: 'Título',
filled: true,
fillColor: AppTheme.of(context).tertiaryBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const Gap(16),
TextFormField(
controller: descriptionController,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Descripción',
filled: true,
fillColor: AppTheme.of(context).tertiaryBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
const Gap(16),
DropdownButtonFormField<MediaCategoryModel>(
value: selectedCategory,
decoration: InputDecoration(
labelText: 'Categoría',
filled: true,
fillColor: AppTheme.of(context).tertiaryBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
items: provider.categories.map((category) {
return DropdownMenuItem(
value: category,
child: Text(category.categoryName),
);
}).toList(),
onChanged: (value) {
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,
),
),
],
],
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancelar',
style: TextStyle(
color: AppTheme.of(context).secondaryText,
),
),
),
ElevatedButton(
onPressed: () async {
// Actualizar campos
if (titleController.text != video.title) {
await provider.updateVideoTitle(
video.mediaFileId,
titleController.text,
);
}
if (descriptionController.text != video.fileDescription) {
await provider.updateVideoDescription(
video.mediaFileId,
descriptionController.text,
);
}
if (selectedCategory != null &&
selectedCategory!.mediaCategoriesId !=
video.mediaCategoryFk) {
await provider.updateVideoCategory(
video.mediaFileId,
selectedCategory!.mediaCategoriesId,
);
}
// 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(
backgroundColor: AppTheme.of(context).primaryColor,
foregroundColor: const Color(0xFF0B0B0D),
),
child: const Text('Guardar'),
),
],
),
),
);
Future<void> _editVideoDialog(
MediaFileModel video, VideosProvider provider) async {
final result = await EditVideoDialog.show(context, video, provider);
// Manejar resultado después de cerrar el diálogo
if (result == true) {
@@ -934,57 +815,9 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
}
}
Future<void> _deleteVideo(
Future<void> _deleteVideoDialog(
MediaFileModel video, VideosProvider provider) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppTheme.of(context).secondaryBackground,
title: Row(
children: [
const Icon(
Icons.warning,
color: Color(0xFFFF2D2D),
),
const Gap(12),
Text(
'Confirmar Eliminación',
style: AppTheme.of(context).title2.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).primaryText,
fontWeight: FontWeight.bold,
),
),
],
),
content: Text(
'¿Estás seguro de que deseas eliminar "${video.title ?? video.fileName}"? Esta acción no se puede deshacer.',
style: AppTheme.of(context).bodyText1.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).secondaryText,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancelar',
style: TextStyle(
color: AppTheme.of(context).secondaryText,
),
),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF2D2D),
foregroundColor: Colors.white,
),
child: const Text('Eliminar'),
),
],
),
);
final confirm = await DeleteVideoDialog.show(context, video, provider);
if (confirm == true) {
final success = await provider.deleteVideo(video.mediaFileId);