优化统计界面

This commit is contained in:
DelLevin-Home
2026-06-27 22:14:19 +08:00
parent a1fe0a23ef
commit c810a1a381
4 changed files with 812 additions and 119 deletions

View File

@@ -0,0 +1,435 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../utils/user_prefs.dart';
/// 相遇统计页
class EncounterPage extends StatelessWidget {
const EncounterPage({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final userPrefs = UserPrefs();
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;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('统计')),
body: Consumer<AppProvider>(
builder: (context, provider, child) {
final movies = provider.movies.where((m) => !m.isDeleted).toList();
final books = provider.books.where((b) => !b.isDeleted).toList();
final notes = provider.notes.where((n) => !n.isDeleted).toList();
final noteWords = notes.fold<int>(0, (sum, n) => sum + n.content.length);
int imageCount = 0;
for (final m in movies) {
if (m.posterPath != null && m.posterPath!.isNotEmpty) imageCount++;
}
for (final b in books) {
if (b.coverPath != null && b.coverPath!.isNotEmpty) imageCount++;
}
for (final n in notes) {
imageCount += n.images.length;
}
final totalRecords = movies.length + books.length + notes.length;
return CustomScrollView(
slivers: [
// 上半部分:与你 + 天数
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 48),
Text(
'与你',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: colors.onSurface,
letterSpacing: 4,
),
),
const SizedBox(height: 12),
RichText(
text: TextSpan(
children: [
TextSpan(
text: '相遇的第',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: colors.onSurface.withValues(alpha: 0.5),
),
),
TextSpan(
text: '$days',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: colors.primary,
),
),
TextSpan(
text: '',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: colors.onSurface.withValues(alpha: 0.5),
),
),
],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'${firstUse.year}${firstUse.month}${firstUse.day}日 — ${now.year}${now.month}${now.day}',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
),
),
),
// 下半部分:用 SliverFillRemaining 推到底部
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Divider(color: colors.outlineVariant, thickness: 0.5),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_statItem(context, '${movies.length}', '影视', Icons.movie_outlined),
_statItem(context, '${books.length}', '书籍', Icons.menu_book_outlined),
_statItem(context, '${notes.length}', '笔记', Icons.note_outlined),
],
),
const SizedBox(height: 24),
Divider(color: colors.outlineVariant, thickness: 0.5),
const SizedBox(height: 24),
Text(
'已记录',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.5),
letterSpacing: 2,
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_recordItem(context, '$totalRecords', '条记录'),
_recordItem(context, _formatCount(noteWords), '文字'),
_recordItem(context, '$imageCount', '张图片'),
],
),
const SizedBox(height: 24),
Divider(color: colors.outlineVariant, thickness: 0.5),
const SizedBox(height: 32),
_buildCuteAnimation(colors),
const SizedBox(height: 32),
],
),
),
),
],
);
},
),
);
}
String _formatCount(int count) {
if (count >= 10000) return '${(count / 10000).toStringAsFixed(1)}';
if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}k';
return '$count';
}
Widget _statItem(BuildContext context, String value, String label, IconData icon) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: colors.onSurface),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)),
),
],
);
}
Widget _recordItem(BuildContext context, String value, String label) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
Text(
value,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.primary),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4)),
),
],
);
}
/// 慢速滚动城市天际线
Widget _buildCuteAnimation(ColorScheme colors) {
return SizedBox(
height: 72,
child: _CityScape(colors: colors),
);
}
}
class _CityScape extends StatefulWidget {
final ColorScheme colors;
const _CityScape({required this.colors});
@override
State<_CityScape> createState() => _CityScapeState();
}
class _CityScapeState extends State<_CityScape> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 20),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
size: const Size(double.infinity, 72),
painter: _CityPainter(_controller.value, widget.colors),
);
},
);
}
}
class _CityPainter extends CustomPainter {
final double t;
final ColorScheme colors;
_CityPainter(this.t, this.colors);
// 后层建筑数据(高度比, 宽度, 是否尖顶)
static const _buildings = <(double, double, bool)>[
(0.30, 16, false),
(0.48, 10, true),
(0.22, 20, false),
(0.55, 10, true),
(0.35, 14, false),
(0.42, 10, true),
(0.25, 18, false),
];
// 前层绿植数据(高度比, 宽度, 类型: 0=圆冠, 1=松树, 2=灌木)
static const _plants = <(double, double, int)>[
(0.35, 10, 0),
(0.50, 8, 1),
(0.22, 14, 2),
(0.45, 8, 0),
(0.30, 12, 1),
(0.20, 10, 2),
(0.52, 8, 1),
(0.28, 14, 2),
];
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
final groundY = size.height - 2;
// 星星
_drawStars(canvas, paint, size, t);
// 后层建筑(慢 0.3x,浅色)
_drawBuildings(canvas, paint, size, groundY, t * 0.6);
// 前层绿植(快 1.5x,深色)
_drawPlants(canvas, paint, size, groundY, t * 1.0);
// 地面线
paint.color = colors.onSurface.withValues(alpha: 0.10);
canvas.drawRect(Rect.fromLTWH(0, groundY, size.width, 1), paint);
}
// ── 后层:建筑 ──
void _drawBuildings(Canvas canvas, Paint paint, Size size, double groundY, double scrollT) {
double totalW = 0;
for (final b in _buildings) {
totalW += b.$2 + 4;
}
final offset = (scrollT * totalW) % totalW;
double x = -offset;
int i = 0;
while (x < size.width + 20) {
final (hR, w, spire) = _buildings[i % _buildings.length];
final h = hR * (size.height - 8);
final bx = x;
final by = groundY - h;
if (bx + w > -10 && bx < size.width + 10) {
final a = 0.06 + hR * 0.05;
paint.color = colors.onSurface.withValues(alpha: a);
canvas.drawRect(Rect.fromLTWH(bx, by, w, h), paint);
// 窗户
if (h > 18) {
paint.color = colors.onSurface.withValues(alpha: 0.04);
for (int r = 0; r < ((h - 6) / 5).floor(); r++) {
for (int c = 0; c < ((w - 4) / 4).floor(); c++) {
if ((i * 13 + r * 7 + c * 11) % 4 == 0) continue;
canvas.drawRect(Rect.fromLTWH(bx + 3 + c * 4.0, by + 4 + r * 5.0, 2, 2), paint);
}
}
}
if (spire) {
paint.color = colors.onSurface.withValues(alpha: a);
final sh = h * 0.18;
canvas.drawPath(
Path()
..moveTo(bx + w / 2 - 2, by)
..lineTo(bx + w / 2, by - sh)
..lineTo(bx + w / 2 + 2, by)
..close(),
paint,
);
}
if (!spire && hR > 0.4 && i % 3 == 0) {
paint.color = colors.onSurface.withValues(alpha: a * 0.5);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 0.5, by - 6, 1, 6), paint);
canvas.drawCircle(Offset(bx + w / 2, by - 6), 1.2, paint);
}
}
x += w + 16;
i++;
}
}
// ── 前层:绿植 ──
void _drawPlants(Canvas canvas, Paint paint, Size size, double groundY, double scrollT) {
double totalW = 0;
for (final p in _plants) {
totalW += p.$2 + 6;
}
final offset = (scrollT * totalW) % totalW;
double x = -offset;
int i = 0;
while (x < size.width + 20) {
final (hR, w, type) = _plants[i % _plants.length];
final h = hR * (size.height - 10);
final bx = x;
final by = groundY;
if (bx + w > -10 && bx < size.width + 10) {
final alpha = 0.18 + hR * 0.10;
if (type == 0) {
// 圆冠树
final trunkH = h * 0.4;
final crownR = w * 0.45;
paint.color = colors.onSurface.withValues(alpha: alpha * 0.7);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 1.5, by - trunkH, 3, trunkH), paint);
paint.color = colors.onSurface.withValues(alpha: alpha);
canvas.drawOval(
Rect.fromCenter(center: Offset(bx + w / 2, by - trunkH - crownR * 0.6), width: crownR * 2, height: crownR * 1.6),
paint,
);
} else if (type == 1) {
// 松树
final trunkH = h * 0.25;
paint.color = colors.onSurface.withValues(alpha: alpha * 0.7);
canvas.drawRect(Rect.fromLTWH(bx + w / 2 - 1.5, by - trunkH, 3, trunkH), paint);
paint.color = colors.onSurface.withValues(alpha: alpha);
for (int layer = 0; layer < 3; layer++) {
final layerW = w * (1.0 - layer * 0.2);
final layerBottom = by - trunkH - layer * (h * 0.2);
final layerTop = layerBottom - h * 0.28;
canvas.drawPath(
Path()
..moveTo(bx + w / 2 - layerW / 2, layerBottom)
..lineTo(bx + w / 2, layerTop)
..lineTo(bx + w / 2 + layerW / 2, layerBottom)
..close(),
paint,
);
}
} else {
// 灌木
paint.color = colors.onSurface.withValues(alpha: alpha);
canvas.drawOval(Rect.fromLTWH(bx, by - h, w, h), paint);
paint.color = colors.onSurface.withValues(alpha: alpha * 0.8);
canvas.drawOval(Rect.fromLTWH(bx + w * 0.2, by - h * 0.7, w * 0.6, h * 0.6), paint);
}
}
x += w + 14;
i++;
}
}
void _drawStars(Canvas canvas, Paint paint, Size size, double t) {
const stars = [
(12.0, 5.0, 1.2), (38.0, 12.0, 0.8), (65.0, 3.0, 1.0),
(95.0, 16.0, 1.4), (130.0, 7.0, 0.9), (165.0, 14.0, 1.1),
(200.0, 4.0, 1.3), (235.0, 18.0, 0.7), (270.0, 9.0, 1.0),
(310.0, 2.0, 1.2), (345.0, 15.0, 0.9), (380.0, 6.0, 1.1),
(420.0, 11.0, 0.8), (460.0, 3.0, 1.0), (500.0, 17.0, 1.3),
];
for (int i = 0; i < stars.length; i++) {
final (sx, sy, r) = stars[i];
if (sx > size.width) continue;
final flicker = 0.15 + 0.12 * sin(t * 2 * pi + i * 1.1);
paint.color = colors.onSurface.withValues(alpha: flicker);
canvas.drawCircle(Offset(sx, sy), r, paint);
}
}
@override
bool shouldRepaint(_CityPainter old) => old.t != t;
}

View File

@@ -900,6 +900,18 @@ class _SettingsPageState extends State<SettingsPage> {
MaterialPageRoute(
builder: (_) => const MainContentSettingsPage())),
),
Divider(
height: 0.5,
indent: 24,
endIndent: 24,
color: colors.outlineVariant),
_buildNavigationItem(
icon: Icons.view_sidebar_outlined,
title: '侧边栏功能设置',
subtitle: '控制侧边栏显示的功能模块',
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const SidebarSettingsPage())),
),
Divider(
height: 0.5,
indent: 24,
@@ -2415,3 +2427,160 @@ class _WebViewPageState extends State<WebViewPage> {
);
}
}
// ─── 侧边栏功能设置 ───
class SidebarSettingsPage extends StatefulWidget {
const SidebarSettingsPage({super.key});
@override
State<SidebarSettingsPage> createState() => _SidebarSettingsPageState();
}
class _SidebarSettingsPageState extends State<SidebarSettingsPage> {
final UserPrefs _userPrefs = UserPrefs();
bool _showHeatmap = true;
bool _showRecent = true;
bool _showEncounter = true;
bool _showStroll = true;
bool _showCalendar = true;
bool _showPerson = true;
bool _showTags = true;
bool _showMdReader = true;
bool _showEpub = true;
@override
void initState() {
super.initState();
_loadSettings();
}
void _loadSettings() {
setState(() {
_showHeatmap = _userPrefs.showSidebarHeatmap;
_showRecent = _userPrefs.showSidebarRecent;
_showEncounter = _userPrefs.showSidebarEncounter;
_showStroll = _userPrefs.showSidebarStroll;
_showCalendar = _userPrefs.showSidebarCalendar;
_showPerson = _userPrefs.showSidebarPerson;
_showTags = _userPrefs.showSidebarTags;
_showMdReader = _userPrefs.showSidebarMdReader;
_showEpub = _userPrefs.showSidebarEpub;
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(title: const Text('侧边栏功能设置')),
body: ListView(
children: [
_buildSectionHeader('信息模块'),
_buildSwitchItem(Icons.calendar_today, '热力图', '显示创作活跃度热力图',
_showHeatmap, (v) async {
await _userPrefs.setShowSidebarHeatmap(v);
setState(() => _showHeatmap = v);
}),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.schedule, '最近添加', '显示最近添加的记录',
_showRecent, (v) async {
await _userPrefs.setShowSidebarRecent(v);
setState(() => _showRecent = v);
}),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.favorite_border, '统计', '与应用相遇的天数和数据概览',
_showEncounter, (v) async {
await _userPrefs.setShowSidebarEncounter(v);
setState(() => _showEncounter = v);
}),
_buildSectionHeader('快捷功能'),
_buildSwitchItem(Icons.explore_outlined, '漫步', '随机发现内容',
_showStroll, (v) async {
await _userPrefs.setShowSidebarStroll(v);
setState(() => _showStroll = v);
}),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.calendar_month_outlined, '书影日历', '按日历查看记录',
_showCalendar, (v) async {
await _userPrefs.setShowSidebarCalendar(v);
setState(() => _showCalendar = v);
}),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.people_outline, '角色信息', '管理影视和书籍中的角色',
_showPerson, (v) async {
await _userPrefs.setShowSidebarPerson(v);
setState(() => _showPerson = v);
}),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.label_outline, '标签管理', '管理所有标签',
_showTags, (v) async {
await _userPrefs.setShowSidebarTags(v);
setState(() => _showTags = v);
}),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.description_outlined, 'MD阅读', 'Markdown 文件阅读器',
_showMdReader, (v) async {
await _userPrefs.setShowSidebarMdReader(v);
setState(() => _showMdReader = v);
}),
Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant),
_buildSwitchItem(Icons.auto_stories_outlined, 'EPUB阅读', 'EPUB 电子书阅读器',
_showEpub, (v) async {
await _userPrefs.setShowSidebarEpub(v);
setState(() => _showEpub = v);
}),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Text('关闭后对应功能将从侧边栏中隐藏。',
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
),
],
),
);
}
Widget _buildSectionHeader(String title) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
child: Text(title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
);
}
Widget _buildSwitchItem(IconData icon, String title, String subtitle,
bool value, ValueChanged<bool> onChanged) {
final colors = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10)),
child: Icon(icon,
color: colors.onSurface.withValues(alpha: 0.6), size: 18)),
title: Text(title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
subtitle: Text(subtitle,
style: TextStyle(
fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Switch(
value: value,
onChanged: onChanged,
activeColor: colors.primary,
activeTrackColor: colors.primary.withValues(alpha: 0.3),
inactiveThumbColor: colors.surface,
inactiveTrackColor: colors.outline),
);
}
}

View File

@@ -16,6 +16,10 @@ class UserPrefs {
final oldValue = _prefs!.getBool('isDarkMode') ?? false;
await _prefs!.setInt('themeMode', oldValue ? 2 : 0);
}
// 记录首次使用日期
if (!_prefs!.containsKey('firstUseDate')) {
await _prefs!.setString('firstUseDate', DateTime.now().toIso8601String());
}
}
/// 获取实例
@@ -28,6 +32,13 @@ class UserPrefs {
// ========== 用户信息 ==========
/// 首次使用日期
DateTime get firstUseDate {
final str = prefs.getString('firstUseDate');
if (str != null) return DateTime.tryParse(str) ?? DateTime.now();
return DateTime.now();
}
/// 昵称
String get nickname => prefs.getString('nickname') ?? 'Mook';
Future<bool> setNickname(String value) => prefs.setString('nickname', value);
@@ -101,6 +112,35 @@ class UserPrefs {
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
// ─── 侧边栏功能开关 ───
bool get showSidebarHeatmap => prefs.getBool('showSidebarHeatmap') ?? true;
Future<bool> setShowSidebarHeatmap(bool value) => prefs.setBool('showSidebarHeatmap', value);
bool get showSidebarRecent => prefs.getBool('showSidebarRecent') ?? true;
Future<bool> setShowSidebarRecent(bool value) => prefs.setBool('showSidebarRecent', value);
bool get showSidebarEncounter => prefs.getBool('showSidebarEncounter') ?? true;
Future<bool> setShowSidebarEncounter(bool value) => prefs.setBool('showSidebarEncounter', value);
bool get showSidebarStroll => prefs.getBool('showSidebarStroll') ?? true;
Future<bool> setShowSidebarStroll(bool value) => prefs.setBool('showSidebarStroll', value);
bool get showSidebarCalendar => prefs.getBool('showSidebarCalendar') ?? true;
Future<bool> setShowSidebarCalendar(bool value) => prefs.setBool('showSidebarCalendar', value);
bool get showSidebarPerson => prefs.getBool('showSidebarPerson') ?? true;
Future<bool> setShowSidebarPerson(bool value) => prefs.setBool('showSidebarPerson', value);
bool get showSidebarTags => prefs.getBool('showSidebarTags') ?? true;
Future<bool> setShowSidebarTags(bool value) => prefs.setBool('showSidebarTags', value);
bool get showSidebarMdReader => prefs.getBool('showSidebarMdReader') ?? true;
Future<bool> setShowSidebarMdReader(bool value) => prefs.setBool('showSidebarMdReader', value);
bool get showSidebarEpub => prefs.getBool('showSidebarEpub') ?? true;
Future<bool> setShowSidebarEpub(bool value) => prefs.setBool('showSidebarEpub', value);
/// 笔记布局样式 (0: 列表, 1: 瀑布流, 2: 时间线)
int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0;
Future<bool> setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value);

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../providers/app_provider.dart';
import '../utils/user_prefs.dart';
import '../pages/encounter_page.dart';
import '../pages/stroll_page.dart';
import '../pages/media_calendar_page.dart';
import '../pages/person_list_page.dart';
@@ -28,6 +29,16 @@ class CustomDrawer extends StatefulWidget {
class _CustomDrawerState extends State<CustomDrawer> {
String _version = '0.1.5';
// 热力图缓存
List<Movie>? _cachedMovies;
List<Book>? _cachedBooks;
List<Note>? _cachedNotes;
Map<DateTime, int>? _cachedDailyCounts;
int? _cachedMaxCount;
// 最近添加缓存
List<_RecentItem>? _cachedRecentItems;
@override
void initState() {
super.initState();
@@ -42,6 +53,17 @@ class _CustomDrawerState extends State<CustomDrawer> {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final userPrefs = UserPrefs();
final showHeatmap = userPrefs.showSidebarHeatmap;
final showRecent = userPrefs.showSidebarRecent;
final showTools = userPrefs.showSidebarEncounter ||
userPrefs.showSidebarStroll ||
userPrefs.showSidebarCalendar ||
userPrefs.showSidebarPerson ||
userPrefs.showSidebarTags ||
userPrefs.showSidebarMdReader ||
userPrefs.showSidebarEpub;
return Drawer(
backgroundColor: colors.surfaceContainerHigh,
child: SafeArea(
@@ -50,12 +72,18 @@ class _CustomDrawerState extends State<CustomDrawer> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProfileCard(context),
if (showHeatmap) ...[
const SizedBox(height: 16),
_buildCalendarSection(context),
],
if (showRecent) ...[
const SizedBox(height: 16),
_buildRecentSection(context),
],
if (showTools) ...[
const SizedBox(height: 16),
_buildToolsCard(context),
],
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 32),
child: Center(
@@ -162,6 +190,19 @@ class _CustomDrawerState extends State<CustomDrawer> {
Widget _buildToolsCard(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final userPrefs = UserPrefs();
final items = <(IconData, String, Widget)>[];
if (userPrefs.showSidebarEncounter) items.add((Icons.favorite_border, '统计', const EncounterPage()));
if (userPrefs.showSidebarStroll) items.add((Icons.explore_outlined, '漫步', const StrollPage()));
if (userPrefs.showSidebarCalendar) items.add((Icons.calendar_month_outlined, '书影日历', const MediaCalendarPage()));
if (userPrefs.showSidebarPerson) items.add((Icons.people_outline, '角色信息', const PersonListPage()));
if (userPrefs.showSidebarTags) items.add((Icons.label_outline, '标签管理', const TagManagementPage()));
if (userPrefs.showSidebarMdReader) items.add((Icons.description_outlined, 'MD阅读', const MdReaderTabPage()));
if (userPrefs.showSidebarEpub) items.add((Icons.auto_stories_outlined, 'EPUB阅读', const EpubLibraryPage()));
if (items.isEmpty) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
@@ -169,37 +210,17 @@ class _CustomDrawerState extends State<CustomDrawer> {
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
_buildToolItem(Icons.explore_outlined, '漫步', () {
children: List.generate(items.length * 2 - 1, (i) {
if (i.isOdd) {
return Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant);
}
final idx = i ~/ 2;
final (icon, title, page) = items[idx];
return _buildToolItem(icon, title, () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const StrollPage()));
}, topRounded: true),
Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant),
_buildToolItem(Icons.calendar_month_outlined, '书影日历', () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const MediaCalendarPage()));
Navigator.push(context, MaterialPageRoute(builder: (_) => page));
}, topRounded: idx == 0, bottomRounded: idx == items.length - 1);
}),
Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant),
_buildToolItem(Icons.people_outline, '角色信息', () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const PersonListPage()));
}),
Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant),
_buildToolItem(Icons.label_outline, '标签管理', () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const TagManagementPage()));
}),
Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant),
_buildToolItem(Icons.description_outlined, 'MD阅读', () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const MdReaderTabPage()));
}),
Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant),
_buildToolItem(Icons.auto_stories_outlined, 'EPUB阅读', () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const EpubLibraryPage()));
}, bottomRounded: true),
],
),
);
}
@@ -233,20 +254,26 @@ class _CustomDrawerState extends State<CustomDrawer> {
// ─── 热力图 ──────────────────────────────────────────────────────────
Widget _buildCalendarSection(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, provider, child) {
final colors = Theme.of(context).colorScheme;
final Map<DateTime, int> dailyCounts = {};
for (final movie in provider.movies.where((m) => !m.isDeleted)) {
// ─── 热力图缓存计算 ───
(int, Map<DateTime, int>) _computeDailyCounts(List<Movie> movies, List<Book> books, List<Note> notes) {
if (identical(movies, _cachedMovies) &&
identical(books, _cachedBooks) &&
identical(notes, _cachedNotes) &&
_cachedDailyCounts != null) {
return (_cachedMaxCount!, _cachedDailyCounts!);
}
final dailyCounts = <DateTime, int>{};
for (final movie in movies.where((m) => !m.isDeleted)) {
final date = DateTime(movie.createdAt.year, movie.createdAt.month, movie.createdAt.day);
dailyCounts[date] = (dailyCounts[date] ?? 0) + 1;
}
for (final book in provider.books.where((b) => !b.isDeleted)) {
for (final book in books.where((b) => !b.isDeleted)) {
final date = DateTime(book.createdAt.year, book.createdAt.month, book.createdAt.day);
dailyCounts[date] = (dailyCounts[date] ?? 0) + 1;
}
for (final note in provider.notes.where((n) => !n.isDeleted)) {
for (final note in notes.where((n) => !n.isDeleted)) {
final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day);
dailyCounts[date] = (dailyCounts[date] ?? 0) + 1;
}
@@ -257,6 +284,46 @@ class _CustomDrawerState extends State<CustomDrawer> {
}
if (maxCount == 0) maxCount = 1;
_cachedMovies = movies;
_cachedBooks = books;
_cachedNotes = notes;
_cachedDailyCounts = dailyCounts;
_cachedMaxCount = maxCount;
return (maxCount, dailyCounts);
}
List<_RecentItem> _computeRecentItems(List<Movie> movies, List<Book> books, List<Note> notes) {
if (identical(movies, _cachedMovies) &&
identical(books, _cachedBooks) &&
identical(notes, _cachedNotes) &&
_cachedRecentItems != null) {
return _cachedRecentItems!;
}
final items = <_RecentItem>[];
for (final m in movies.where((m) => !m.isDeleted)) {
items.add(_RecentItem(type: 'movie', title: m.title, date: m.createdAt, data: m));
}
for (final b in books.where((b) => !b.isDeleted)) {
items.add(_RecentItem(type: 'book', title: b.title, date: b.createdAt, data: b));
}
for (final n in notes.where((n) => !n.isDeleted)) {
items.add(_RecentItem(type: 'note', title: n.title.isNotEmpty ? n.title : '随手记', date: n.createdAt, data: n));
}
items.sort((a, b) => b.date.compareTo(a.date));
_cachedRecentItems = items;
return items;
}
Widget _buildCalendarSection(BuildContext context) {
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 colors = Theme.of(context).colorScheme;
final (maxCount, dailyCounts) = _computeDailyCounts(movies, books, notes);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final daysSinceSunday = today.weekday % 7;
@@ -348,8 +415,6 @@ class _CustomDrawerState extends State<CustomDrawer> {
],
),
);
},
);
}
Color _heatmapColor(int count, int maxCount) {
@@ -369,10 +434,11 @@ class _CustomDrawerState extends State<CustomDrawer> {
// ─── 最近添加 ────────────────────────────────────────────────────────
Widget _buildRecentSection(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, provider, child) {
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 colors = Theme.of(context).colorScheme;
final recent = _getRecentItems(provider);
final recent = _computeRecentItems(movies, books, notes);
if (recent.isEmpty) return const SizedBox.shrink();
return Container(
@@ -415,23 +481,6 @@ class _CustomDrawerState extends State<CustomDrawer> {
],
),
);
},
);
}
List<_RecentItem> _getRecentItems(AppProvider provider) {
final items = <_RecentItem>[];
for (final m in provider.movies.where((m) => !m.isDeleted)) {
items.add(_RecentItem(type: 'movie', title: m.title, date: m.createdAt, data: m));
}
for (final b in provider.books.where((b) => !b.isDeleted)) {
items.add(_RecentItem(type: 'book', title: b.title, date: b.createdAt, data: b));
}
for (final n in provider.notes.where((n) => !n.isDeleted)) {
items.add(_RecentItem(type: 'note', title: n.title.isNotEmpty ? n.title : '随手记', date: n.createdAt, data: n));
}
items.sort((a, b) => b.date.compareTo(a.date));
return items;
}
void _openRecentItem(BuildContext context, _RecentItem item) {