import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; import '../utils/user_prefs.dart'; import '../widgets/fade_in_local_image.dart'; /// 数据统计页面 - 多维度数据分析 class StatisticsPage extends StatefulWidget { const StatisticsPage({super.key}); @override State createState() => _StatisticsPageState(); } class _StatisticsPageState extends State { int _cloudTabIndex = 0; bool get _showMovies => UserPrefs().showMovieTab; bool get _showBooks => UserPrefs().showBookTab; bool get _showNotes => UserPrefs().showNoteTab; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Scaffold( backgroundColor: colors.surface, appBar: AppBar(title: const Text('数据统计')), body: Consumer( builder: (context, provider, child) { final movies = provider.movies.where((m) => !m.isDeleted).toList(); final books = provider.books.where((b) => !b.isDeleted).toList(); final notes = provider.notes.where((n) => !n.isDeleted).toList(); return ListView( padding: const EdgeInsets.all(20), children: [ // 1. 总览 _buildOverview(movies, books, notes), const SizedBox(height: 28), // 2. 状态分布 if (_showMovies) ...[ _buildStatusSection('影视状态分布', movies, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}), const SizedBox(height: 28), ], if (_showBooks) ...[ _buildStatusSection('阅读状态分布', books, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}), const SizedBox(height: 28), ], // 3. 习惯洞察 _buildHabitsInsight(movies, books, notes), const SizedBox(height: 28), // 4. 类型偏好雷达图 _buildGenreRadar(movies, books), const SizedBox(height: 28), // 5. 导演/作者 TOP 5 _buildDirectorTop5(movies), const SizedBox(height: 28), _buildAuthorTop5(books), const SizedBox(height: 28), // 6. 高分之最 _buildTopRated(movies, books), const SizedBox(height: 28), // 7. 评分分布 _buildRatingDistribution(movies, books), const SizedBox(height: 28), // 8. 年度趋势 _buildYearlyTrend(movies, books, notes), const SizedBox(height: 28), // 9. 星期分布 _buildWeekdayDistribution(movies, books, notes), const SizedBox(height: 28), // 10. 累计增长 _buildCumulativeGrowth(movies, books, notes), const SizedBox(height: 28), // 11. 标签词云 _buildTagCloud(movies, books, notes), const SizedBox(height: 28), // 12+13. 马拉松 + 标签之最 _buildFunStats(movies, books, notes), const SizedBox(height: 80), ], ); }, ), ); } // ─── 1. 总览区域 ────────────────────────────────────────────────────── Widget _buildOverview(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final completed = movies.where((m) => m.status == 'watched').length + books.where((b) => b.status == 'read').length; final totalWithStatus = movies.length + books.length; final completionRate = totalWithStatus > 0 ? completed / totalWithStatus : 0.0; // 本月新增 final now = DateTime.now(); final thisMonth = movies.where((m) => m.createdAt.year == now.year && m.createdAt.month == now.month).length + books.where((b) => b.createdAt.year == now.year && b.createdAt.month == now.month).length + notes.where((n) => n.createdAt.year == now.year && n.createdAt.month == now.month).length; final lastMonthDate = DateTime(now.year, now.month - 1, 1); final lastMonth = movies.where((m) => m.createdAt.year == lastMonthDate.year && m.createdAt.month == lastMonthDate.month).length + books.where((b) => b.createdAt.year == lastMonthDate.year && b.createdAt.month == lastMonthDate.month).length + notes.where((n) => n.createdAt.year == lastMonthDate.year && n.createdAt.month == lastMonthDate.month).length; final monthDiff = thisMonth - lastMonth; // 平均评分 final allRatings = [...movies, ...books].map((e) => (e as dynamic).rating as double?).where((r) => r != null && r > 0).toList(); final avgRating = allRatings.isNotEmpty ? allRatings.reduce((a, b) => a! + b!)! / allRatings.length : 0.0; // 记录天数 final allDates = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)]; final daysTracked = allDates.isNotEmpty ? now.difference(allDates.reduce((a, b) => a.isBefore(b) ? a : b)).inDays + 1 : 0; return Row( children: [ _buildOverviewCard('完成率', completionRate == 0 ? '-' : '${(completionRate * 100).toStringAsFixed(0)}%', Icons.check_circle_outline, colors.primary, subtitle: completionRate > 0 ? '已看+已读' : null), const SizedBox(width: 10), _buildOverviewCard('本月新增', '$thisMonth', Icons.trending_up, const Color(0xFF66BB6A), subtitle: monthDiff >= 0 ? '↑$monthDiff' : '↓${monthDiff.abs()}'), const SizedBox(width: 10), _buildOverviewCard('平均评分', avgRating > 0 ? avgRating.toStringAsFixed(1) : '-', Icons.star_outline, const Color(0xFFFFB800), subtitle: avgRating > 0 ? '/ 10' : null), const SizedBox(width: 10), _buildOverviewCard('记录天数', daysTracked > 0 ? '$daysTracked' : '-', Icons.calendar_today_outlined, const Color(0xFF7E57C2), subtitle: daysTracked > 0 ? '天' : null), ], ); } Widget _buildOverviewCard(String label, String value, IconData icon, Color color, {String? subtitle}) { final colors = Theme.of(context).colorScheme; return Expanded( child: Container( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), decoration: BoxDecoration( color: color.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(12), ), child: Column( children: [ Icon(icon, size: 20, color: color), const SizedBox(height: 8), Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: color)), if (subtitle != null) ...[ const SizedBox(height: 2), Text(subtitle, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), ], const SizedBox(height: 4), Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), ], ), ), ); } // ─── 2. 状态分布 ──────────────────────────────────────────────────────── Widget _buildStatusSection(String title, List items, String Function(dynamic) getStatus, Map labels) { final colors = Theme.of(context).colorScheme; final total = items.length; return _buildCard( title: title, child: Column( children: labels.entries.map((e) { final count = items.where((i) => getStatus(i) == e.value).length; final pct = total > 0 ? count / total : 0.0; return Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text(e.key, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), const Spacer(), Text('$count', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), const SizedBox(width: 4), Text('${(pct * 100).toStringAsFixed(0)}%', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator(value: pct, backgroundColor: colors.outlineVariant, color: colors.primary, minHeight: 6), ), ], ), ); }).toList(), ), ); } // ─── 3. 习惯洞察 ──────────────────────────────────────────────────────── Widget _buildHabitsInsight(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final allDates = [ ...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt), ]..sort(); if (allDates.isEmpty) return const SizedBox.shrink(); // 最活跃月份 final monthCounts = {}; for (final d in allDates) { monthCounts[d.month] = (monthCounts[d.month] ?? 0) + 1; } final busiestMonth = monthCounts.entries.isEmpty ? 1 : monthCounts.entries.reduce((a, b) => a.value >= b.value ? a : b).key; const monthNames = ['', '一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; // 记录频率 final firstDate = allDates.first; final totalMonths = math.max(1, (DateTime.now().year - firstDate.year) * 12 + DateTime.now().month - firstDate.month + 1); final avgPerMonth = (allDates.length / totalMonths).toStringAsFixed(1); // 观影/阅读节奏(已看完的平均间隔天数) final watchedDates = movies.where((m) => m.status == 'watched').map((m) => m.createdAt).toList()..sort(); final readDates = books.where((b) => b.status == 'read').map((b) => b.createdAt).toList()..sort(); final watchedAvgGap = _calcAvgGap(watchedDates); final readAvgGap = _calcAvgGap(readDates); return _buildCard( title: '习惯洞察', child: Column( children: [ _buildInsightRow(Icons.calendar_month_outlined, '最活跃月份', monthNames[busiestMonth]), Divider(height: 1, color: colors.outlineVariant), _buildInsightRow(Icons.speed_outlined, '记录频率', '平均每月 $avgPerMonth 条'), Divider(height: 1, color: colors.outlineVariant), if (watchedAvgGap > 0) _buildInsightRow(Icons.movie_outlined, '观影节奏', '平均 ${watchedAvgGap.toStringAsFixed(0)} 天一部'), if (watchedAvgGap > 0 && readAvgGap > 0) Divider(height: 1, color: colors.outlineVariant), if (readAvgGap > 0) _buildInsightRow(Icons.menu_book_outlined, '阅读节奏', '平均 ${readAvgGap.toStringAsFixed(0)} 天一本'), ], ), ); } double _calcAvgGap(List dates) { if (dates.length < 2) return 0; final sorted = dates.toList()..sort(); double totalGap = 0; for (int i = 1; i < sorted.length; i++) { totalGap += sorted[i].difference(sorted[i - 1]).inDays.toDouble(); } return totalGap / (sorted.length - 1); } Widget _buildInsightRow(IconData icon, String label, String value) { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( children: [ Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.5)), const SizedBox(width: 12), Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), const Spacer(), Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), ], ), ); } // ─── 4. 类型偏好雷达图 ────────────────────────────────────────────────── Widget _buildGenreRadar(List movies, List books) { final colors = Theme.of(context).colorScheme; final movieGenres = {}; final bookGenres = {}; for (final m in movies) { for (final g in m.genres) { movieGenres[g] = (movieGenres[g] ?? 0) + 1; } } for (final b in books) { for (final g in b.genres) { bookGenres[g] = (bookGenres[g] ?? 0) + 1; } } final allGenres = {}; allGenres.addAll(movieGenres); for (final e in bookGenres.entries) { allGenres[e.key] = (allGenres[e.key] ?? 0) + e.value; } final sorted = allGenres.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); final top6 = sorted.take(6).toList(); if (top6.length < 3) return const SizedBox.shrink(); final maxVal = top6.first.value.toDouble(); return _buildCard( title: '类型偏好', child: Column( children: [ SizedBox( height: 220, child: RadarChart( RadarChartData( radarShape: RadarShape.polygon, dataSets: [ if (movieGenres.isNotEmpty) RadarDataSet( dataEntries: top6.map((e) => RadarEntry(value: (movieGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(), borderColor: const Color(0xFF4A90D9), fillColor: const Color(0xFF4A90D9).withValues(alpha: 0.15), borderWidth: 2, ), if (bookGenres.isNotEmpty) RadarDataSet( dataEntries: top6.map((e) => RadarEntry(value: (bookGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(), borderColor: const Color(0xFF7E57C2), fillColor: const Color(0xFF7E57C2).withValues(alpha: 0.15), borderWidth: 2, ), ], radarBorderData: BorderSide(color: colors.outlineVariant, width: 0.5), gridBorderData: BorderSide(color: colors.outlineVariant, width: 0.5), tickBorderData: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.3), width: 0.5), ticksTextStyle: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)), titleTextStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.6)), titlePositionPercentageOffset: 0.15, getTitle: (index, angle) => RadarChartTitle(text: top6[index].key), tickCount: 3, ), ), ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (movieGenres.isNotEmpty) ...[ Container(width: 10, height: 3, decoration: BoxDecoration(color: const Color(0xFF4A90D9), borderRadius: BorderRadius.circular(1.5))), const SizedBox(width: 4), Text('影视', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), const SizedBox(width: 16), ], if (bookGenres.isNotEmpty) ...[ Container(width: 10, height: 3, decoration: BoxDecoration(color: const Color(0xFF7E57C2), borderRadius: BorderRadius.circular(1.5))), const SizedBox(width: 4), Text('书籍', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), ], ], ), ], ), ); } // ─── 5. 导演/作者 TOP 5 ──────────────────────────────────────────────── Widget _buildDirectorTop5(List movies) { final colors = Theme.of(context).colorScheme; final counts = {}; for (final m in movies) { for (final d in m.directors) { counts[d] = (counts[d] ?? 0) + 1; } } final sorted = counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); final top5 = sorted.take(5).toList(); if (top5.isEmpty) return const SizedBox.shrink(); final maxVal = top5.first.value.toDouble(); return _buildCard( title: '导演 TOP 5', child: Column( children: top5.map((e) { final ratio = e.value / maxVal; return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ SizedBox(width: 60, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7)), overflow: TextOverflow.ellipsis)), const SizedBox(width: 10), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: ratio, backgroundColor: colors.outlineVariant, color: const Color(0xFF4A90D9), minHeight: 4), ), ), const SizedBox(width: 8), Text('${e.value}部', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), ], ), ); }).toList(), ), ); } Widget _buildAuthorTop5(List books) { final colors = Theme.of(context).colorScheme; final counts = {}; for (final b in books) { for (final a in b.authors) { counts[a] = (counts[a] ?? 0) + 1; } } final sorted = counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); final top5 = sorted.take(5).toList(); if (top5.isEmpty) return const SizedBox.shrink(); final maxVal = top5.first.value.toDouble(); return _buildCard( title: '作者 TOP 5', child: Column( children: top5.map((e) { final ratio = e.value / maxVal; return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ SizedBox(width: 60, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7)), overflow: TextOverflow.ellipsis)), const SizedBox(width: 10), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: ratio, backgroundColor: colors.outlineVariant, color: const Color(0xFF7E57C2), minHeight: 4), ), ), const SizedBox(width: 8), Text('${e.value}本', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), ], ), ); }).toList(), ), ); } // ─── 6. 高分之最 TOP 5 ───────────────────────────────────────────────── Widget _buildTopRated(List movies, List books) { final colors = Theme.of(context).colorScheme; final ratedMovies = movies.where((m) => m.rating != null && m.rating! > 0).toList() ..sort((a, b) => b.rating!.compareTo(a.rating!)); final ratedBooks = books.where((b) => b.rating != null && b.rating! > 0).toList() ..sort((a, b) => b.rating!.compareTo(a.rating!)); if (ratedMovies.isEmpty && ratedBooks.isEmpty) return const SizedBox.shrink(); return _buildCard( title: '高分之最', child: Column( children: [ if (ratedMovies.isNotEmpty) ...[ Text('影视 TOP 5', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), const SizedBox(height: 8), ...ratedMovies.take(5).map((m) => _buildTopRatedItem(m.title, m.rating!, m.posterPath, colors)), if (ratedBooks.isNotEmpty) const SizedBox(height: 16), ], if (ratedBooks.isNotEmpty) ...[ Text('书籍 TOP 5', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), const SizedBox(height: 8), ...ratedBooks.take(5).map((b) => _buildTopRatedItem(b.title, b.rating!, b.coverPath, colors)), ], ], ), ); } Widget _buildTopRatedItem(String title, double rating, String? imagePath, ColorScheme colors) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ Container( width: 32, height: 44, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(4)), clipBehavior: Clip.antiAlias, child: imagePath != null && imagePath.isNotEmpty ? FadeInLocalImage(path: imagePath, fit: BoxFit.cover, errorWidget: Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2))) : Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)), ), const SizedBox(width: 10), Expanded(child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, color: colors.onSurface))), Icon(Icons.star, size: 16, color: const Color(0xFFFFB800)), const SizedBox(width: 4), Text(rating.toStringAsFixed(1), style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: colors.onSurface)), ], ), ); } // ─── 7. 评分分布 ──────────────────────────────────────────────────────── Widget _buildRatingDistribution(List movies, List books) { final colors = Theme.of(context).colorScheme; final allRatings = []; for (final m in movies) { if (m.rating != null) allRatings.add(m.rating!); } for (final b in books) { if (b.rating != null) allRatings.add(b.rating!); } if (allRatings.isEmpty) return const SizedBox.shrink(); final avg = allRatings.reduce((a, b) => a + b) / allRatings.length; final counts = List.filled(10, 0); for (final r in allRatings) { counts[(r.round()).clamp(1, 10) - 1]++; } final maxCount = counts.reduce((a, b) => a > b ? a : b).toDouble(); if (maxCount == 0) return const SizedBox.shrink(); const barColors = [ Color(0xFFBDBDBD), Color(0xFFBDBDBD), Color(0xFFFFCC80), Color(0xFFFFCC80), Color(0xFFFFB74D), Color(0xFFFFB74D), Color(0xFFFFA726), Color(0xFFFFA726), Color(0xFFFFB800), Color(0xFFFFB800), ]; return _buildCard( title: '评分分布', child: Column( children: [ Row( children: [ Text('平均评分', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), const Spacer(), Text(avg.toStringAsFixed(1), style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: colors.onSurface)), Text(' / 10', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))), ], ), const SizedBox(height: 20), SizedBox( height: 160, child: BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, maxY: maxCount * 1.2, minY: 0, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipColor: (_) => colors.inverseSurface, getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem('${group.x + 1}星 ${rod.toY.toInt()}部', TextStyle(color: colors.onInverseSurface, fontSize: 12, fontWeight: FontWeight.w600)); }, ), ), titlesData: FlTitlesData( show: true, bottomTitles: AxisTitles(sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) => Padding( padding: const EdgeInsets.only(top: 6), child: Text('${value.toInt() + 1}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.5))), ), reservedSize: 24, )), leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), borderData: FlBorderData(show: false), gridData: const FlGridData(show: false), barGroups: List.generate(10, (i) => BarChartGroupData(x: i, barRods: [ BarChartRodData( toY: counts[i].toDouble(), color: barColors[i], width: 20, borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), backDrawRodData: BackgroundBarChartRodData(show: true, toY: maxCount * 1.2, color: colors.outlineVariant.withValues(alpha: 0.3)), ), ])), ), ), ), const SizedBox(height: 8), Text('星级评分', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), ], ), ); } // ─── 8. 年度趋势折线图 ────────────────────────────────────────────────── Widget _buildYearlyTrend(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final now = DateTime.now(); final months = List.generate(12, (i) { final d = DateTime(now.year, now.month - (11 - i), 1); return '${d.month}月'; }); List countByMonth(List items) { return List.generate(12, (i) { final d = DateTime(now.year, now.month - (11 - i), 1); return items.where((item) => item.createdAt.year == d.year && item.createdAt.month == d.month).length; }); } final movieData = countByMonth(movies); final bookData = countByMonth(books); final noteData = countByMonth(notes); final allValues = [...movieData, ...bookData, ...noteData]; final maxVal = allValues.isEmpty ? 1 : allValues.reduce((a, b) => a > b ? a : b); final safeMax = maxVal == 0 ? 1 : maxVal; return _buildCard( title: '年度趋势', child: Column( children: [ SizedBox( height: 180, child: LineChart( LineChartData( minY: 0, maxY: (safeMax * 1.3).toDouble(), lineBarsData: [ _buildLineData(movieData, const Color(0xFF4A90D9)), _buildLineData(bookData, const Color(0xFF7E57C2)), _buildLineData(noteData, const Color(0xFF66BB6A)), ], titlesData: FlTitlesData( bottomTitles: AxisTitles(sideTitles: SideTitles( showTitles: true, interval: 2, getTitlesWidget: (value, meta) { final idx = value.toInt(); if (idx < 0 || idx >= months.length) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: 6), child: Text(months[idx], style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), ); }, reservedSize: 24, )), leftTitles: AxisTitles(sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) => Text('${value.toInt()}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))), reservedSize: 28, )), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: math.max(1, safeMax / 3).toDouble(), getDrawingHorizontalLine: (value) => FlLine(color: colors.outlineVariant, strokeWidth: 0.5), ), borderData: FlBorderData(show: false), lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipColor: (_) => colors.inverseSurface, getTooltipItems: (spots) => spots.map((s) { final labels = ['影视', '书籍', '笔记']; return LineTooltipItem('${labels[s.barIndex]} ${s.y.toInt()}', TextStyle(color: colors.onInverseSurface, fontSize: 12, fontWeight: FontWeight.w600)); }).toList(), ), ), ), ), ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildLegend(const Color(0xFF4A90D9), '影视'), const SizedBox(width: 16), _buildLegend(const Color(0xFF7E57C2), '书籍'), const SizedBox(width: 16), _buildLegend(const Color(0xFF66BB6A), '笔记'), ], ), ], ), ); } LineChartBarData _buildLineData(List data, Color color) { return LineChartBarData( spots: List.generate(12, (i) => FlSpot(i.toDouble(), data[i].toDouble())), isCurved: true, color: color, barWidth: 2, dotData: FlDotData(show: true, getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(radius: 3, color: color, strokeWidth: 0)), belowBarData: BarAreaData(show: true, color: color.withValues(alpha: 0.08)), ); } Widget _buildLegend(Color color, String label) { final colors = Theme.of(context).colorScheme; return Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: 10, height: 3, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(1.5))), const SizedBox(width: 4), Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), ], ); } // ─── 9. 星期分布 ──────────────────────────────────────────────────────── Widget _buildWeekdayDistribution(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final allDates = [ ...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt), ]; if (allDates.isEmpty) return const SizedBox.shrink(); final weekdayCounts = List.filled(7, 0); for (final d in allDates) { weekdayCounts[d.weekday - 1]++; } final maxCount = weekdayCounts.reduce((a, b) => a > b ? a : b).toDouble(); if (maxCount == 0) return const SizedBox.shrink(); final dayLabels = ['一', '二', '三', '四', '五', '六', '日']; return _buildCard( title: '星期分布', child: SizedBox( height: 140, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(7, (i) { final ratio = weekdayCounts[i] / maxCount; final barHeight = (ratio * 100).clamp(4.0, 100.0); return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Text('${weekdayCounts[i]}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), const SizedBox(height: 4), Container( height: barHeight, decoration: BoxDecoration( color: colors.primary.withValues(alpha: 0.6 + ratio * 0.4), borderRadius: BorderRadius.circular(4), ), ), const SizedBox(height: 6), Text(dayLabels[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), ], ), ), ); }), ), ), ); } // ─── 10. 累计增长曲线 ────────────────────────────────────────────────── Widget _buildCumulativeGrowth(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final allItems = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)]; if (allItems.isEmpty) return const SizedBox.shrink(); allItems.sort(); // 按月累计 final monthlyCumulative = {}; int cumulative = 0; final now = DateTime.now(); for (int i = 11; i >= 0; i--) { final d = DateTime(now.year, now.month - i, 1); final nextMonth = DateTime(d.year, d.month + 1, 1); final count = allItems.where((date) => !date.isBefore(d) && date.isBefore(nextMonth)).length; cumulative += count; monthlyCumulative[11 - i] = cumulative; } final maxVal = cumulative.toDouble(); if (maxVal == 0) return const SizedBox.shrink(); return _buildCard( title: '累计增长', child: SizedBox( height: 160, child: LineChart( LineChartData( minY: 0, maxY: maxVal * 1.2, lineBarsData: [ LineChartBarData( spots: List.generate(12, (i) => FlSpot(i.toDouble(), (monthlyCumulative[i] ?? 0).toDouble())), isCurved: true, color: colors.primary, barWidth: 2.5, dotData: FlDotData(show: true, getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(radius: 3, color: colors.primary, strokeWidth: 0)), belowBarData: BarAreaData(show: true, color: colors.primary.withValues(alpha: 0.08)), ), ], titlesData: FlTitlesData( bottomTitles: AxisTitles(sideTitles: SideTitles( showTitles: true, interval: 2, getTitlesWidget: (value, meta) { final d = DateTime(now.year, now.month - (11 - value.toInt()), 1); return Padding( padding: const EdgeInsets.only(top: 6), child: Text('${d.month}月', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), ); }, reservedSize: 24, )), leftTitles: AxisTitles(sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) => Text('${value.toInt()}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))), reservedSize: 28, )), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: math.max(1, maxVal / 3).toDouble(), getDrawingHorizontalLine: (value) => FlLine(color: colors.outlineVariant, strokeWidth: 0.5), ), borderData: FlBorderData(show: false), ), ), ), ); } // ─── 11. 标签词云 ────────────────────────────────────────────────────── Widget _buildTagCloud(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final tabs = []; if (_showMovies) tabs.add('影视'); if (_showBooks) tabs.add('书籍'); if (_showNotes) tabs.add('笔记'); if (tabs.isEmpty) return const SizedBox.shrink(); if (_cloudTabIndex >= tabs.length) _cloudTabIndex = 0; final tagCounts = {}; switch (tabs[_cloudTabIndex]) { case '影视': for (final m in movies) { for (final g in m.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } } break; case '书籍': for (final b in books) { for (final g in b.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } } break; case '笔记': for (final n in notes) { for (final t in n.tags) { tagCounts[t] = (tagCounts[t] ?? 0) + 1; } } break; } final sorted = tagCounts.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); if (sorted.isEmpty) return const SizedBox.shrink(); final maxCount = sorted.first.value; final minCount = sorted.last.value; final range = math.max(maxCount - minCount, 1); const cloudColors = [ Color(0xFFE53935), Color(0xFF4A90D9), Color(0xFF7E57C2), Color(0xFF66BB6A), Color(0xFFFF8F00), Color(0xFF00ACC1), Color(0xFF5C6BC0), Color(0xFF26A69A), Color(0xFF8D6E63), ]; return _buildCard( title: '标签词云', child: Column(children: [ Row( children: tabs.map((t) { final i = tabs.indexOf(t); final selected = _cloudTabIndex == i; return Expanded( child: GestureDetector( onTap: () => setState(() => _cloudTabIndex = i), child: Container( padding: const EdgeInsets.symmetric(vertical: 8), margin: EdgeInsets.only(right: i < tabs.length - 1 ? 6 : 0), decoration: BoxDecoration( color: selected ? colors.primary : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Text(t, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), ), ), ); }).toList(), ), const SizedBox(height: 16), Wrap( spacing: 6, runSpacing: 6, children: sorted.map((e) { final ratio = (e.value - minCount) / range; final fontSize = (12.0 + ratio * 18.0).roundToDouble(); final c = cloudColors[((ratio * (cloudColors.length - 1)).round()).clamp(0, cloudColors.length - 1)]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), child: Text(e.key, style: TextStyle(fontSize: fontSize, fontWeight: fontSize > 18 ? FontWeight.w700 : FontWeight.w500, color: c, height: 1.3)), ); }).toList(), ), ]), ); } // ─── 12+13. 趣味统计 ────────────────────────────────────────────────── Widget _buildFunStats(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final allItems = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)]; if (allItems.isEmpty) return const SizedBox.shrink(); // 观影马拉松 final sortedDates = allItems.map((d) => DateTime(d.year, d.month, d.day)).toSet().toList()..sort(); int maxStreak = 1, currentStreak = 1; for (int i = 1; i < sortedDates.length; i++) { if (sortedDates[i].difference(sortedDates[i - 1]).inDays == 1) { currentStreak++; maxStreak = math.max(maxStreak, currentStreak); } else { currentStreak = 1; } } // 标签之最 final tagCounts = {}; for (final m in movies) { for (final g in m.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } } for (final b in books) { for (final g in b.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } } for (final n in notes) { for (final t in n.tags) { tagCounts[t] = (tagCounts[t] ?? 0) + 1; } } final topTag = tagCounts.entries.isEmpty ? null : tagCounts.entries.reduce((a, b) => a.value >= b.value ? a : b); return Row( children: [ Expanded( child: _buildFunCard(Icons.local_fire_department_outlined, '连续记录', maxStreak > 1 ? '$maxStreak 天' : '-', '最长连续记录天数', const Color(0xFFFF8F00), colors), ), const SizedBox(width: 12), Expanded( child: _buildFunCard(Icons.label_outlined, '最常用标签', topTag != null ? topTag.key : '-', topTag != null ? '使用 ${topTag.value} 次' : '', const Color(0xFF26A69A), colors), ), ], ); } Widget _buildFunCard(IconData icon, String label, String value, String sub, Color color, ColorScheme colors) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: color.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(14), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 22, color: color), const SizedBox(height: 10), Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: color)), const SizedBox(height: 4), Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))), if (sub.isNotEmpty) ...[ const SizedBox(height: 2), Text(sub, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))), ], ], ), ); } // ─── 通用卡片 ──────────────────────────────────────────────────────── Widget _buildCard({required String title, required Widget child}) { final colors = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container(width: 3, height: 14, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(2))), const SizedBox(width: 8), Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), ], ), const SizedBox(height: 16), child, ], ), ); } }