添加主页显示!

This commit is contained in:
DelLevin-Home
2026-07-15 16:12:19 +08:00
parent 5dc8d64857
commit 1fcbfb6874
6 changed files with 1036 additions and 104 deletions

View File

@@ -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<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);
}

View File

@@ -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<HomePage> {
return Consumer<AppProvider>(
builder: (context, provider, child) {
_onNavIndexChanged(provider);
final isHomeTab = provider.mainTabIndex == -1;
return Scaffold(
body: Row(
children: [
@@ -103,7 +105,8 @@ class _HomePageState extends State<HomePage> {
},
),
VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant),
// 第二栏:列表面板(搜索 + 列表
// 第二栏:列表面板(主页时隐藏
if (!isHomeTab) ...[
SizedBox(
width: 300,
child: _DesktopListPanel(
@@ -111,8 +114,13 @@ class _HomePageState extends State<HomePage> {
),
),
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<AppProvider>(
builder: (context, provider, child) {
final movies = provider.movies.where((m) => !m.isDeleted).toList();
@@ -996,20 +1007,14 @@ 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)),
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),
@@ -1037,18 +1042,18 @@ class _StrollDialogState extends State<_StrollDialog> {
),
),
]),
),
// 内容
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8),
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))),
]))
: PageView.builder(
: 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,
@@ -1058,9 +1063,6 @@ class _StrollDialogState extends State<_StrollDialog> {
),
),
),
],
),
),
);
}
@@ -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);

View File

@@ -145,6 +145,7 @@ class _MainContentPageState extends State<MainContentPage> {
String _getAppBarTitle(AppProvider provider) {
switch (provider.mainTabIndex) {
case -1: return '主页';
case 0: return '影视';
case 1: return '阅读';
case 2: return '笔记';

View File

@@ -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<FeatureSettingsPage> {
final UserPrefs _userPrefs = UserPrefs();
// 主界面
bool _showDesktopHomeTab = true;
bool _showMovieTab = true;
bool _showBookTab = true;
bool _showNoteTab = true;
@@ -39,6 +41,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
void _loadSettings() {
setState(() {
_showDesktopHomeTab = _userPrefs.showDesktopHomeTab;
_showMovieTab = _userPrefs.showMovieTab;
_showBookTab = _userPrefs.showBookTab;
_showNoteTab = _userPrefs.showNoteTab;
@@ -59,6 +62,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
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<FeatureSettingsPage> {
}
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<FeatureSettingsPage> {
3 => _showGameTab,
_ => false,
};
}).toList();
}));
return all;
}
void _fixDefaultTabIndex() {
@@ -160,6 +166,16 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
// ── 模块开关 ──
_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(

View File

@@ -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,6 +176,13 @@ class AppProvider extends ChangeNotifier {
_gameLayoutStyle = userPrefs.gameLayoutStyle;
_gameWallMode = userPrefs.gameWallMode;
final defaultIndex = userPrefs.defaultMainTabIndex;
// Windows 桌面端且主页开启时,强制默认为主页
if (Platform.isWindows && userPrefs.showDesktopHomeTab) {
if (defaultIndex != -1) {
userPrefs.setDefaultMainTabIndex(-1);
}
_mainTabIndex = -1;
} else {
// 确保选中的标签是启用的
final showMovie = userPrefs.showMovieTab;
final showBook = userPrefs.showBookTab;
@@ -195,6 +203,7 @@ class AppProvider extends ChangeNotifier {
_mainTabIndex = 3;
}
}
}
notifyListeners();
}

View File

@@ -108,10 +108,14 @@ class UserPrefs {
bool get showGameTab => prefs.getBool('showGameTab') ?? false;
Future<bool> 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<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
/// 桌面端是否显示主页标签
bool get showDesktopHomeTab => prefs.getBool('showDesktopHomeTab') ?? true;
Future<bool> setShowDesktopHomeTab(bool value) => prefs.setBool('showDesktopHomeTab', value);
// ─── 侧边栏功能开关 ───
bool get showSidebarHeatmap => prefs.getBool('showSidebarHeatmap') ?? true;