Files
MookNote/lib/pages/home/desktop_home_page.dart
2026-08-09 22:07:55 +08:00

898 lines
48 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 DesktopHomePage extends StatefulWidget {
const DesktopHomePage({super.key});
@override
State<DesktopHomePage> createState() => _DesktopHomePageState();
}
class _DesktopHomePageState extends State<DesktopHomePage> {
int _cloudTabIndex = 0;
bool get _showMovies => UserPrefs().showMovieTab;
bool get _showBooks => UserPrefs().showBookTab;
bool get _showNotes => UserPrefs().showNoteTab;
bool get _showGames => UserPrefs().showGameTab;
static const _movieColor = Color(0xFF2563EB);
static const _bookColor = Color(0xFF16A34A);
static const _noteColor = Color(0xFF9333EA);
static const _gameColor = Color(0xFFEA580C);
// 缓存
List<Movie>? _cachedMovies;
List<Book>? _cachedBooks;
List<Note>? _cachedNotes;
List<Game>? _cachedGames;
List<Movie>? _fm;
List<Book>? _fb;
List<Note>? _fn;
List<Game>? _fg;
void _updateCache(List<Movie> movies, List<Book> books, List<Note> notes, List<Game> games) {
if (!identical(movies, _cachedMovies) || !identical(books, _cachedBooks) ||
!identical(notes, _cachedNotes) || !identical(games, _cachedGames)) {
_cachedMovies = movies;
_cachedBooks = books;
_cachedNotes = notes;
_cachedGames = games;
_fm = movies.where((m) => !m.isDeleted).toList();
_fb = books.where((b) => !b.isDeleted).toList();
_fn = notes.where((n) => !n.isDeleted).toList();
_fg = games.where((g) => !g.isDeleted).toList();
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final movies = context.select<AppProvider, List<Movie>>((p) => p.movies);
final books = context.select<AppProvider, List<Book>>((p) => p.books);
final notes = context.select<AppProvider, List<Note>>((p) => p.notes);
final games = context.select<AppProvider, List<Game>>((p) => p.games);
_updateCache(movies, books, notes, games);
final fm = _fm!, fb = _fb!, fn = _fn!, fg = _fg!;
// 预计算总览数据
final completed = fm.where((m) => m.status == 'watched').length +
fb.where((b) => b.status == 'read').length +
fg.where((g) => g.status == 'completed').length;
final totalWithStatus = fm.length + fb.length + fg.length;
final completionRate = totalWithStatus > 0 ? completed / totalWithStatus : 0.0;
final now = DateTime.now();
final thisMonth = fm.where((m) => m.createdAt.year == now.year && m.createdAt.month == now.month).length +
fb.where((b) => b.createdAt.year == now.year && b.createdAt.month == now.month).length +
fn.where((n) => n.createdAt.year == now.year && n.createdAt.month == now.month).length +
fg.where((g) => g.createdAt.year == now.year && g.createdAt.month == now.month).length;
final allRatings = <double>[];
for (final m in fm) { if (m.rating != null && m.rating! > 0) allRatings.add(m.rating!); }
for (final b in fb) { if (b.rating != null && b.rating! > 0) allRatings.add(b.rating!); }
for (final g in fg) { if (g.rating != null && g.rating! > 0) allRatings.add(g.rating!); }
final avgRating = allRatings.isNotEmpty ? allRatings.reduce((a, b) => a + b) / allRatings.length : 0.0;
return ListView(
padding: const EdgeInsets.all(28),
children: [
// 顶部:问候 + 模块卡片
_buildHeroSection(fm, fb, fn, fg, completionRate, thisMonth, avgRating, colors),
const SizedBox(height: 20),
// 双列:年度趋势 + 状态分布
_buildTwoCol(
_buildYearlyTrend(fm, fb, fn, fg, colors),
_buildAllStatus(fm, fb, fg, colors),
),
const SizedBox(height: 20),
// 双列:类型雷达 + 习惯洞察
_buildTwoCol(
_buildGenreRadar(fm, fb, fg, colors),
_buildHabitsInsight(fm, fb, fn, fg, colors),
),
const SizedBox(height: 20),
// 双列导演TOP5 + 作者TOP5
_buildTwoCol(
_showMovies ? _buildDirectorTop5(fm, colors) : const SizedBox.shrink(),
_showBooks ? _buildAuthorTop5(fb, colors) : const SizedBox.shrink(),
),
const SizedBox(height: 20),
// 高分之最(全宽)
_buildTopRated(fm, fb, fg, colors),
const SizedBox(height: 20),
// 双列:评分分布 + 星期分布
_buildTwoCol(
_buildRatingDistribution(fm, fb, fg, colors),
_buildWeekdayDistribution(fm, fb, fn, fg, colors),
),
const SizedBox(height: 20),
// 双列:累计增长 + 趣味统计
_buildTwoCol(
_buildCumulativeGrowth(fm, fb, fn, fg, colors),
_buildFunStats(fm, fb, fn, fg, colors),
),
const SizedBox(height: 20),
// 标签词云(全宽)
_buildTagCloud(fm, fb, fn, fg, colors),
const SizedBox(height: 60),
],
);
}
// ─── 双列布局辅助 ──────────────────────────────────────────────────────
Widget _buildTwoCol(Widget left, Widget right) {
if (left is SizedBox && right is SizedBox) return const SizedBox.shrink();
if (left is SizedBox) return right;
if (right is SizedBox) return left;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: left),
const SizedBox(width: 20),
Expanded(child: right),
],
),
);
}
// ─── 顶部英雄区域 ──────────────────────────────────────────────────────
Widget _buildHeroSection(List<Movie> fm, List<Book> fb, List<Note> fn, List<Game> fg,
double completionRate, int thisMonth, double avgRating, ColorScheme colors) {
final hour = DateTime.now().hour;
final greeting = hour < 6 ? '夜深了' : hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好';
final nickname = UserPrefs().nickname;
final firstUse = UserPrefs().firstUseDate;
final now = DateTime.now();
final days = DateTime(now.year, now.month, now.day)
.difference(DateTime(firstUse.year, firstUse.month, firstUse.day))
.inDays + 1;
// 模块卡片数据
final items = <_ModuleData>[];
if (_showMovies) {
final watched = fm.where((m) => m.status == 'watched').length;
items.add(_ModuleData(Icons.movie_outlined, '影视', fm.length, '已看$watched', _movieColor, 0));
}
if (_showBooks) {
final read = fb.where((b) => b.status == 'read').length;
items.add(_ModuleData(Icons.menu_book_outlined, '阅读', fb.length, '已读$read', _bookColor, 1));
}
if (_showNotes) {
final words = fn.fold<int>(0, (sum, n) => sum + n.content.length);
final ws = words >= 10000 ? '${(words / 10000).toStringAsFixed(1)}' : '$words';
items.add(_ModuleData(Icons.sticky_note_2_outlined, '笔记', fn.length, '$ws字', _noteColor, 2));
}
if (_showGames) {
final completed = fg.where((g) => g.status == 'completed').length;
items.add(_ModuleData(Icons.sports_esports_outlined, '游戏', fg.length, '通关$completed', _gameColor, 3));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 问候
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$greeting$nickname', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: colors.onSurface)),
const SizedBox(height: 4),
Text('${now.year}.${now.month.toString().padLeft(2, '0')}.${now.day.toString().padLeft(2, '0')} · 相遇第 $days', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
const Spacer(),
// 右侧快捷指标
_buildQuickChip('完成率', completionRate > 0 ? '${(completionRate * 100).toStringAsFixed(0)}%' : '-', Icons.check_circle_outline, colors.primary, colors),
const SizedBox(width: 8),
_buildQuickChip('本月', '$thisMonth', Icons.trending_up, const Color(0xFF66BB6A), colors),
const SizedBox(width: 8),
_buildQuickChip('均分', avgRating > 0 ? avgRating.toStringAsFixed(1) : '-', Icons.star_outline, const Color(0xFFFFB800), colors),
],
),
const SizedBox(height: 20),
// 模块卡片
Row(
children: items.asMap().entries.map((e) {
final d = e.value;
return Expanded(
child: GestureDetector(
onTap: () => context.read<AppProvider>().setMainTabIndex(d.tabIndex),
child: Container(
margin: EdgeInsets.only(right: e.key < items.length - 1 ? 12 : 0),
padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [d.color.withValues(alpha: 0.12), d.color.withValues(alpha: 0.04)],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: d.color.withValues(alpha: 0.15), width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 28, height: 28,
decoration: BoxDecoration(color: d.color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8)),
child: Icon(d.icon, size: 15, color: d.color),
),
const SizedBox(width: 8),
Text(d.label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))),
]),
const SizedBox(height: 14),
Text('${d.count}', style: TextStyle(fontSize: 30, fontWeight: FontWeight.w800, color: d.color, height: 1)),
const SizedBox(height: 4),
Text(d.sub, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
),
),
);
}).toList(),
),
],
);
}
Widget _buildQuickChip(String label, String value, IconData icon, Color color, ColorScheme colors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(width: 2),
Text(label, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
);
}
// ─── 合并状态分布 ──────────────────────────────────────────────────────
Widget _buildAllStatus(List<Movie> fm, List<Book> fb, List<Game> fg, ColorScheme colors) {
return _buildCard(
title: '状态分布',
colors: colors,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_showMovies) ...[
_buildModuleStatusChip(Icons.movie_outlined, '影视', _movieColor, colors),
const SizedBox(height: 10),
..._buildStatusRows(fm, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}, _movieColor, colors),
if (_showBooks || _showGames) ...[const SizedBox(height: 16), Divider(height: 1, color: colors.outlineVariant), const SizedBox(height: 16)],
],
if (_showBooks) ...[
_buildModuleStatusChip(Icons.menu_book_outlined, '阅读', _bookColor, colors),
const SizedBox(height: 10),
..._buildStatusRows(fb, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}, _bookColor, colors),
if (_showGames) ...[const SizedBox(height: 16), Divider(height: 1, color: colors.outlineVariant), const SizedBox(height: 16)],
],
if (_showGames) ...[
_buildModuleStatusChip(Icons.sports_esports_outlined, '游戏', _gameColor, colors),
const SizedBox(height: 10),
..._buildStatusRows(fg, (g) => g.status, {'通关': 'completed', '在玩': 'playing', '想玩': 'want_to_play', '弃游': 'abandoned'}, _gameColor, colors),
],
],
),
);
}
Widget _buildModuleStatusChip(IconData icon, String label, Color color, ColorScheme colors) {
return Row(
children: [
Container(
width: 22, height: 22,
decoration: BoxDecoration(color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6)),
child: Icon(icon, size: 12, color: color),
),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
);
}
List<Widget> _buildStatusRows(List items, String Function(dynamic) getStatus, Map<String, String> labels, Color barColor, ColorScheme colors) {
final total = items.length;
return 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: 8),
child: Row(
children: [
SizedBox(width: 48, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)))),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(value: pct, backgroundColor: colors.outlineVariant, color: barColor, minHeight: 5),
),
),
const SizedBox(width: 8),
SizedBox(width: 60, child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('$count', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(width: 4),
Text('${(pct * 100).toStringAsFixed(0)}%', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))),
],
)),
],
),
);
}).toList();
}
// ─── 习惯洞察 ─────────────────────────────────────────────────────────
Widget _buildHabitsInsight(List<Movie> fm, List<Book> fb, List<Note> fn, List<Game> fg, ColorScheme colors) {
final allDates = [
...fm.map((m) => m.createdAt), ...fb.map((b) => b.createdAt),
...fn.map((n) => n.createdAt), ...fg.map((g) => g.createdAt),
]..sort();
if (allDates.isEmpty) return const SizedBox.shrink();
final monthCounts = <int, int>{};
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 = fm.where((m) => m.status == 'watched').map((m) => m.createdAt).toList()..sort();
final readDates = fb.where((b) => b.status == 'read').map((b) => b.createdAt).toList()..sort();
final playedDates = fg.where((g) => g.status == 'completed').map((g) => g.createdAt).toList()..sort();
return _buildCard(
title: '习惯洞察',
colors: colors,
child: Column(
children: [
_buildInsightRow(Icons.calendar_month_outlined, '最活跃月份', monthNames[busiestMonth], colors),
Divider(height: 1, color: colors.outlineVariant),
_buildInsightRow(Icons.speed_outlined, '记录频率', '每月 $avgPerMonth', colors),
if (watchedDates.length >= 2) ...[
Divider(height: 1, color: colors.outlineVariant),
_buildInsightRow(Icons.movie_outlined, '观影节奏', '${_calcAvgGap(watchedDates).toStringAsFixed(0)} 天/部', colors),
],
if (readDates.length >= 2) ...[
Divider(height: 1, color: colors.outlineVariant),
_buildInsightRow(Icons.menu_book_outlined, '阅读节奏', '${_calcAvgGap(readDates).toStringAsFixed(0)} 天/本', colors),
],
if (playedDates.length >= 2) ...[
Divider(height: 1, color: colors.outlineVariant),
_buildInsightRow(Icons.sports_esports_outlined, '游戏节奏', '${_calcAvgGap(playedDates).toStringAsFixed(0)} 天/款', colors),
],
],
),
);
}
double _calcAvgGap(List<DateTime> 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, ColorScheme colors) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Icon(icon, size: 16, color: colors.onSurface.withValues(alpha: 0.4)),
const SizedBox(width: 10),
Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))),
const Spacer(),
Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
);
}
// ─── 类型偏好雷达图 ────────────────────────────────────────────────────
Widget _buildGenreRadar(List<Movie> fm, List<Book> fb, List<Game> fg, ColorScheme colors) {
final movieGenres = <String, int>{};
final bookGenres = <String, int>{};
final gameGenres = <String, int>{};
for (final m in fm) { for (final g in m.genres) { movieGenres[g] = (movieGenres[g] ?? 0) + 1; } }
for (final b in fb) { for (final g in b.genres) { bookGenres[g] = (bookGenres[g] ?? 0) + 1; } }
for (final g in fg) { for (final ge in g.genres) { gameGenres[ge] = (gameGenres[ge] ?? 0) + 1; } }
final allGenres = <String, int>{};
allGenres.addAll(movieGenres);
for (final e in bookGenres.entries) { allGenres[e.key] = (allGenres[e.key] ?? 0) + e.value; }
for (final e in gameGenres.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();
final dataSets = <RadarDataSet>[];
if (movieGenres.isNotEmpty) dataSets.add(RadarDataSet(dataEntries: top6.map((e) => RadarEntry(value: (movieGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(), borderColor: _movieColor, fillColor: _movieColor.withValues(alpha: 0.12), borderWidth: 2));
if (bookGenres.isNotEmpty) dataSets.add(RadarDataSet(dataEntries: top6.map((e) => RadarEntry(value: (bookGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(), borderColor: _bookColor, fillColor: _bookColor.withValues(alpha: 0.12), borderWidth: 2));
if (gameGenres.isNotEmpty) dataSets.add(RadarDataSet(dataEntries: top6.map((e) => RadarEntry(value: (gameGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(), borderColor: _gameColor, fillColor: _gameColor.withValues(alpha: 0.12), borderWidth: 2));
return _buildCard(
title: '类型偏好',
colors: colors,
child: Column(
children: [
SizedBox(height: 200, child: RadarChart(RadarChartData(
radarShape: RadarShape.polygon,
dataSets: dataSets,
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: 8),
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
if (movieGenres.isNotEmpty) ...[_buildLegend(_movieColor, '影视', colors), const SizedBox(width: 14)],
if (bookGenres.isNotEmpty) ...[_buildLegend(_bookColor, '书籍', colors), const SizedBox(width: 14)],
if (gameGenres.isNotEmpty) _buildLegend(_gameColor, '游戏', colors),
]),
],
),
);
}
// ─── 导演/作者 TOP 5 ──────────────────────────────────────────────────
Widget _buildDirectorTop5(List<Movie> fm, ColorScheme colors) {
final counts = <String, int>{};
for (final m in fm) { 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', colors: colors, child: Column(children: top5.map((e) {
return Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(children: [
SizedBox(width: 56, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7)), overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
Expanded(child: ClipRRect(borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: e.value / maxVal, backgroundColor: colors.outlineVariant, color: _movieColor, minHeight: 4))),
const SizedBox(width: 8),
Text('${e.value}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)),
]));
}).toList()));
}
Widget _buildAuthorTop5(List<Book> fb, ColorScheme colors) {
final counts = <String, int>{};
for (final b in fb) { 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', colors: colors, child: Column(children: top5.map((e) {
return Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(children: [
SizedBox(width: 56, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7)), overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
Expanded(child: ClipRRect(borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: e.value / maxVal, backgroundColor: colors.outlineVariant, color: _bookColor, minHeight: 4))),
const SizedBox(width: 8),
Text('${e.value}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)),
]));
}).toList()));
}
// ─── 高分之最 ──────────────────────────────────────────────────────────
Widget _buildTopRated(List<Movie> fm, List<Book> fb, List<Game> fg, ColorScheme colors) {
final ratedMovies = fm.where((m) => m.rating != null && m.rating! > 0).toList()..sort((a, b) => b.rating!.compareTo(a.rating!));
final ratedBooks = fb.where((b) => b.rating != null && b.rating! > 0).toList()..sort((a, b) => b.rating!.compareTo(a.rating!));
final ratedGames = fg.where((g) => g.rating != null && g.rating! > 0).toList()..sort((a, b) => b.rating!.compareTo(a.rating!));
if (ratedMovies.isEmpty && ratedBooks.isEmpty && ratedGames.isEmpty) return const SizedBox.shrink();
// 三列并排
return _buildCard(
title: '高分之最',
colors: colors,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (ratedMovies.isNotEmpty) Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionChip(Icons.movie_outlined, '影视', _movieColor, colors),
const SizedBox(height: 8),
...ratedMovies.take(5).map((m) => _buildTopRatedItem(m.title, m.rating!, m.posterPath, colors)),
],
)),
if (ratedMovies.isNotEmpty && (ratedBooks.isNotEmpty || ratedGames.isNotEmpty))
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: VerticalDivider(width: 1, color: colors.outlineVariant)),
if (ratedBooks.isNotEmpty) Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionChip(Icons.menu_book_outlined, '书籍', _bookColor, colors),
const SizedBox(height: 8),
...ratedBooks.take(5).map((b) => _buildTopRatedItem(b.title, b.rating!, b.coverPath, colors)),
],
)),
if (ratedBooks.isNotEmpty && ratedGames.isNotEmpty)
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: VerticalDivider(width: 1, color: colors.outlineVariant)),
if (ratedGames.isNotEmpty) Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionChip(Icons.sports_esports_outlined, '游戏', _gameColor, colors),
const SizedBox(height: 8),
...ratedGames.take(5).map((g) => _buildTopRatedItem(g.title, g.rating!, g.coverPath, colors)),
],
)),
],
),
);
}
Widget _buildSectionChip(IconData icon, String label, Color color, ColorScheme colors) {
return Row(children: [
Container(width: 20, height: 20, decoration: BoxDecoration(color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(5)),
child: Icon(icon, size: 11, color: color)),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))),
]);
}
Widget _buildTopRatedItem(String title, double rating, String? imagePath, ColorScheme colors) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 30, height: 42,
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: 12, color: colors.onSurface.withValues(alpha: 0.2)))
: Icon(Icons.image_outlined, size: 12, color: colors.onSurface.withValues(alpha: 0.2)),
),
const SizedBox(width: 8),
Expanded(child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.onSurface))),
const Icon(Icons.star, size: 14, color: Color(0xFFFFB800)),
const SizedBox(width: 3),
Text(rating.toStringAsFixed(1), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: colors.onSurface)),
],
),
);
}
// ─── 评分分布 ──────────────────────────────────────────────────────────
Widget _buildRatingDistribution(List<Movie> fm, List<Book> fb, List<Game> fg, ColorScheme colors) {
final allRatings = <double>[];
for (final m in fm) { if (m.rating != null) allRatings.add(m.rating!); }
for (final b in fb) { if (b.rating != null) allRatings.add(b.rating!); }
for (final g in fg) { if (g.rating != null) allRatings.add(g.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: '评分分布', colors: colors, child: Column(children: [
Row(children: [
Text('平均评分', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
const Spacer(),
Text(avg.toStringAsFixed(1), style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: colors.onSurface)),
Text(' / 10', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))),
]),
const SizedBox(height: 16),
SizedBox(height: 140, child: BarChart(BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxCount * 1.2, minY: 0,
barTouchData: BarTouchData(touchTooltipData: BarTouchTooltipData(
getTooltipColor: (_) => colors.inverseSurface,
getTooltipItem: (group, groupIndex, rod, rodIndex) => BarTooltipItem('${group.x + 1}${rod.toY.toInt()}', TextStyle(color: colors.onInverseSurface, fontSize: 11, fontWeight: FontWeight.w600)),
)),
titlesData: FlTitlesData(show: true,
bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, getTitlesWidget: (value, meta) => Padding(padding: const EdgeInsets.only(top: 4), child: Text('${value.toInt() + 1}', style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.4)))), reservedSize: 20)),
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: 16, borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
backDrawRodData: BackgroundBarChartRodData(show: true, toY: maxCount * 1.2, color: colors.outlineVariant.withValues(alpha: 0.3))),
])),
))),
]));
}
// ─── 年度趋势 ──────────────────────────────────────────────────────────
Widget _buildYearlyTrend(List<Movie> fm, List<Book> fb, List<Note> fn, List<Game> fg, ColorScheme colors) {
final now = DateTime.now();
final months = List.generate(12, (i) { final d = DateTime(now.year, now.month - (11 - i), 1); return '${d.month}'; });
List<int> countByMonth(List items) => List.generate(12, (i) {
final d = DateTime(now.year, now.month - (11 - i), 1);
return items.where((item) => (item as dynamic).createdAt.year == d.year && (item as dynamic).createdAt.month == d.month).length;
});
final movieData = countByMonth(fm), bookData = countByMonth(fb), noteData = countByMonth(fn), gameData = countByMonth(fg);
final allValues = [...movieData, ...bookData, ...noteData, ...gameData];
final maxVal = allValues.isEmpty ? 1 : allValues.reduce((a, b) => a > b ? a : b);
final safeMax = maxVal == 0 ? 1 : maxVal;
final lineBars = <LineChartBarData>[
if (_showMovies) _buildLineData(movieData, _movieColor),
if (_showBooks) _buildLineData(bookData, _bookColor),
if (_showNotes) _buildLineData(noteData, _noteColor),
if (_showGames) _buildLineData(gameData, _gameColor),
];
final legends = <(Color, String)>[];
if (_showMovies) legends.add((_movieColor, '影视'));
if (_showBooks) legends.add((_bookColor, '书籍'));
if (_showNotes) legends.add((_noteColor, '笔记'));
if (_showGames) legends.add((_gameColor, '游戏'));
return _buildCard(title: '年度趋势', colors: colors, child: Column(children: [
SizedBox(
height: 280,
child: LineChart(LineChartData(
minY: 0, maxY: (safeMax * 1.3).toDouble(),
lineBarsData: lineBars,
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: 9, 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 = legends.map((l) => l.$2).toList();
return LineTooltipItem('${labels[s.barIndex]} ${s.y.toInt()}', TextStyle(color: colors.onInverseSurface, fontSize: 11, fontWeight: FontWeight.w600));
}).toList(),
)),
)),
),
const SizedBox(height: 10),
Row(mainAxisAlignment: MainAxisAlignment.center, children: legends.asMap().entries.expand((e) {
return [<Widget>[_buildLegend(e.value.$1, e.value.$2, colors)], if (e.key < legends.length - 1) [const SizedBox(width: 14)]].expand((x) => x);
}).toList()),
]));
}
LineChartBarData _buildLineData(List<int> data, Color color) => 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: 2.5, color: color, strokeWidth: 0)),
belowBarData: BarAreaData(show: true, color: color.withValues(alpha: 0.08)),
);
Widget _buildLegend(Color color, String label, ColorScheme colors) => 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))),
]);
// ─── 星期分布 ──────────────────────────────────────────────────────────
Widget _buildWeekdayDistribution(List<Movie> fm, List<Book> fb, List<Note> fn, List<Game> fg, ColorScheme colors) {
final allDates = [...fm.map((m) => m.createdAt), ...fb.map((b) => b.createdAt), ...fn.map((n) => n.createdAt), ...fg.map((g) => g.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: '星期分布', colors: colors, child: SizedBox(height: 130, child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(7, (i) {
final ratio = weekdayCounts[i] / maxCount;
return Expanded(child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3), child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('${weekdayCounts[i]}', style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.35))),
const SizedBox(height: 3),
Container(height: (ratio * 80).clamp(4.0, 80.0), decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.5 + ratio * 0.5), borderRadius: BorderRadius.circular(3))),
const SizedBox(height: 5),
Text(dayLabels[i], style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.45))),
],
)));
}),
)));
}
// ─── 累计增长 ──────────────────────────────────────────────────────────
Widget _buildCumulativeGrowth(List<Movie> fm, List<Book> fb, List<Note> fn, List<Game> fg, ColorScheme colors) {
final allItems = [...fm.map((m) => m.createdAt), ...fb.map((b) => b.createdAt), ...fn.map((n) => n.createdAt), ...fg.map((g) => g.createdAt)];
if (allItems.isEmpty) return const SizedBox.shrink();
allItems.sort();
final monthlyCumulative = <int, int>{};
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);
cumulative += allItems.where((date) => !date.isBefore(d) && date.isBefore(nextMonth)).length;
monthlyCumulative[11 - i] = cumulative;
}
final maxVal = cumulative.toDouble();
if (maxVal == 0) return const SizedBox.shrink();
return _buildCard(title: '累计增长', colors: colors, child: SizedBox(height: 150, 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: 2.5, 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: 9, color: colors.onSurface.withValues(alpha: 0.4))));
}, reservedSize: 20)),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, getTitlesWidget: (value, meta) => Text('${value.toInt()}', style: TextStyle(fontSize: 9, 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),
))));
}
// ─── 标签词云 ──────────────────────────────────────────────────────────
Widget _buildTagCloud(List<Movie> fm, List<Book> fb, List<Note> fn, List<Game> fg, ColorScheme colors) {
final tabs = <String>[];
if (_showMovies) tabs.add('影视');
if (_showBooks) tabs.add('书籍');
if (_showNotes) tabs.add('笔记');
if (_showGames) tabs.add('游戏');
if (tabs.isEmpty) return const SizedBox.shrink();
if (_cloudTabIndex >= tabs.length) _cloudTabIndex = 0;
final tagCounts = <String, int>{};
switch (tabs[_cloudTabIndex]) {
case '影视': for (final m in fm) { for (final g in m.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } }
case '书籍': for (final b in fb) { for (final g in b.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } }
case '笔记': for (final n in fn) { for (final t in n.tags) { tagCounts[t] = (tagCounts[t] ?? 0) + 1; } }
case '游戏': for (final g in fg) { for (final ge in g.genres) { tagCounts[ge] = (tagCounts[ge] ?? 0) + 1; } }
}
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, 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: '标签词云', colors: colors, child: Column(children: [
Row(children: tabs.asMap().entries.map((e) {
final selected = _cloudTabIndex == e.key;
return Expanded(child: GestureDetector(
onTap: () => setState(() => _cloudTabIndex = e.key),
child: Container(padding: const EdgeInsets.symmetric(vertical: 6), margin: EdgeInsets.only(right: e.key < tabs.length - 1 ? 4 : 0),
decoration: BoxDecoration(color: selected ? colors.primary : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)),
child: Text(e.value, textAlign: TextAlign.center, style: TextStyle(fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5)))),
));
}).toList()),
const SizedBox(height: 14),
Wrap(spacing: 6, runSpacing: 4, children: sorted.map((e) {
final ratio = (e.value - minCount) / range;
final fontSize = (11.0 + ratio * 16.0).roundToDouble();
final c = cloudColors[((ratio * (cloudColors.length - 1)).round()).clamp(0, cloudColors.length - 1)];
return Text(e.key, style: TextStyle(fontSize: fontSize, fontWeight: fontSize > 18 ? FontWeight.w700 : FontWeight.w500, color: c, height: 1.3));
}).toList()),
]));
}
// ─── 趣味统计 ──────────────────────────────────────────────────────────
Widget _buildFunStats(List<Movie> fm, List<Book> fb, List<Note> fn, List<Game> fg, ColorScheme colors) {
final allItems = [...fm.map((m) => m.createdAt), ...fb.map((b) => b.createdAt), ...fn.map((n) => n.createdAt), ...fg.map((g) => g.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 = <String, int>{};
for (final m in fm) { for (final g in m.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } }
for (final b in fb) { for (final g in b.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } }
for (final n in fn) { for (final t in n.tags) { tagCounts[t] = (tagCounts[t] ?? 0) + 1; } }
for (final g in fg) { for (final ge in g.genres) { tagCounts[ge] = (tagCounts[ge] ?? 0) + 1; } }
final topTag = tagCounts.entries.isEmpty ? null : tagCounts.entries.reduce((a, b) => a.value >= b.value ? a : b);
return _buildCard(title: '趣味统计', colors: colors, child: Column(children: [
_buildFunCard(Icons.local_fire_department_outlined, '连续记录', maxStreak > 1 ? '$maxStreak' : '-', '最长连续记录天数', const Color(0xFFFF8F00), colors),
const SizedBox(height: 10),
_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(14),
decoration: BoxDecoration(color: color.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(10)),
child: Row(children: [
Container(width: 32, height: 32, decoration: BoxDecoration(color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)),
child: Icon(icon, size: 16, color: color)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 2),
Text(value, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color)),
])),
if (sub.isNotEmpty) Text(sub, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))),
]),
);
}
// ─── 通用卡片 ──────────────────────────────────────────────────────────
Widget _buildCard({required String title, required Widget child, required ColorScheme colors}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(width: 3, height: 12, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(1.5))),
const SizedBox(width: 8),
Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
]),
const SizedBox(height: 14),
child,
],
),
);
}
}
class _ModuleData {
final IconData icon;
final String label;
final int count;
final String sub;
final Color color;
final int tabIndex;
_ModuleData(this.icon, this.label, this.count, this.sub, this.color, this.tabIndex);
}