generated from dellevin/template
添加主页显示!
This commit is contained in:
897
lib/pages/home/desktop_home_page.dart
Normal file
897
lib/pages/home/desktop_home_page.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import '../../widgets/add_sheet.dart';
|
|||||||
import '../../widgets/add_type_selector.dart';
|
import '../../widgets/add_type_selector.dart';
|
||||||
import '../../widgets/fade_in_local_image.dart';
|
import '../../widgets/fade_in_local_image.dart';
|
||||||
import '../../pages/epub_reader/epub_library_page.dart';
|
import '../../pages/epub_reader/epub_library_page.dart';
|
||||||
|
import 'desktop_home_page.dart';
|
||||||
import '../../pages/movies/movie_detail_page.dart';
|
import '../../pages/movies/movie_detail_page.dart';
|
||||||
import '../../pages/book/book_detail_page.dart';
|
import '../../pages/book/book_detail_page.dart';
|
||||||
import '../../pages/note/note_detail_page.dart';
|
import '../../pages/note/note_detail_page.dart';
|
||||||
@@ -92,6 +93,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
return Consumer<AppProvider>(
|
return Consumer<AppProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
_onNavIndexChanged(provider);
|
_onNavIndexChanged(provider);
|
||||||
|
final isHomeTab = provider.mainTabIndex == -1;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -103,16 +105,22 @@ class _HomePageState extends State<HomePage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant),
|
VerticalDivider(width: 1, thickness: 1, color: colors.outlineVariant),
|
||||||
// 第二栏:列表面板(搜索 + 列表)
|
// 第二栏:列表面板(主页时隐藏)
|
||||||
SizedBox(
|
if (!isHomeTab) ...[
|
||||||
width: 300,
|
SizedBox(
|
||||||
child: _DesktopListPanel(
|
width: 300,
|
||||||
mainTabIndex: provider.mainTabIndex,
|
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)),
|
_IconRailItem(icon: Icons.search, activeIcon: Icons.search, label: '搜索', accentColor: colors.primary, selected: false, onTap: () => _showSearchDialog(context)),
|
||||||
const SizedBox(height: 2),
|
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) {
|
...tabs.map((t) {
|
||||||
final selected = mainTabIndex == t.$4;
|
final selected = mainTabIndex == t.$4;
|
||||||
@@ -532,7 +543,7 @@ class _DesktopIconRail extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showStrollDialog(BuildContext context) {
|
void _showStrollDialog(BuildContext context) {
|
||||||
Navigator.of(context).push(_NoSwipeDialogRoute(builder: (_) => const _StrollDialog()));
|
showDialog(context: context, builder: (_) => const _StrollDialog());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showCalendarDialog(BuildContext context) {
|
void _showCalendarDialog(BuildContext context) {
|
||||||
@@ -581,11 +592,11 @@ class _EncounterDialog extends StatelessWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
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('统计'),
|
title: const Text('统计'),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 360,
|
width: 420,
|
||||||
height: 440,
|
height: 620,
|
||||||
child: Consumer<AppProvider>(
|
child: Consumer<AppProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
final movies = provider.movies.where((m) => !m.isDeleted).toList();
|
final movies = provider.movies.where((m) => !m.isDeleted).toList();
|
||||||
@@ -996,70 +1007,61 @@ class _StrollDialogState extends State<_StrollDialog> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
return Dialog(
|
return AlertDialog(
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
child: SizedBox(
|
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||||
width: 520,
|
contentPadding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
|
||||||
height: 620,
|
title: Row(children: [
|
||||||
child: Column(
|
const Text('漫步'),
|
||||||
children: [
|
const Spacer(),
|
||||||
// 标题栏
|
...[('all', '全部'), ('movie', '影视'), ('book', '书籍'), ('note', '笔记')].map((f) => Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.only(left: 4),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
|
child: GestureDetector(
|
||||||
child: Row(children: [
|
onTap: () { if (_filter != f.$1) setState(() { _filter = f.$1; _items.clear(); _seenIds.clear(); _loadBatch(5); }); },
|
||||||
const Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
child: Container(
|
||||||
const Spacer(),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
...[('all', '全部'), ('movie', '影视'), ('book', '书籍'), ('note', '笔记')].map((f) => Padding(
|
decoration: BoxDecoration(color: _filter == f.$1 ? colors.primary : colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)),
|
||||||
padding: const EdgeInsets.only(left: 4),
|
child: Text(f.$2, style: TextStyle(fontSize: 11, fontWeight: _filter == f.$1 ? FontWeight.w600 : FontWeight.normal,
|
||||||
child: GestureDetector(
|
color: _filter == f.$1 ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))),
|
||||||
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)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
// 内容
|
),
|
||||||
Expanded(
|
)),
|
||||||
child: Padding(
|
const SizedBox(width: 4),
|
||||||
padding: const EdgeInsets.only(top: 8),
|
GestureDetector(
|
||||||
child: _items.isEmpty
|
onTap: _reshuffle,
|
||||||
? Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Container(
|
||||||
Icon(Icons.explore_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2)),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
const SizedBox(height: 12),
|
decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(14)),
|
||||||
Text('还没有内容', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
]))
|
Icon(Icons.casino_outlined, size: 13, color: colors.onPrimary),
|
||||||
: PageView.builder(
|
const SizedBox(width: 3),
|
||||||
controller: _pageController,
|
Text('随机', style: TextStyle(fontSize: 11, color: colors.onPrimary, fontWeight: FontWeight.w500)),
|
||||||
onPageChanged: (index) { if (index >= _items.length - 2) setState(() => _loadBatch(3)); },
|
]),
|
||||||
itemCount: _items.length,
|
),
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return _buildCard(_items[index], colors);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
|
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(
|
content: SizedBox(
|
||||||
width: 420,
|
width: 420,
|
||||||
height: 520,
|
height: 620,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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()),
|
IconButton(icon: const Icon(Icons.refresh, size: 20), onPressed: _refresh, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
|
||||||
]),
|
]),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 480,
|
width: 420,
|
||||||
height: 480,
|
height: 620,
|
||||||
child: _selectedPerson != null
|
child: _selectedPerson != null
|
||||||
? _buildPersonDetail(_selectedPerson!, colors)
|
? _buildPersonDetail(_selectedPerson!, colors)
|
||||||
: Column(
|
: 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()),
|
IconButton(icon: const Icon(Icons.sync, size: 20), tooltip: '从数据中同步标签', onPressed: _syncTags, padding: EdgeInsets.zero, constraints: const BoxConstraints()),
|
||||||
]),
|
]),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 520,
|
width: 420,
|
||||||
height: 440,
|
height: 620,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 搜索栏
|
// 搜索栏
|
||||||
@@ -2454,7 +2456,7 @@ class _BackupChoiceDialogState extends State<_BackupChoiceDialog> {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
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: [
|
title: Row(children: [
|
||||||
const Text('备份'),
|
const Text('备份'),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -2471,8 +2473,8 @@ class _BackupChoiceDialogState extends State<_BackupChoiceDialog> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 480,
|
width: 420,
|
||||||
height: 440,
|
height: 620,
|
||||||
child: _tabIndex == 0
|
child: _tabIndex == 0
|
||||||
? const _LocalBackupContent()
|
? const _LocalBackupContent()
|
||||||
: const _WebDAVBackupContent(),
|
: const _WebDAVBackupContent(),
|
||||||
@@ -3217,6 +3219,8 @@ class _RecycleBinDialogState extends State<_RecycleBinDialog> {
|
|||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
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: [
|
title: Row(children: [
|
||||||
const Text('回收站'),
|
const Text('回收站'),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -3232,8 +3236,8 @@ class _RecycleBinDialogState extends State<_RecycleBinDialog> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 400,
|
width: 420,
|
||||||
height: 480,
|
height: 620,
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))
|
? Center(child: CircularProgressIndicator(strokeWidth: 2, color: colors.primary))
|
||||||
: Column(children: [
|
: Column(children: [
|
||||||
@@ -3729,6 +3733,7 @@ class _DesktopListPanelState extends State<_DesktopListPanel> {
|
|||||||
|
|
||||||
Widget _buildCategoryList(BuildContext context) {
|
Widget _buildCategoryList(BuildContext context) {
|
||||||
switch (widget.mainTabIndex) {
|
switch (widget.mainTabIndex) {
|
||||||
|
case -1: return const SizedBox.shrink(); // 主页 — 面板已隐藏
|
||||||
case 0: return _buildMovieList(context);
|
case 0: return _buildMovieList(context);
|
||||||
case 1: return _buildBookList(context);
|
case 1: return _buildBookList(context);
|
||||||
case 2: return _buildNoteList(context);
|
case 2: return _buildNoteList(context);
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
|
|
||||||
String _getAppBarTitle(AppProvider provider) {
|
String _getAppBarTitle(AppProvider provider) {
|
||||||
switch (provider.mainTabIndex) {
|
switch (provider.mainTabIndex) {
|
||||||
|
case -1: return '主页';
|
||||||
case 0: return '影视';
|
case 0: return '影视';
|
||||||
case 1: return '阅读';
|
case 1: return '阅读';
|
||||||
case 2: return '笔记';
|
case 2: return '笔记';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../utils/user_prefs.dart';
|
import '../../utils/user_prefs.dart';
|
||||||
import '../../utils/toast_util.dart';
|
import '../../utils/toast_util.dart';
|
||||||
@@ -13,6 +14,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
final UserPrefs _userPrefs = UserPrefs();
|
final UserPrefs _userPrefs = UserPrefs();
|
||||||
|
|
||||||
// 主界面
|
// 主界面
|
||||||
|
bool _showDesktopHomeTab = true;
|
||||||
bool _showMovieTab = true;
|
bool _showMovieTab = true;
|
||||||
bool _showBookTab = true;
|
bool _showBookTab = true;
|
||||||
bool _showNoteTab = true;
|
bool _showNoteTab = true;
|
||||||
@@ -39,6 +41,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
|
|
||||||
void _loadSettings() {
|
void _loadSettings() {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_showDesktopHomeTab = _userPrefs.showDesktopHomeTab;
|
||||||
_showMovieTab = _userPrefs.showMovieTab;
|
_showMovieTab = _userPrefs.showMovieTab;
|
||||||
_showBookTab = _userPrefs.showBookTab;
|
_showBookTab = _userPrefs.showBookTab;
|
||||||
_showNoteTab = _userPrefs.showNoteTab;
|
_showNoteTab = _userPrefs.showNoteTab;
|
||||||
@@ -59,6 +62,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
|
|
||||||
int get _enabledTabCount {
|
int get _enabledTabCount {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
|
if (_showDesktopHomeTab && Platform.isWindows) count++;
|
||||||
if (_showMovieTab) count++;
|
if (_showMovieTab) count++;
|
||||||
if (_showBookTab) count++;
|
if (_showBookTab) count++;
|
||||||
if (_showNoteTab) count++;
|
if (_showNoteTab) count++;
|
||||||
@@ -67,13 +71,14 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<(int, String, IconData)> get _enabledTabs {
|
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),
|
(0, '影视', Icons.movie_outlined),
|
||||||
(1, '阅读', Icons.menu_book_outlined),
|
(1, '阅读', Icons.menu_book_outlined),
|
||||||
(2, '笔记', Icons.note_outlined),
|
(2, '笔记', Icons.note_outlined),
|
||||||
(3, '游戏', Icons.sports_esports_outlined),
|
(3, '游戏', Icons.sports_esports_outlined),
|
||||||
];
|
].where((t) {
|
||||||
return all.where((t) {
|
|
||||||
return switch (t.$1) {
|
return switch (t.$1) {
|
||||||
0 => _showMovieTab,
|
0 => _showMovieTab,
|
||||||
1 => _showBookTab,
|
1 => _showBookTab,
|
||||||
@@ -81,7 +86,8 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
3 => _showGameTab,
|
3 => _showGameTab,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}).toList();
|
}));
|
||||||
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fixDefaultTabIndex() {
|
void _fixDefaultTabIndex() {
|
||||||
@@ -160,6 +166,16 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
|
|
||||||
// ── 模块开关 ──
|
// ── 模块开关 ──
|
||||||
_buildSectionHeader('模块开关'),
|
_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, '观影', '记录和管理观影记录',
|
_buildSwitchItem(Icons.movie_outlined, '观影', '记录和管理观影记录',
|
||||||
_showMovieTab, _toggleMovieTab),
|
_showMovieTab, _toggleMovieTab),
|
||||||
Divider(
|
Divider(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/data_models.dart';
|
import '../models/data_models.dart';
|
||||||
import '../data/movie/movie_dao.dart';
|
import '../data/movie/movie_dao.dart';
|
||||||
@@ -175,24 +176,32 @@ class AppProvider extends ChangeNotifier {
|
|||||||
_gameLayoutStyle = userPrefs.gameLayoutStyle;
|
_gameLayoutStyle = userPrefs.gameLayoutStyle;
|
||||||
_gameWallMode = userPrefs.gameWallMode;
|
_gameWallMode = userPrefs.gameWallMode;
|
||||||
final defaultIndex = userPrefs.defaultMainTabIndex;
|
final defaultIndex = userPrefs.defaultMainTabIndex;
|
||||||
// 确保选中的标签是启用的
|
// Windows 桌面端且主页开启时,强制默认为主页
|
||||||
final showMovie = userPrefs.showMovieTab;
|
if (Platform.isWindows && userPrefs.showDesktopHomeTab) {
|
||||||
final showBook = userPrefs.showBookTab;
|
if (defaultIndex != -1) {
|
||||||
final showNote = userPrefs.showNoteTab;
|
userPrefs.setDefaultMainTabIndex(-1);
|
||||||
final showGame = userPrefs.showGameTab;
|
}
|
||||||
final enabled = [showMovie, showBook, showNote, showGame];
|
_mainTabIndex = -1;
|
||||||
if (defaultIndex >= 0 && defaultIndex < enabled.length && enabled[defaultIndex]) {
|
|
||||||
_mainTabIndex = defaultIndex;
|
|
||||||
} else {
|
} else {
|
||||||
// 回退到第一个启用的标签
|
// 确保选中的标签是启用的
|
||||||
if (showMovie) {
|
final showMovie = userPrefs.showMovieTab;
|
||||||
_mainTabIndex = 0;
|
final showBook = userPrefs.showBookTab;
|
||||||
} else if (showBook) {
|
final showNote = userPrefs.showNoteTab;
|
||||||
_mainTabIndex = 1;
|
final showGame = userPrefs.showGameTab;
|
||||||
} else if (showNote) {
|
final enabled = [showMovie, showBook, showNote, showGame];
|
||||||
_mainTabIndex = 2;
|
if (defaultIndex >= 0 && defaultIndex < enabled.length && enabled[defaultIndex]) {
|
||||||
} else if (showGame) {
|
_mainTabIndex = defaultIndex;
|
||||||
_mainTabIndex = 3;
|
} else {
|
||||||
|
// 回退到第一个启用的标签
|
||||||
|
if (showMovie) {
|
||||||
|
_mainTabIndex = 0;
|
||||||
|
} else if (showBook) {
|
||||||
|
_mainTabIndex = 1;
|
||||||
|
} else if (showNote) {
|
||||||
|
_mainTabIndex = 2;
|
||||||
|
} else if (showGame) {
|
||||||
|
_mainTabIndex = 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -108,10 +108,14 @@ class UserPrefs {
|
|||||||
bool get showGameTab => prefs.getBool('showGameTab') ?? false;
|
bool get showGameTab => prefs.getBool('showGameTab') ?? false;
|
||||||
Future<bool> setShowGameTab(bool value) => prefs.setBool('showGameTab', value);
|
Future<bool> setShowGameTab(bool value) => prefs.setBool('showGameTab', value);
|
||||||
|
|
||||||
/// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记)
|
/// 默认启动标签 (-1: 主页, 0: 影视, 1: 阅读, 2: 笔记, 3: 游戏)
|
||||||
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
|
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? -1;
|
||||||
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
|
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;
|
bool get showSidebarHeatmap => prefs.getBool('showSidebarHeatmap') ?? true;
|
||||||
|
|||||||
Reference in New Issue
Block a user