generated from dellevin/template
898 lines
48 KiB
Dart
898 lines
48 KiB
Dart
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.note_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);
|
||
}
|