From 1fcbfb6874ad89b0666e4183408bd90f062f5623 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Wed, 15 Jul 2026 16:12:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=BB=E9=A1=B5=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/desktop_home_page.dart | 897 +++++++++++++++++++ lib/pages/home/home_page.dart | 167 ++-- lib/pages/home/main_content_page.dart | 1 + lib/pages/profile/feature_settings_page.dart | 24 +- lib/providers/app_provider.dart | 43 +- lib/utils/user_prefs.dart | 8 +- 6 files changed, 1036 insertions(+), 104 deletions(-) create mode 100644 lib/pages/home/desktop_home_page.dart diff --git a/lib/pages/home/desktop_home_page.dart b/lib/pages/home/desktop_home_page.dart new file mode 100644 index 0000000..f4048cd --- /dev/null +++ b/lib/pages/home/desktop_home_page.dart @@ -0,0 +1,897 @@ +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 createState() => _DesktopHomePageState(); +} + +class _DesktopHomePageState extends State { + 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? _cachedMovies; + List? _cachedBooks; + List? _cachedNotes; + List? _cachedGames; + List? _fm; + List? _fb; + List? _fn; + List? _fg; + + void _updateCache(List movies, List books, List notes, List 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>((p) => p.movies); + final books = context.select>((p) => p.books); + final notes = context.select>((p) => p.notes); + final games = context.select>((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 = []; + 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 fm, List fb, List fn, List 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(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().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 fm, List fb, List 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 _buildStatusRows(List items, String Function(dynamic) getStatus, Map 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 fm, List fb, List fn, List 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 = {}; + 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 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 fm, List fb, List fg, ColorScheme colors) { + final movieGenres = {}; + final bookGenres = {}; + final gameGenres = {}; + 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 = {}; + 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 = []; + 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 fm, ColorScheme colors) { + final counts = {}; + 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 fb, ColorScheme colors) { + final counts = {}; + 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 fm, List fb, List 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 fm, List fb, List fg, ColorScheme colors) { + final allRatings = []; + 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 fm, List fb, List fn, List 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 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 = [ + 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 [[_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 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 fm, List fb, List fn, List 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 fm, List fb, List fn, List 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 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 fm, List fb, List fn, List fg, ColorScheme colors) { + final tabs = []; + 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 = {}; + 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 fm, List fb, List fn, List 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 = {}; + 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); +} diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 5d73bf2..2abf5b3 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -15,6 +15,7 @@ import '../../widgets/add_sheet.dart'; import '../../widgets/add_type_selector.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../pages/epub_reader/epub_library_page.dart'; +import 'desktop_home_page.dart'; import '../../pages/movies/movie_detail_page.dart'; import '../../pages/book/book_detail_page.dart'; import '../../pages/note/note_detail_page.dart'; @@ -92,6 +93,7 @@ class _HomePageState extends State { return Consumer( builder: (context, provider, child) { _onNavIndexChanged(provider); + final isHomeTab = provider.mainTabIndex == -1; return Scaffold( body: Row( children: [ @@ -103,16 +105,22 @@ class _HomePageState extends State { }, ), VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant), - // 第二栏:列表面板(搜索 + 列表) - SizedBox( - width: 300, - child: _DesktopListPanel( - mainTabIndex: provider.mainTabIndex, + // 第二栏:列表面板(主页时隐藏) + if (!isHomeTab) ...[ + SizedBox( + width: 300, + child: _DesktopListPanel( + mainTabIndex: provider.mainTabIndex, + ), ), - ), - VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant), + VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant), + ], // 第三栏:内容区 - Expanded(child: _buildPageView(provider)), + Expanded( + child: isHomeTab + ? const DesktopHomePage() + : _buildPageView(provider), + ), ], ), ); @@ -341,6 +349,9 @@ class _DesktopIconRail extends StatelessWidget { }), _IconRailItem(icon: Icons.search, activeIcon: Icons.search, label: '搜索', accentColor: colors.primary, selected: false, onTap: () => _showSearchDialog(context)), const SizedBox(height: 2), + // 主页标签 + if (userPrefs.showDesktopHomeTab) + _IconRailItem(icon: Icons.dashboard_outlined, activeIcon: Icons.dashboard, label: '主页', accentColor: colors.primary, selected: mainTabIndex == -1, onTap: () => onTabSelected(-1)), // 分类图标 ...tabs.map((t) { final selected = mainTabIndex == t.$4; @@ -532,7 +543,7 @@ class _DesktopIconRail extends StatelessWidget { } void _showStrollDialog(BuildContext context) { - Navigator.of(context).push(_NoSwipeDialogRoute(builder: (_) => const _StrollDialog())); + showDialog(context: context, builder: (_) => const _StrollDialog()); } void _showCalendarDialog(BuildContext context) { @@ -581,11 +592,11 @@ class _EncounterDialog extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), - contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0), title: const Text('统计'), content: SizedBox( - width: 360, - height: 440, + width: 420, + height: 620, child: Consumer( builder: (context, provider, child) { final movies = provider.movies.where((m) => !m.isDeleted).toList(); @@ -996,70 +1007,61 @@ class _StrollDialogState extends State<_StrollDialog> { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return Dialog( + return AlertDialog( backgroundColor: colors.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - child: SizedBox( - width: 520, - height: 620, - child: Column( - children: [ - // 标题栏 - Padding( - padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), - child: Row(children: [ - const Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), - const Spacer(), - ...[('all', '全部'), ('movie', '影视'), ('book', '书籍'), ('note', '笔记')].map((f) => Padding( - padding: const EdgeInsets.only(left: 4), - child: GestureDetector( - onTap: () { if (_filter != f.$1) setState(() { _filter = f.$1; _items.clear(); _seenIds.clear(); _loadBatch(5); }); }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration(color: _filter == f.$1 ? colors.primary : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), - child: Text(f.$2, style: TextStyle(fontSize: 11, fontWeight: _filter == f.$1 ? FontWeight.w600 : FontWeight.normal, - color: _filter == f.$1 ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), - ), - ), - )), - const SizedBox(width: 4), - GestureDetector( - onTap: _reshuffle, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(14)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.casino_outlined, size: 13, color: colors.onPrimary), - const SizedBox(width: 3), - Text('随机', style: TextStyle(fontSize: 11, color: colors.onPrimary, fontWeight: FontWeight.w500)), - ]), - ), - ), - ]), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + title: Row(children: [ + const Text('漫步'), + const Spacer(), + ...[('all', '全部'), ('movie', '影视'), ('book', '书籍'), ('note', '笔记')].map((f) => Padding( + padding: const EdgeInsets.only(left: 4), + child: GestureDetector( + onTap: () { if (_filter != f.$1) setState(() { _filter = f.$1; _items.clear(); _seenIds.clear(); _loadBatch(5); }); }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: _filter == f.$1 ? colors.primary : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: Text(f.$2, style: TextStyle(fontSize: 11, fontWeight: _filter == f.$1 ? FontWeight.w600 : FontWeight.normal, + color: _filter == f.$1 ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), ), - // 内容 - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: _items.isEmpty - ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.explore_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2)), - const SizedBox(height: 12), - Text('还没有内容', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), - ])) - : PageView.builder( - controller: _pageController, - onPageChanged: (index) { if (index >= _items.length - 2) setState(() => _loadBatch(3)); }, - itemCount: _items.length, - itemBuilder: (context, index) { - return _buildCard(_items[index], colors); - }, - ), - ), - ), - ], + ), + )), + const SizedBox(width: 4), + GestureDetector( + onTap: _reshuffle, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(14)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.casino_outlined, size: 13, color: colors.onPrimary), + const SizedBox(width: 3), + Text('随机', style: TextStyle(fontSize: 11, color: colors.onPrimary, fontWeight: FontWeight.w500)), + ]), + ), ), + ]), + content: SizedBox( + width: 420, + height: 620, + child: _items.isEmpty + ? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.explore_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text('还没有内容', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), + ])) + : Padding( + padding: const EdgeInsets.only(top: 8), + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { if (index >= _items.length - 2) setState(() => _loadBatch(3)); }, + itemCount: _items.length, + itemBuilder: (context, index) { + return _buildCard(_items[index], colors); + }, + ), + ), ), ); } @@ -1301,7 +1303,7 @@ class _CalendarDialogState extends State<_CalendarDialog> { ]), content: SizedBox( width: 420, - height: 520, + height: 620, child: Column( children: [ // 星期头 @@ -1528,8 +1530,8 @@ class _PersonListDialogState extends State<_PersonListDialog> { IconButton(icon: const Icon(Icons.refresh, size: 20), onPressed: _refresh, padding: EdgeInsets.zero, constraints: const BoxConstraints()), ]), content: SizedBox( - width: 480, - height: 480, + width: 420, + height: 620, child: _selectedPerson != null ? _buildPersonDetail(_selectedPerson!, colors) : Column( @@ -1884,8 +1886,8 @@ class _TagManagementDialogState extends State<_TagManagementDialog> { IconButton(icon: const Icon(Icons.sync, size: 20), tooltip: '从数据中同步标签', onPressed: _syncTags, padding: EdgeInsets.zero, constraints: const BoxConstraints()), ]), content: SizedBox( - width: 520, - height: 440, + width: 420, + height: 620, child: Column( children: [ // 搜索栏 @@ -2454,7 +2456,7 @@ class _BackupChoiceDialogState extends State<_BackupChoiceDialog> { elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), - contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 0), + contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0), title: Row(children: [ const Text('备份'), const Spacer(), @@ -2471,8 +2473,8 @@ class _BackupChoiceDialogState extends State<_BackupChoiceDialog> { ), ]), content: SizedBox( - width: 480, - height: 440, + width: 420, + height: 620, child: _tabIndex == 0 ? const _LocalBackupContent() : const _WebDAVBackupContent(), @@ -3217,6 +3219,8 @@ class _RecycleBinDialogState extends State<_RecycleBinDialog> { backgroundColor: colors.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0), title: Row(children: [ const Text('回收站'), const Spacer(), @@ -3232,8 +3236,8 @@ class _RecycleBinDialogState extends State<_RecycleBinDialog> { ), ]), content: SizedBox( - width: 400, - height: 480, + width: 420, + height: 620, child: _isLoading ? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary)) : Column(children: [ @@ -3729,6 +3733,7 @@ class _DesktopListPanelState extends State<_DesktopListPanel> { Widget _buildCategoryList(BuildContext context) { switch (widget.mainTabIndex) { + case -1: return const SizedBox.shrink(); // 主页 — 面板已隐藏 case 0: return _buildMovieList(context); case 1: return _buildBookList(context); case 2: return _buildNoteList(context); diff --git a/lib/pages/home/main_content_page.dart b/lib/pages/home/main_content_page.dart index 475a28d..241be52 100644 --- a/lib/pages/home/main_content_page.dart +++ b/lib/pages/home/main_content_page.dart @@ -145,6 +145,7 @@ class _MainContentPageState extends State { String _getAppBarTitle(AppProvider provider) { switch (provider.mainTabIndex) { + case -1: return '主页'; case 0: return '影视'; case 1: return '阅读'; case 2: return '笔记'; diff --git a/lib/pages/profile/feature_settings_page.dart b/lib/pages/profile/feature_settings_page.dart index 37a4ae4..b88107c 100644 --- a/lib/pages/profile/feature_settings_page.dart +++ b/lib/pages/profile/feature_settings_page.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import '../../utils/user_prefs.dart'; import '../../utils/toast_util.dart'; @@ -13,6 +14,7 @@ class _FeatureSettingsPageState extends State { final UserPrefs _userPrefs = UserPrefs(); // 主界面 + bool _showDesktopHomeTab = true; bool _showMovieTab = true; bool _showBookTab = true; bool _showNoteTab = true; @@ -39,6 +41,7 @@ class _FeatureSettingsPageState extends State { void _loadSettings() { setState(() { + _showDesktopHomeTab = _userPrefs.showDesktopHomeTab; _showMovieTab = _userPrefs.showMovieTab; _showBookTab = _userPrefs.showBookTab; _showNoteTab = _userPrefs.showNoteTab; @@ -59,6 +62,7 @@ class _FeatureSettingsPageState extends State { int get _enabledTabCount { int count = 0; + if (_showDesktopHomeTab && Platform.isWindows) count++; if (_showMovieTab) count++; if (_showBookTab) count++; if (_showNoteTab) count++; @@ -67,13 +71,14 @@ class _FeatureSettingsPageState extends State { } List<(int, String, IconData)> get _enabledTabs { - final all = [ + final all = <(int, String, IconData)>[]; + if (_showDesktopHomeTab && Platform.isWindows) all.add((-1, '主页', Icons.dashboard_outlined)); + all.addAll([ (0, '影视', Icons.movie_outlined), (1, '阅读', Icons.menu_book_outlined), (2, '笔记', Icons.note_outlined), (3, '游戏', Icons.sports_esports_outlined), - ]; - return all.where((t) { + ].where((t) { return switch (t.$1) { 0 => _showMovieTab, 1 => _showBookTab, @@ -81,7 +86,8 @@ class _FeatureSettingsPageState extends State { 3 => _showGameTab, _ => false, }; - }).toList(); + })); + return all; } void _fixDefaultTabIndex() { @@ -160,6 +166,16 @@ class _FeatureSettingsPageState extends State { // ── 模块开关 ── _buildSectionHeader('模块开关'), + if (Platform.isWindows) ...[ + _buildSwitchItem(Icons.dashboard_outlined, '主页', '桌面端数据概览与分析', _showDesktopHomeTab, (v) async { + await _userPrefs.setShowDesktopHomeTab(v); + setState(() { + _showDesktopHomeTab = v; + _fixDefaultTabIndex(); + }); + }), + Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + ], _buildSwitchItem(Icons.movie_outlined, '观影', '记录和管理观影记录', _showMovieTab, _toggleMovieTab), Divider( diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index bfaeac8..124ac86 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:io'; import 'package:flutter/material.dart'; import '../models/data_models.dart'; import '../data/movie/movie_dao.dart'; @@ -175,24 +176,32 @@ class AppProvider extends ChangeNotifier { _gameLayoutStyle = userPrefs.gameLayoutStyle; _gameWallMode = userPrefs.gameWallMode; final defaultIndex = userPrefs.defaultMainTabIndex; - // 确保选中的标签是启用的 - final showMovie = userPrefs.showMovieTab; - final showBook = userPrefs.showBookTab; - final showNote = userPrefs.showNoteTab; - final showGame = userPrefs.showGameTab; - final enabled = [showMovie, showBook, showNote, showGame]; - if (defaultIndex >= 0 && defaultIndex < enabled.length && enabled[defaultIndex]) { - _mainTabIndex = defaultIndex; + // Windows 桌面端且主页开启时,强制默认为主页 + if (Platform.isWindows && userPrefs.showDesktopHomeTab) { + if (defaultIndex != -1) { + userPrefs.setDefaultMainTabIndex(-1); + } + _mainTabIndex = -1; } else { - // 回退到第一个启用的标签 - if (showMovie) { - _mainTabIndex = 0; - } else if (showBook) { - _mainTabIndex = 1; - } else if (showNote) { - _mainTabIndex = 2; - } else if (showGame) { - _mainTabIndex = 3; + // 确保选中的标签是启用的 + final showMovie = userPrefs.showMovieTab; + final showBook = userPrefs.showBookTab; + final showNote = userPrefs.showNoteTab; + final showGame = userPrefs.showGameTab; + final enabled = [showMovie, showBook, showNote, showGame]; + if (defaultIndex >= 0 && defaultIndex < enabled.length && enabled[defaultIndex]) { + _mainTabIndex = defaultIndex; + } else { + // 回退到第一个启用的标签 + if (showMovie) { + _mainTabIndex = 0; + } else if (showBook) { + _mainTabIndex = 1; + } else if (showNote) { + _mainTabIndex = 2; + } else if (showGame) { + _mainTabIndex = 3; + } } } notifyListeners(); diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 1666ccb..2fde908 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -108,10 +108,14 @@ class UserPrefs { bool get showGameTab => prefs.getBool('showGameTab') ?? false; Future setShowGameTab(bool value) => prefs.setBool('showGameTab', value); - /// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记) - int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0; + /// 默认启动标签 (-1: 主页, 0: 影视, 1: 阅读, 2: 笔记, 3: 游戏) + int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? -1; Future setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value); + /// 桌面端是否显示主页标签 + bool get showDesktopHomeTab => prefs.getBool('showDesktopHomeTab') ?? true; + Future setShowDesktopHomeTab(bool value) => prefs.setBool('showDesktopHomeTab', value); + // ─── 侧边栏功能开关 ─── bool get showSidebarHeatmap => prefs.getBool('showSidebarHeatmap') ?? true;