diff --git a/lib/pages/videos/premium_dashboard_page.dart b/lib/pages/videos/premium_dashboard_page.dart index 3cac461..6bf24b2 100644 --- a/lib/pages/videos/premium_dashboard_page.dart +++ b/lib/pages/videos/premium_dashboard_page.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:fl_chart/fl_chart.dart'; import 'package:shimmer/shimmer.dart'; import 'package:energy_media/providers/videos_provider.dart'; import 'package:energy_media/theme/theme.dart'; import 'package:gap/gap.dart'; +// Importar los widgets modulares +import 'package:energy_media/pages/videos/widgets/premium_dashboard_widgets/premium_dashboard_widgets.dart'; + class PremiumDashboardPage extends StatefulWidget { const PremiumDashboardPage({Key? key}) : super(key: key); @@ -48,9 +50,9 @@ class _PremiumDashboardPageState extends State Future _loadStats() async { final provider = Provider.of(context, listen: false); - final result = await provider.getDashboardStats(); + final result = await provider.getVideoMetrics(); setState(() { - stats = result; + stats = result ?? {}; isLoading = false; }); } @@ -116,38 +118,51 @@ class _PremiumDashboardPageState extends State final totalVideos = stats['total_videos'] ?? 0; final totalViews = stats['total_reproducciones'] ?? 0; - final avgViews = totalVideos > 0 ? (totalViews / totalVideos).round() : 0; + final avgViewsPerDay = stats['promedio_reproducciones_por_dia'] ?? 0.0; + + // Datos de ejemplo para sparkline (podrías obtenerlos de la BD) + final sparklineData1 = [45.0, 52.0, 48.0, 65.0, 59.0, 72.0, 78.0]; + final sparklineData2 = [120.0, 145.0, 132.0, 168.0, 175.0, 190.0, 210.0]; + final sparklineData3 = [25.0, 28.0, 24.0, 32.0, 30.0, 35.0, 38.0]; final cards = [ - _StatCard( - title: 'Total Videos', - value: totalVideos.toString(), - icon: Icons.video_library, - gradient: const LinearGradient( - colors: [Color(0xFF4EC9F5), Color(0xFF2E8BC0)], + PremiumStatCard( + data: StatCardData( + title: 'Total Videos', + value: totalVideos, + icon: Icons.video_library_rounded, + gradientColors: const [Color(0xFF4EC9F5), Color(0xFF2E8BC0)], + trend: '+12%', + trendUp: true, ), - trend: '+12%', - trendUp: true, + showSparkline: true, + sparklineData: sparklineData1, ), - _StatCard( - title: 'Reproducciones', - value: _formatNumber(totalViews), - icon: Icons.play_circle_filled, - gradient: const LinearGradient( - colors: [Color(0xFFFFB733), Color(0xFFFF8A00)], + PremiumStatCard( + data: StatCardData( + title: 'Reproducciones', + value: totalViews, + icon: Icons.play_circle_filled_rounded, + gradientColors: const [Color(0xFFFFB733), Color(0xFFFF8A00)], + trend: '+23%', + trendUp: true, ), - trend: '+23%', - trendUp: true, + showSparkline: true, + sparklineData: sparklineData2, ), - _StatCard( - title: 'Promedio Vistas', - value: _formatNumber(avgViews), - icon: Icons.trending_up, - gradient: const LinearGradient( - colors: [Color(0xFF00C9A7), Color(0xFF00B894)], + PremiumStatCard( + data: StatCardData( + title: 'Promedio/Día', + value: avgViewsPerDay is num + ? avgViewsPerDay.toInt() + : (avgViewsPerDay as double).toInt(), + icon: Icons.trending_up_rounded, + gradientColors: const [Color(0xFF00C9A7), Color(0xFF00B894)], + trend: '+8%', + trendUp: true, ), - trend: '+8%', - trendUp: true, + showSparkline: true, + sparklineData: sparklineData3, ), ]; @@ -168,356 +183,76 @@ class _PremiumDashboardPageState extends State physics: const NeverScrollableScrollPhysics(), crossAxisSpacing: 20, mainAxisSpacing: 20, - childAspectRatio: 2.2, + childAspectRatio: 1.8, children: cards, ); } Widget _buildLoadingSkeleton(bool isMobile) { + if (isMobile) { + // En mobile usar Column en lugar de GridView para evitar distorsión + return Column( + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Shimmer.fromColors( + baseColor: AppTheme.of(context).tertiaryBackground, + highlightColor: AppTheme.of(context).secondaryBackground, + child: const StatCardSkeleton(isMobile: true), + ), + ), + ), + ); + } + return Shimmer.fromColors( baseColor: AppTheme.of(context).tertiaryBackground, highlightColor: AppTheme.of(context).secondaryBackground, child: GridView.count( - crossAxisCount: isMobile ? 1 : 3, + crossAxisCount: 3, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), crossAxisSpacing: 20, mainAxisSpacing: 20, - childAspectRatio: isMobile ? 3 : 1.5, + childAspectRatio: 1.8, children: List.generate( 3, - (index) => Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - ), + (index) => const StatCardSkeleton(), ), ), ); } Widget _buildViewsChart() { - return Container( - padding: const EdgeInsets.all(28), - decoration: BoxDecoration( - color: AppTheme.of(context).secondaryBackground, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppTheme.of(context).primaryColor.withOpacity(0.1), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 20, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFFFFB733).withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.bar_chart, - color: Color(0xFFFFB733), - size: 24, - ), - ), - const Gap(12), - Text( - 'Reproducciones Semanales', - style: AppTheme.of(context).title3.override( - fontFamily: 'Poppins', - color: AppTheme.of(context).primaryText, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ], - ), - const Gap(32), - SizedBox( - height: 200, - child: BarChart( - BarChartData( - alignment: BarChartAlignment.spaceAround, - maxY: 100, - barTouchData: BarTouchData(enabled: true), - titlesData: FlTitlesData( - show: true, - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) { - const days = ['L', 'M', 'X', 'J', 'V', 'S', 'D']; - return Text( - days[value.toInt() % 7], - style: TextStyle( - color: AppTheme.of(context).tertiaryText, - fontSize: 12, - fontFamily: 'Poppins', - ), - ); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) { - return Text( - value.toInt().toString(), - style: TextStyle( - color: AppTheme.of(context).tertiaryText, - fontSize: 12, - fontFamily: 'Poppins', - ), - ); - }, - ), - ), - topTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - ), - borderData: FlBorderData(show: false), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (value) { - return FlLine( - color: AppTheme.of(context).tertiaryText.withOpacity(0.1), - strokeWidth: 1, - ); - }, - ), - barGroups: [ - BarChartGroupData(x: 0, barRods: [ - BarChartRodData( - toY: 65, - gradient: const LinearGradient( - colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)], - ), - width: 20, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), - ) - ]), - BarChartGroupData(x: 1, barRods: [ - BarChartRodData( - toY: 80, - gradient: const LinearGradient( - colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)], - ), - width: 20, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), - ) - ]), - BarChartGroupData(x: 2, barRods: [ - BarChartRodData( - toY: 45, - gradient: const LinearGradient( - colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)], - ), - width: 20, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), - ) - ]), - BarChartGroupData(x: 3, barRods: [ - BarChartRodData( - toY: 90, - gradient: const LinearGradient( - colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)], - ), - width: 20, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), - ) - ]), - BarChartGroupData(x: 4, barRods: [ - BarChartRodData( - toY: 75, - gradient: const LinearGradient( - colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)], - ), - width: 20, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), - ) - ]), - BarChartGroupData(x: 5, barRods: [ - BarChartRodData( - toY: 55, - gradient: const LinearGradient( - colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)], - ), - width: 20, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), - ) - ]), - BarChartGroupData(x: 6, barRods: [ - BarChartRodData( - toY: 40, - gradient: const LinearGradient( - colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)], - ), - width: 20, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), - ) - ]), - ], - ), - ), - ), - ], - ), + // Datos de ejemplo - idealmente vendrían de la BD + final chartData = [ + const ChartDataPoint(label: 'Lun', value: 65), + const ChartDataPoint(label: 'Mar', value: 80), + const ChartDataPoint(label: 'Mié', value: 45), + const ChartDataPoint(label: 'Jue', value: 90), + const ChartDataPoint(label: 'Vie', value: 75), + const ChartDataPoint(label: 'Sáb', value: 55), + const ChartDataPoint(label: 'Dom', value: 40), + ]; + + return PremiumViewsChart( + data: chartData, + title: 'Reproducciones Semanales', + icon: Icons.bar_chart_rounded, + iconColor: const Color(0xFFFFB733), ); } Widget _buildTopVideos() { - final provider = Provider.of(context); - final topVideos = provider.mediaFiles.take(5).toList(); + final provider = Provider.of(context, listen: false); - return Container( - padding: const EdgeInsets.all(28), - decoration: BoxDecoration( - color: AppTheme.of(context).secondaryBackground, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppTheme.of(context).primaryColor.withOpacity(0.1), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 20, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF6B2F8A).withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.star, - color: Color(0xFF6B2F8A), - size: 24, - ), - ), - const Gap(12), - Text( - 'Top 5 Videos', - style: AppTheme.of(context).title3.override( - fontFamily: 'Poppins', - color: AppTheme.of(context).primaryText, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ], - ), - const Gap(24), - ...topVideos.asMap().entries.map((entry) { - final index = entry.key; - final video = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - const Color(0xFF4EC9F5), - const Color(0xFFFFB733), - ], - ), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - '${index + 1}', - style: const TextStyle( - color: Color(0xFF0B0B0D), - fontWeight: FontWeight.bold, - fontFamily: 'Poppins', - ), - ), - ), - ), - const Gap(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - video.title ?? video.fileName, - style: AppTheme.of(context).bodyText1.override( - fontFamily: 'Poppins', - color: AppTheme.of(context).primaryText, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - '${video.reproducciones} vistas', - style: AppTheme.of(context).bodyText2.override( - fontFamily: 'Poppins', - color: AppTheme.of(context).tertiaryText, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ); - }).toList(), - ], - ), + return Top5VideosWidget( + loadTopVideos: provider.getTop5VideosByViews, + onVideoTap: (video) { + // Opcional: navegar al video o mostrar detalles + debugPrint('Video tapped: ${video.title}'); + }, ); } @@ -529,14 +264,19 @@ class _PremiumDashboardPageState extends State padding: const EdgeInsets.all(28), decoration: BoxDecoration( color: AppTheme.of(context).secondaryBackground, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(24), border: Border.all( - color: AppTheme.of(context).primaryColor.withOpacity(0.1), + color: AppTheme.of(context).primaryColor.withOpacity(0.08), width: 1, ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: const Color(0xFF00C9A7).withOpacity(0.08), + blurRadius: 30, + offset: const Offset(0, 10), + ), + BoxShadow( + color: Colors.black.withOpacity(0.03), blurRadius: 20, offset: const Offset(0, 4), ), @@ -548,26 +288,54 @@ class _PremiumDashboardPageState extends State Row( children: [ Container( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xFF00C9A7).withOpacity(0.1), - borderRadius: BorderRadius.circular(10), + gradient: LinearGradient( + colors: [ + const Color(0xFF00C9A7).withOpacity(0.2), + const Color(0xFF00C9A7).withOpacity(0.1), + ], + ), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00C9A7).withOpacity(0.2), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), child: const Icon( - Icons.history, + Icons.history_rounded, color: Color(0xFF00C9A7), size: 24, ), ), - const Gap(12), - Text( - 'Actividad Reciente', - style: AppTheme.of(context).title3.override( - fontFamily: 'Poppins', - color: AppTheme.of(context).primaryText, - fontWeight: FontWeight.bold, - fontSize: 18, + const Gap(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Actividad Reciente', + style: AppTheme.of(context).title3.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).primaryText, + fontWeight: FontWeight.bold, + fontSize: 18, + ), ), + const Gap(2), + Text( + 'Últimos videos subidos', + style: AppTheme.of(context).bodyText2.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).tertiaryText, + fontSize: 12, + ), + ), + ], + ), ), ], ), @@ -575,75 +343,125 @@ class _PremiumDashboardPageState extends State ...recentVideos.map((video) { return Container( margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.of(context).tertiaryBackground, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Opcional: navegar al video + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - const Color(0xFF4EC9F5), - const Color(0xFFFFB733), - ], + color: AppTheme.of(context) + .tertiaryBackground + .withOpacity(0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + AppTheme.of(context).primaryColor.withOpacity(0.05), + width: 1, ), - borderRadius: BorderRadius.circular(10), ), - child: const Icon( - Icons.video_library, - color: Color(0xFF0B0B0D), - ), - ), - const Gap(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - video.title ?? video.fileName, - style: AppTheme.of(context).bodyText1.override( - fontFamily: 'Poppins', - color: AppTheme.of(context).primaryText, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + // Thumbnail + Container( + width: 56, + height: 42, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF4EC9F5).withOpacity(0.8), + const Color(0xFFFFB733).withOpacity(0.8), + ], + ), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.video_library_rounded, + color: Color(0xFF0B0B0D), + size: 22, + ), ), - const Gap(4), - Text( - 'Subido hace ${_getTimeAgo(video.createdAt)}', - style: AppTheme.of(context).bodyText2.override( - fontFamily: 'Poppins', - color: AppTheme.of(context).tertiaryText, - fontSize: 12, + const Gap(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + video.title ?? video.fileName, + style: AppTheme.of(context).bodyText1.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).primaryText, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + const Gap(4), + Row( + children: [ + Icon( + Icons.schedule_rounded, + size: 14, + color: AppTheme.of(context).tertiaryText, + ), + const Gap(4), + Text( + 'Subido ${_getTimeAgo(video.createdAt)}', + style: AppTheme.of(context) + .bodyText2 + .override( + fontFamily: 'Poppins', + color: + AppTheme.of(context).tertiaryText, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + // Badge de reproducciones + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF00C9A7).withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFF00C9A7).withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: Color(0xFF00C9A7), + size: 14, + ), + const Gap(4), + Text( + '${video.reproducciones}', + style: const TextStyle( + color: Color(0xFF00C9A7), + fontWeight: FontWeight.bold, + fontSize: 12, + fontFamily: 'Poppins', + ), + ), + ], + ), ), ], ), ), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFF00C9A7).withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '${video.reproducciones}', - style: const TextStyle( - color: Color(0xFF00C9A7), - fontWeight: FontWeight.bold, - fontSize: 12, - fontFamily: 'Poppins', - ), - ), - ), - ], + ), ), ); }).toList(), @@ -652,167 +470,21 @@ class _PremiumDashboardPageState extends State ); } - String _formatNumber(int number) { - if (number >= 1000000) { - return '${(number / 1000000).toStringAsFixed(1)}M'; - } else if (number >= 1000) { - return '${(number / 1000).toStringAsFixed(1)}K'; - } - return number.toString(); - } - String _getTimeAgo(DateTime? timestamp) { if (timestamp == null) return 'hace un momento'; final difference = DateTime.now().difference(timestamp); if (difference.inDays > 30) { - return '${(difference.inDays / 30).floor()} meses'; + return 'hace ${(difference.inDays / 30).floor()} meses'; } else if (difference.inDays > 0) { - return '${difference.inDays} días'; + return 'hace ${difference.inDays} días'; } else if (difference.inHours > 0) { - return '${difference.inHours} horas'; + return 'hace ${difference.inHours} horas'; } else if (difference.inMinutes > 0) { - return '${difference.inMinutes} minutos'; + return 'hace ${difference.inMinutes} minutos'; } else { return 'hace un momento'; } } } - -class _StatCard extends StatefulWidget { - final String title; - final String value; - final IconData icon; - final Gradient gradient; - final String? trend; - final bool trendUp; - - const _StatCard({ - required this.title, - required this.value, - required this.icon, - required this.gradient, - this.trend, - this.trendUp = true, - }); - - @override - State<_StatCard> createState() => _StatCardState(); -} - -class _StatCardState extends State<_StatCard> - with SingleTickerProviderStateMixin { - bool _isHovered = false; - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - transform: Matrix4.identity()..scale(_isHovered ? 1.02 : 1.0), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: widget.gradient, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: widget.gradient.colors.first.withOpacity(0.25), - blurRadius: _isHovered ? 16 : 10, - offset: Offset(0, _isHovered ? 6 : 3), - ), - ], - ), - child: Row( - children: [ - // Icono y valor en la izquierda - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Icono - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.25), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - widget.icon, - color: const Color(0xFF0B0B0D), - size: 22, - ), - ), - const Gap(12), - // Valor - Text( - widget.value, - style: const TextStyle( - color: Color(0xFF0B0B0D), - fontSize: 28, - fontWeight: FontWeight.bold, - fontFamily: 'Poppins', - height: 1.0, - ), - ), - const Gap(4), - // Título - Text( - widget.title, - style: TextStyle( - color: const Color(0xFF0B0B0D).withOpacity(0.75), - fontSize: 12, - fontFamily: 'Poppins', - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - // Trend badge a la derecha - if (widget.trend != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: widget.trendUp - ? Colors.white.withOpacity(0.3) - : Colors.black.withOpacity(0.2), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - widget.trendUp - ? Icons.trending_up - : Icons.trending_down, - size: 18, - color: const Color(0xFF0B0B0D), - ), - const Gap(2), - Text( - widget.trend!, - style: const TextStyle( - color: Color(0xFF0B0B0D), - fontSize: 11, - fontWeight: FontWeight.bold, - fontFamily: 'Poppins', - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/videos/widgets/premium_dashboard_widgets/premium_dashboard_widgets.dart b/lib/pages/videos/widgets/premium_dashboard_widgets/premium_dashboard_widgets.dart new file mode 100644 index 0000000..a00e1fd --- /dev/null +++ b/lib/pages/videos/widgets/premium_dashboard_widgets/premium_dashboard_widgets.dart @@ -0,0 +1,7 @@ +/// Widgets modulares para el Premium Dashboard +/// +/// Este archivo exporta todos los componentes reutilizables del dashboard. + +export 'stat_card_widget.dart'; +export 'views_chart_widget.dart'; +export 'top5_videos_widget.dart'; diff --git a/lib/pages/videos/widgets/premium_dashboard_widgets/stat_card_widget.dart b/lib/pages/videos/widgets/premium_dashboard_widgets/stat_card_widget.dart new file mode 100644 index 0000000..f712055 --- /dev/null +++ b/lib/pages/videos/widgets/premium_dashboard_widgets/stat_card_widget.dart @@ -0,0 +1,643 @@ +import 'package:flutter/material.dart'; +import 'package:countup/countup.dart'; +import 'package:gap/gap.dart'; +import 'package:energy_media/theme/theme.dart'; + +/// Model para definir los datos de una StatCard +class StatCardData { + final String title; + final int value; + final String? formattedValue; + final IconData icon; + final List gradientColors; + final String? trend; + final bool trendUp; + final String? subtitle; + + const StatCardData({ + required this.title, + required this.value, + this.formattedValue, + required this.icon, + required this.gradientColors, + this.trend, + this.trendUp = true, + this.subtitle, + }); +} + +/// Widget de tarjeta estadística premium con animaciones y diseño moderno +class PremiumStatCard extends StatefulWidget { + final StatCardData data; + final VoidCallback? onTap; + final bool showSparkline; + final List? sparklineData; + + const PremiumStatCard({ + Key? key, + required this.data, + this.onTap, + this.showSparkline = false, + this.sparklineData, + }) : super(key: key); + + @override + State createState() => _PremiumStatCardState(); +} + +class _PremiumStatCardState extends State + with SingleTickerProviderStateMixin { + bool _isHovered = false; + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(reverse: true); + + _pulseAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width <= 800; + + // En móvil usar versión simplificada sin efectos pesados + if (isMobile) { + return _buildMobileCard(); + } + + return _buildDesktopCard(); + } + + /// Versión simplificada para móvil - sin efectos que causan blur + Widget _buildMobileCard() { + return GestureDetector( + onTap: widget.onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 130), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + // Color sólido en lugar de gradiente para evitar blur + color: widget.data.gradientColors.first, + boxShadow: [ + BoxShadow( + color: widget.data.gradientColors.first.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Header row: Icon + Trend + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Icono simplificado + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + widget.data.icon, + color: Colors.white, + size: 24, + ), + ), + // Trend badge simplificado + if (widget.data.trend != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.data.trendUp + ? Icons.trending_up_rounded + : Icons.trending_down_rounded, + size: 14, + color: Colors.white, + ), + const Gap(4), + Text( + widget.data.trend!, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + ), + ), + ], + ), + ), + ], + ), + const Gap(12), + // Valor + Text( + widget.data.value.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + height: 1.0, + ), + ), + const Gap(4), + // Título + Text( + widget.data.title, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + fontFamily: 'Poppins', + ), + ), + ], + ), + ), + ), + ); + } + + /// Versión completa para desktop con todos los efectos + Widget _buildDesktopCard() { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + cursor: widget.onTap != null + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + transform: Matrix4.identity() + ..translate(0.0, _isHovered ? -4.0 : 0.0) + ..scale(_isHovered ? 1.02 : 1.0), + child: Container( + constraints: const BoxConstraints(minHeight: 140), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: widget.data.gradientColors.first.withOpacity( + _isHovered ? 0.4 : 0.25, + ), + blurRadius: _isHovered ? 30 : 20, + offset: Offset(0, _isHovered ? 12 : 8), + spreadRadius: _isHovered ? 2 : 0, + ), + BoxShadow( + color: widget.data.gradientColors.last.withOpacity(0.15), + blurRadius: 40, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Stack( + children: [ + // Background gradient + _buildBackground(), + + // Glow effect animado + _buildAnimatedGlow(), + + // Pattern overlay + _buildPatternOverlay(false), + + // Content + _buildContent(), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildBackground() { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + widget.data.gradientColors.first, + widget.data.gradientColors.last, + ], + stops: const [0.0, 1.0], + ), + ), + ); + } + + Widget _buildAnimatedGlow() { + return AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + return Positioned( + right: -50 + (_pulseAnimation.value * 20), + top: -30 + (_pulseAnimation.value * 10), + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.white.withOpacity(0.3 * _pulseAnimation.value), + Colors.white.withOpacity(0.0), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildPatternOverlay(bool isMobile) { + return Positioned.fill( + child: CustomPaint( + painter: _DotPatternPainter( + color: Colors.white.withOpacity(isMobile ? 0.02 : 0.05), + ), + ), + ); + } + + Widget _buildContent() { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Header row: Icon + Trend + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildIconContainer(), + if (widget.data.trend != null) _buildTrendBadge(), + ], + ), + + const Gap(16), + + // Value con animación countup + _buildAnimatedValue(), + + const Gap(4), + + // Title y subtitle + _buildTitleSection(), + + // Sparkline opcional + if (widget.showSparkline && widget.sparklineData != null) ...[ + const Gap(12), + _buildSparkline(), + ], + ], + ), + ); + } + + Widget _buildIconContainer() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + widget.data.icon, + color: Colors.white, + size: 26, + ), + ); + } + + Widget _buildTrendBadge() { + final isUp = widget.data.trendUp; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.25), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded, + size: 16, + color: Colors.white, + ), + const Gap(4), + Text( + widget.data.trend!, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + ), + ), + ], + ), + ); + } + + Widget _buildAnimatedValue() { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Countup( + begin: 0, + end: widget.data.value.toDouble(), + duration: const Duration(milliseconds: 1500), + curve: Curves.easeOutCubic, + separator: ',', + style: const TextStyle( + color: Colors.white, + fontSize: 48, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + height: 1.0, + shadows: [ + Shadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + ), + if (widget.data.subtitle != null) ...[ + const Gap(4), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + widget.data.subtitle!, + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 14, + fontFamily: 'Poppins', + ), + ), + ), + ], + ], + ); + } + + Widget _buildTitleSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.data.title, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 32, + fontWeight: FontWeight.w600, + fontFamily: 'Poppins', + letterSpacing: 0.3, + ), + ), + ], + ); + } + + Widget _buildSparkline() { + final data = widget.sparklineData!; + final maxValue = data.reduce((a, b) => a > b ? a : b); + final minValue = data.reduce((a, b) => a < b ? a : b); + final range = maxValue - minValue; + + return SizedBox( + height: 30, + child: CustomPaint( + size: const Size(double.infinity, 30), + painter: _SparklinePainter( + data: data, + minValue: minValue, + range: range == 0 ? 1 : range, + lineColor: Colors.white.withOpacity(0.6), + fillColor: Colors.white.withOpacity(0.1), + ), + ), + ); + } +} + +/// Painter para el patrón de puntos del fondo +class _DotPatternPainter extends CustomPainter { + final Color color; + + _DotPatternPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + const spacing = 20.0; + const radius = 1.5; + + for (double x = 0; x < size.width; x += spacing) { + for (double y = 0; y < size.height; y += spacing) { + canvas.drawCircle(Offset(x, y), radius, paint); + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Painter para el sparkline mini-chart +class _SparklinePainter extends CustomPainter { + final List data; + final double minValue; + final double range; + final Color lineColor; + final Color fillColor; + + _SparklinePainter({ + required this.data, + required this.minValue, + required this.range, + required this.lineColor, + required this.fillColor, + }); + + @override + void paint(Canvas canvas, Size size) { + if (data.isEmpty) return; + + final linePaint = Paint() + ..color = lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = fillColor + ..style = PaintingStyle.fill; + + final path = Path(); + final fillPath = Path(); + + final stepX = size.width / (data.length - 1); + + for (int i = 0; i < data.length; i++) { + final x = i * stepX; + final normalizedValue = (data[i] - minValue) / range; + final y = size.height - (normalizedValue * size.height); + + if (i == 0) { + path.moveTo(x, y); + fillPath.moveTo(x, size.height); + fillPath.lineTo(x, y); + } else { + path.lineTo(x, y); + fillPath.lineTo(x, y); + } + } + + fillPath.lineTo(size.width, size.height); + fillPath.close(); + + canvas.drawPath(fillPath, fillPaint); + canvas.drawPath(path, linePaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +/// Widget para mostrar skeleton loading de las stat cards +class StatCardSkeleton extends StatelessWidget { + final bool isMobile; + + const StatCardSkeleton({Key? key, this.isMobile = false}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: isMobile ? 120 : 160, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [ + AppTheme.of(context).tertiaryBackground, + AppTheme.of(context).secondaryBackground, + ], + ), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: AppTheme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + ), + Container( + width: 60, + height: 30, + decoration: BoxDecoration( + color: AppTheme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 80, + height: 32, + decoration: BoxDecoration( + color: AppTheme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + ), + const Gap(8), + Container( + width: 100, + height: 16, + decoration: BoxDecoration( + color: AppTheme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/videos/widgets/premium_dashboard_widgets/top5_videos_widget.dart b/lib/pages/videos/widgets/premium_dashboard_widgets/top5_videos_widget.dart new file mode 100644 index 0000000..635ef47 --- /dev/null +++ b/lib/pages/videos/widgets/premium_dashboard_widgets/top5_videos_widget.dart @@ -0,0 +1,534 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:energy_media/theme/theme.dart'; + +/// Model para los datos del top video +class TopVideoData { + final int mediaFileId; + final String title; + final String? fileUrl; + final String? storagePath; + final int reproducciones; + final String? posterUrl; + + const TopVideoData({ + required this.mediaFileId, + required this.title, + this.fileUrl, + this.storagePath, + required this.reproducciones, + this.posterUrl, + }); + + factory TopVideoData.fromMap(Map map) { + return TopVideoData( + mediaFileId: map['media_file_id'] as int, + title: map['title'] as String? ?? 'Sin título', + fileUrl: map['file_url'] as String?, + storagePath: map['storage_path'] as String?, + reproducciones: map['reproducciones'] as int? ?? 0, + posterUrl: map['poster_url'] as String?, + ); + } +} + +/// Widget premium para mostrar el Top 5 de videos más vistos +class Top5VideosWidget extends StatefulWidget { + final Future>> Function() loadTopVideos; + final Function(TopVideoData)? onVideoTap; + + const Top5VideosWidget({ + Key? key, + required this.loadTopVideos, + this.onVideoTap, + }) : super(key: key); + + @override + State createState() => _Top5VideosWidgetState(); +} + +class _Top5VideosWidgetState extends State + with SingleTickerProviderStateMixin { + List _topVideos = []; + bool _isLoading = true; + String? _error; + late AnimationController _animationController; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + _loadData(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + Future _loadData() async { + try { + setState(() { + _isLoading = true; + _error = null; + }); + + final result = await widget.loadTopVideos(); + _topVideos = result.map((map) => TopVideoData.fromMap(map)).toList(); + + setState(() => _isLoading = false); + _animationController.forward(); + } catch (e) { + setState(() { + _isLoading = false; + _error = 'Error al cargar los videos'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: AppTheme.of(context).primaryColor.withOpacity(0.08), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6B2F8A).withOpacity(0.08), + blurRadius: 30, + offset: const Offset(0, 10), + ), + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + _buildContent(), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: AppTheme.of(context).primaryColor.withOpacity(0.05), + width: 1, + ), + ), + ), + child: Row( + children: [ + // Icon con gradiente púrpura + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF6B2F8A).withOpacity(0.2), + const Color(0xFF6B2F8A).withOpacity(0.1), + ], + ), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6B2F8A).withOpacity(0.2), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.emoji_events_rounded, + color: Color(0xFF6B2F8A), + size: 24, + ), + ), + const Gap(16), + // Título + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Top 5 Videos', + style: AppTheme.of(context).title3.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).primaryText, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + const Gap(2), + Text( + 'Más reproducidos', + style: AppTheme.of(context).bodyText2.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).tertiaryText, + fontSize: 12, + ), + ), + ], + ), + ), + // Botón refresh + IconButton( + onPressed: _loadData, + icon: Icon( + Icons.refresh_rounded, + color: AppTheme.of(context).tertiaryText, + ), + tooltip: 'Actualizar', + ), + ], + ), + ); + } + + Widget _buildContent() { + if (_isLoading) { + return _buildLoadingState(); + } + + if (_error != null) { + return _buildErrorState(); + } + + if (_topVideos.isEmpty) { + return _buildEmptyState(); + } + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: _topVideos.asMap().entries.map((entry) { + final index = entry.key; + final video = entry.value; + final delay = index * 0.1; + + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final animValue = + (_animationController.value - delay).clamp(0.0, 1.0 - delay) / + (1.0 - delay); + return Opacity( + opacity: animValue, + child: Transform.translate( + offset: Offset(30 * (1 - animValue), 0), + child: child, + ), + ); + }, + child: _buildVideoItem(index, video), + ); + }).toList(), + ), + ); + } + + Widget _buildVideoItem(int index, TopVideoData video) { + // Colores para las medallas/posiciones + final positionColors = [ + const LinearGradient( + colors: [Color(0xFFFFD700), Color(0xFFFFA500)]), // 1st - Gold + const LinearGradient( + colors: [Color(0xFFC0C0C0), Color(0xFF9E9E9E)]), // 2nd - Silver + const LinearGradient( + colors: [Color(0xFFCD7F32), Color(0xFF8B4513)]), // 3rd - Bronze + const LinearGradient( + colors: [Color(0xFF4EC9F5), Color(0xFF2E8BC0)]), // 4th + const LinearGradient( + colors: [Color(0xFF6B2F8A), Color(0xFF4A0E78)]), // 5th + ]; + + final gradient = positionColors[index.clamp(0, 4)]; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onVideoTap != null + ? () => widget.onVideoTap!(video) + : null, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.of(context).tertiaryBackground.withOpacity(0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: index == 0 + ? const Color(0xFFFFD700).withOpacity(0.3) + : AppTheme.of(context).primaryColor.withOpacity(0.05), + width: index == 0 ? 2 : 1, + ), + ), + child: Row( + children: [ + // Posición con medalla + _buildPositionBadge(index + 1, gradient), + const Gap(12), + + // Thumbnail + _buildThumbnail(video), + const Gap(12), + + // Info del video + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + video.title, + style: AppTheme.of(context).bodyText1.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).primaryText, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const Gap(4), + Row( + children: [ + Icon( + Icons.play_circle_outline_rounded, + size: 14, + color: AppTheme.of(context).tertiaryText, + ), + const Gap(4), + Text( + _formatViews(video.reproducciones), + style: AppTheme.of(context).bodyText2.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).tertiaryText, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + + // Badge de reproducciones + _buildViewsBadge(video.reproducciones), + ], + ), + ), + ), + ), + ); + } + + Widget _buildPositionBadge(int position, LinearGradient gradient) { + final isTop3 = position <= 3; + + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: gradient, + borderRadius: BorderRadius.circular(10), + boxShadow: isTop3 + ? [ + BoxShadow( + color: gradient.colors.first.withOpacity(0.4), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: isTop3 + ? Icon( + position == 1 + ? Icons.emoji_events + : position == 2 + ? Icons.workspace_premium + : Icons.military_tech, + color: Colors.white, + size: 20, + ) + : Text( + '$position', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontFamily: 'Poppins', + fontSize: 14, + ), + ), + ), + ); + } + + Widget _buildThumbnail(TopVideoData video) { + return Container( + width: 56, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: AppTheme.of(context).tertiaryBackground, + image: video.posterUrl != null + ? DecorationImage( + image: NetworkImage(video.posterUrl!), + fit: BoxFit.cover, + ) + : null, + ), + child: video.posterUrl == null + ? Center( + child: Icon( + Icons.video_library_rounded, + color: AppTheme.of(context).tertiaryText, + size: 20, + ), + ) + : null, + ); + } + + Widget _buildViewsBadge(int views) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF4EC9F5), Color(0xFFFFB733)], + ), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatViewsShort(views), + style: const TextStyle( + color: Color(0xFF0B0B0D), + fontWeight: FontWeight.bold, + fontSize: 11, + fontFamily: 'Poppins', + ), + ), + ); + } + + Widget _buildLoadingState() { + return Padding( + padding: const EdgeInsets.all(16), + child: Shimmer.fromColors( + baseColor: AppTheme.of(context).tertiaryBackground, + highlightColor: AppTheme.of(context).secondaryBackground, + child: Column( + children: List.generate( + 5, + (index) => Container( + margin: const EdgeInsets.only(bottom: 12), + height: 64, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + ); + } + + Widget _buildErrorState() { + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline_rounded, + size: 48, + color: AppTheme.of(context).error, + ), + const Gap(16), + Text( + _error!, + style: AppTheme.of(context).bodyText1.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).tertiaryText, + ), + textAlign: TextAlign.center, + ), + const Gap(16), + TextButton.icon( + onPressed: _loadData, + icon: const Icon(Icons.refresh), + label: const Text('Reintentar'), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.videocam_off_rounded, + size: 48, + color: AppTheme.of(context).tertiaryText, + ), + const Gap(16), + Text( + 'No hay videos disponibles', + style: AppTheme.of(context).bodyText1.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).tertiaryText, + ), + ), + ], + ), + ), + ); + } + + String _formatViews(int views) { + if (views >= 1000000) { + return '${(views / 1000000).toStringAsFixed(1)}M reproducciones'; + } else if (views >= 1000) { + return '${(views / 1000).toStringAsFixed(1)}K reproducciones'; + } + return '$views reproducciones'; + } + + String _formatViewsShort(int views) { + if (views >= 1000000) { + return '${(views / 1000000).toStringAsFixed(1)}M'; + } else if (views >= 1000) { + return '${(views / 1000).toStringAsFixed(1)}K'; + } + return views.toString(); + } +} diff --git a/lib/pages/videos/widgets/premium_dashboard_widgets/views_chart_widget.dart b/lib/pages/videos/widgets/premium_dashboard_widgets/views_chart_widget.dart new file mode 100644 index 0000000..ed1db87 --- /dev/null +++ b/lib/pages/videos/widgets/premium_dashboard_widgets/views_chart_widget.dart @@ -0,0 +1,569 @@ +import 'package:flutter/material.dart'; +import 'package:graphic/graphic.dart'; +import 'package:gap/gap.dart'; +import 'package:energy_media/theme/theme.dart'; + +/// Model para los datos del chart +class ChartDataPoint { + final String label; + final double value; + final Color? color; + + const ChartDataPoint({ + required this.label, + required this.value, + this.color, + }); +} + +/// Widget de gráfica premium para reproducciones usando la librería Graphic +class PremiumViewsChart extends StatefulWidget { + final List data; + final String title; + final IconData icon; + final Color iconColor; + final bool showLegend; + final ChartType chartType; + + const PremiumViewsChart({ + Key? key, + required this.data, + this.title = 'Reproducciones Semanales', + this.icon = Icons.bar_chart_rounded, + this.iconColor = const Color(0xFFFFB733), + this.showLegend = false, + this.chartType = ChartType.bar, + }) : super(key: key); + + @override + State createState() => _PremiumViewsChartState(); +} + +enum ChartType { bar, line, area } + +class _PremiumViewsChartState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + ChartType _currentChartType = ChartType.bar; + + @override + void initState() { + super.initState(); + _currentChartType = widget.chartType; + _controller = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + )..forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppTheme.of(context).secondaryBackground, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: AppTheme.of(context).primaryColor.withOpacity(0.08), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: AppTheme.of(context).primaryColor.withOpacity(0.05), + blurRadius: 30, + offset: const Offset(0, 10), + ), + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + _buildChart(), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: AppTheme.of(context).primaryColor.withOpacity(0.05), + width: 1, + ), + ), + ), + child: Row( + children: [ + // Icon con gradiente + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + widget.iconColor.withOpacity(0.2), + widget.iconColor.withOpacity(0.1), + ], + ), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: widget.iconColor.withOpacity(0.2), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + widget.icon, + color: widget.iconColor, + size: 24, + ), + ), + const Gap(16), + // Título + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: AppTheme.of(context).title3.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).primaryText, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + const Gap(2), + Text( + 'Últimos 7 días', + style: AppTheme.of(context).bodyText2.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).tertiaryText, + fontSize: 12, + ), + ), + ], + ), + ), + // Toggle de tipo de chart + _buildChartTypeToggle(), + ], + ), + ); + } + + Widget _buildChartTypeToggle() { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppTheme.of(context).tertiaryBackground, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildToggleButton( + icon: Icons.bar_chart_rounded, + isSelected: _currentChartType == ChartType.bar, + onTap: () => setState(() => _currentChartType = ChartType.bar), + ), + _buildToggleButton( + icon: Icons.show_chart_rounded, + isSelected: _currentChartType == ChartType.line, + onTap: () => setState(() => _currentChartType = ChartType.line), + ), + _buildToggleButton( + icon: Icons.area_chart_rounded, + isSelected: _currentChartType == ChartType.area, + onTap: () => setState(() => _currentChartType = ChartType.area), + ), + ], + ), + ); + } + + Widget _buildToggleButton({ + required IconData icon, + required bool isSelected, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.of(context).primaryColor + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + size: 18, + color: isSelected ? Colors.white : AppTheme.of(context).tertiaryText, + ), + ), + ); + } + + Widget _buildChart() { + if (widget.data.isEmpty) { + return _buildEmptyState(); + } + + // Convertir datos para la librería Graphic + final chartData = widget.data + .asMap() + .entries + .map((e) => { + 'day': e.value.label, + 'views': e.value.value, + 'index': e.key, + }) + .toList(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 24, 24), + child: SizedBox( + height: 220, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: _buildGraphicChart(chartData), + ), + ), + ); + } + + Widget _buildGraphicChart(List> chartData) { + final primaryColor = AppTheme.of(context).primaryColor; + final secondaryColor = AppTheme.of(context).secondaryColor; + final tertiaryText = AppTheme.of(context).tertiaryText; + + switch (_currentChartType) { + case ChartType.line: + return Chart( + key: const ValueKey('line'), + data: chartData, + variables: { + 'day': Variable( + accessor: (Map datum) => datum['day'] as String, + ), + 'views': Variable( + accessor: (Map datum) => datum['views'] as num, + scale: LinearScale(min: 0), + ), + }, + marks: [ + LineMark( + shape: ShapeEncode(value: BasicLineShape(smooth: true)), + size: SizeEncode(value: 3), + color: ColorEncode(value: primaryColor), + ), + PointMark( + size: SizeEncode(value: 8), + color: ColorEncode(value: primaryColor), + ), + ], + axes: [ + Defaults.horizontalAxis + ..label = LabelStyle( + textStyle: TextStyle( + color: tertiaryText, + fontSize: 11, + fontFamily: 'Poppins', + ), + ), + Defaults.verticalAxis + ..label = LabelStyle( + textStyle: TextStyle( + color: tertiaryText, + fontSize: 11, + fontFamily: 'Poppins', + ), + ) + ..grid = Defaults.strokeStyle, + ], + selections: { + 'touchMove': PointSelection( + on: { + GestureType.hover, + GestureType.scaleUpdate, + GestureType.tapDown, + }, + dim: Dim.x, + ), + }, + tooltip: TooltipGuide( + backgroundColor: AppTheme.of(context).secondaryBackground, + elevation: 8, + textStyle: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 12, + fontFamily: 'Poppins', + ), + ), + crosshair: CrosshairGuide( + styles: [ + PaintStyle(strokeColor: primaryColor.withOpacity(0.3)), + ], + ), + ); + + case ChartType.area: + return Chart( + key: const ValueKey('area'), + data: chartData, + variables: { + 'day': Variable( + accessor: (Map datum) => datum['day'] as String, + ), + 'views': Variable( + accessor: (Map datum) => datum['views'] as num, + scale: LinearScale(min: 0), + ), + }, + marks: [ + AreaMark( + shape: ShapeEncode(value: BasicAreaShape(smooth: true)), + gradient: GradientEncode( + value: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + primaryColor.withOpacity(0.4), + secondaryColor.withOpacity(0.1), + Colors.transparent, + ], + ), + ), + ), + LineMark( + shape: ShapeEncode(value: BasicLineShape(smooth: true)), + size: SizeEncode(value: 2.5), + color: ColorEncode(value: primaryColor), + ), + ], + axes: [ + Defaults.horizontalAxis + ..label = LabelStyle( + textStyle: TextStyle( + color: tertiaryText, + fontSize: 11, + fontFamily: 'Poppins', + ), + ), + Defaults.verticalAxis + ..label = LabelStyle( + textStyle: TextStyle( + color: tertiaryText, + fontSize: 11, + fontFamily: 'Poppins', + ), + ) + ..grid = Defaults.strokeStyle, + ], + selections: { + 'touchMove': PointSelection( + on: { + GestureType.hover, + GestureType.scaleUpdate, + GestureType.tapDown, + }, + dim: Dim.x, + ), + }, + tooltip: TooltipGuide( + backgroundColor: AppTheme.of(context).secondaryBackground, + elevation: 8, + textStyle: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 12, + fontFamily: 'Poppins', + ), + ), + crosshair: CrosshairGuide( + styles: [ + PaintStyle(strokeColor: primaryColor.withOpacity(0.3)), + ], + ), + ); + + case ChartType.bar: + default: + return Chart( + key: const ValueKey('bar'), + data: chartData, + variables: { + 'day': Variable( + accessor: (Map datum) => datum['day'] as String, + ), + 'views': Variable( + accessor: (Map datum) => datum['views'] as num, + scale: LinearScale(min: 0), + ), + }, + marks: [ + IntervalMark( + size: SizeEncode(value: 24), + gradient: GradientEncode( + value: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + primaryColor, + secondaryColor, + ], + ), + ), + ), + ], + axes: [ + Defaults.horizontalAxis + ..label = LabelStyle( + textStyle: TextStyle( + color: tertiaryText, + fontSize: 11, + fontFamily: 'Poppins', + ), + ), + Defaults.verticalAxis + ..label = LabelStyle( + textStyle: TextStyle( + color: tertiaryText, + fontSize: 11, + fontFamily: 'Poppins', + ), + ) + ..grid = Defaults.strokeStyle, + ], + selections: { + 'tap': PointSelection( + on: { + GestureType.hover, + GestureType.tapDown, + }, + ), + }, + tooltip: TooltipGuide( + backgroundColor: AppTheme.of(context).secondaryBackground, + elevation: 8, + textStyle: TextStyle( + color: AppTheme.of(context).primaryText, + fontSize: 12, + fontFamily: 'Poppins', + ), + ), + ); + } + } + + Widget _buildEmptyState() { + return Container( + height: 200, + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.bar_chart_rounded, + size: 48, + color: AppTheme.of(context).tertiaryText, + ), + const Gap(16), + Text( + 'No hay datos disponibles', + style: AppTheme.of(context).bodyText1.override( + fontFamily: 'Poppins', + color: AppTheme.of(context).tertiaryText, + ), + ), + ], + ), + ), + ); + } +} + +/// Widget alternativo usando FL_Chart si prefieres mantenerlo más simple +class SimpleBarChart extends StatelessWidget { + final List data; + final Color barColor; + final Color? gradientEndColor; + + const SimpleBarChart({ + Key? key, + required this.data, + this.barColor = const Color(0xFF4EC9F5), + this.gradientEndColor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // Este es un placeholder simple - podrías implementar con FL_Chart aquí + return Container( + height: 200, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.end, + children: data.map((point) { + final maxValue = + data.map((d) => d.value).reduce((a, b) => a > b ? a : b); + final heightPercent = maxValue > 0 ? point.value / maxValue : 0.0; + + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + width: 30, + height: 150 * heightPercent, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + barColor, + gradientEndColor ?? barColor.withOpacity(0.6), + ], + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(6), + topRight: Radius.circular(6), + ), + ), + ), + const Gap(8), + Text( + point.label, + style: TextStyle( + color: AppTheme.of(context).tertiaryText, + fontSize: 11, + fontFamily: 'Poppins', + ), + ), + ], + ); + }).toList(), + ), + ); + } +} diff --git a/lib/providers/videos_provider.dart b/lib/providers/videos_provider.dart index 4a64fc2..72016fc 100644 --- a/lib/providers/videos_provider.dart +++ b/lib/providers/videos_provider.dart @@ -755,6 +755,46 @@ class VideosProvider extends ChangeNotifier { } } + /// Get top 5 videos by views using Supabase function + /// Returns list of maps with: media_file_id, title, file_url, storage_path, reproducciones, poster_url + Future>> getTop5VideosByViews() async { + try { + final response = await supabaseML.rpc('get_top_5_videos_by_views'); + + if (response == null) return []; + + return (response as List) + .map((item) => Map.from(item as Map)) + .toList(); + } catch (e) { + print('Error en getTop5VideosByViews: $e'); + return []; + } + } + + /// Get video metrics using Supabase function + /// Returns: total_videos, total_reproducciones, promedio_reproducciones_por_dia + Future?> getVideoMetrics() async { + try { + final response = await supabaseML.rpc('get_video_metrics'); + + if (response == null || (response as List).isEmpty) return null; + + // La función retorna un array con un solo objeto + final data = (response as List).first as Map; + + return { + 'total_videos': data['total_videos'] ?? 0, + 'total_reproducciones': data['total_reproducciones'] ?? 0, + 'promedio_reproducciones_por_dia': + data['promedio_reproducciones_por_dia'] ?? 0.0, + }; + } catch (e) { + print('Error en getVideoMetrics: $e'); + return null; + } + } + /// Update missing video durations (batch process) Future> updateMissingDurations( Function(int current, int total) onProgress, diff --git a/pubspec.lock b/pubspec.lock index edea86b..c2527ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + countup: + dependency: "direct main" + description: + name: countup + sha256: "3a7509b20170fd78ff9553848016465d34fdbc4ce8adb3d5361be80592b92675" + url: "https://pub.dev" + source: hosted + version: "0.1.4" cross_file: dependency: transitive description: @@ -597,6 +605,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.6" + graphic: + dependency: "direct main" + description: + name: graphic + sha256: af3a5a967d95ce2c2c9f7dee83c21f8bd6e6a458341820920cf15c0311cdaddc + url: "https://pub.dev" + source: hosted + version: "2.6.0" group_button: dependency: "direct main" description: @@ -901,6 +917,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ec91cf7..e576390 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -77,6 +77,8 @@ dependencies: chewie: ^1.8.0 shimmer: ^3.0.0 fl_chart: 0.69.0 + graphic: ^2.6.0 + countup: ^0.1.4 video_thumbnail: ^0.5.3 get_thumbnail_video: ^0.7.3