From c810a1a3819fb9b7204b42109ae87df1f18239a8 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sat, 27 Jun 2026 22:14:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=9F=E8=AE=A1=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/encounter_page.dart | 435 +++++++++++++++++++++++++++++++++ lib/pages/profile_page.dart | 169 +++++++++++++ lib/utils/user_prefs.dart | 42 +++- lib/widgets/custom_drawer.dart | 285 ++++++++++++--------- 4 files changed, 812 insertions(+), 119 deletions(-) create mode 100644 lib/pages/encounter_page.dart diff --git a/lib/pages/encounter_page.dart b/lib/pages/encounter_page.dart new file mode 100644 index 0000000..14c9776 --- /dev/null +++ b/lib/pages/encounter_page.dart @@ -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( + 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(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; +} diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 0bb242a..e5f80e1 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -900,6 +900,18 @@ class _SettingsPageState extends State { 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 { ); } } + +// ─── 侧边栏功能设置 ─── + +class SidebarSettingsPage extends StatefulWidget { + const SidebarSettingsPage({super.key}); + + @override + State createState() => _SidebarSettingsPageState(); +} + +class _SidebarSettingsPageState extends State { + 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 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), + ); + } +} diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 372928e..6e96d35 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -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()); + } } /// 获取实例 @@ -27,7 +31,14 @@ 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 setNickname(String value) => prefs.setString('nickname', value); @@ -101,6 +112,35 @@ class UserPrefs { int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0; Future setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value); + // ─── 侧边栏功能开关 ─── + + bool get showSidebarHeatmap => prefs.getBool('showSidebarHeatmap') ?? true; + Future setShowSidebarHeatmap(bool value) => prefs.setBool('showSidebarHeatmap', value); + + bool get showSidebarRecent => prefs.getBool('showSidebarRecent') ?? true; + Future setShowSidebarRecent(bool value) => prefs.setBool('showSidebarRecent', value); + + bool get showSidebarEncounter => prefs.getBool('showSidebarEncounter') ?? true; + Future setShowSidebarEncounter(bool value) => prefs.setBool('showSidebarEncounter', value); + + bool get showSidebarStroll => prefs.getBool('showSidebarStroll') ?? true; + Future setShowSidebarStroll(bool value) => prefs.setBool('showSidebarStroll', value); + + bool get showSidebarCalendar => prefs.getBool('showSidebarCalendar') ?? true; + Future setShowSidebarCalendar(bool value) => prefs.setBool('showSidebarCalendar', value); + + bool get showSidebarPerson => prefs.getBool('showSidebarPerson') ?? true; + Future setShowSidebarPerson(bool value) => prefs.setBool('showSidebarPerson', value); + + bool get showSidebarTags => prefs.getBool('showSidebarTags') ?? true; + Future setShowSidebarTags(bool value) => prefs.setBool('showSidebarTags', value); + + bool get showSidebarMdReader => prefs.getBool('showSidebarMdReader') ?? true; + Future setShowSidebarMdReader(bool value) => prefs.setBool('showSidebarMdReader', value); + + bool get showSidebarEpub => prefs.getBool('showSidebarEpub') ?? true; + Future setShowSidebarEpub(bool value) => prefs.setBool('showSidebarEpub', value); + /// 笔记布局样式 (0: 列表, 1: 瀑布流, 2: 时间线) int get noteLayoutStyle => prefs.getInt('noteLayoutStyle') ?? 0; Future setNoteLayoutStyle(int value) => prefs.setInt('noteLayoutStyle', value); diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 1c7a4e9..9d65f45 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -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 { String _version = '0.1.5'; + // 热力图缓存 + List? _cachedMovies; + List? _cachedBooks; + List? _cachedNotes; + Map? _cachedDailyCounts; + int? _cachedMaxCount; + + // 最近添加缓存 + List<_RecentItem>? _cachedRecentItems; + @override void initState() { super.initState(); @@ -42,6 +53,17 @@ class _CustomDrawerState extends State { @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 { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildProfileCard(context), - const SizedBox(height: 16), - _buildCalendarSection(context), - const SizedBox(height: 16), - _buildRecentSection(context), - const SizedBox(height: 16), - _buildToolsCard(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 { 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 { 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())); - }), - 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), - ], + Navigator.push(context, MaterialPageRoute(builder: (_) => page)); + }, topRounded: idx == 0, bottomRounded: idx == items.length - 1); + }), ), ); } @@ -233,29 +254,75 @@ class _CustomDrawerState extends State { // ─── 热力图 ────────────────────────────────────────────────────────── - Widget _buildCalendarSection(BuildContext context) { - return Consumer( - builder: (context, provider, child) { - final colors = Theme.of(context).colorScheme; - final Map dailyCounts = {}; - for (final movie in provider.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)) { - 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)) { - final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day); - dailyCounts[date] = (dailyCounts[date] ?? 0) + 1; - } + // ─── 热力图缓存计算 ─── - int maxCount = 0; - for (final c in dailyCounts.values) { - if (c > maxCount) maxCount = c; - } - if (maxCount == 0) maxCount = 1; + (int, Map) _computeDailyCounts(List movies, List books, List notes) { + if (identical(movies, _cachedMovies) && + identical(books, _cachedBooks) && + identical(notes, _cachedNotes) && + _cachedDailyCounts != null) { + return (_cachedMaxCount!, _cachedDailyCounts!); + } + + final dailyCounts = {}; + 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 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 notes.where((n) => !n.isDeleted)) { + final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day); + dailyCounts[date] = (dailyCounts[date] ?? 0) + 1; + } + + int maxCount = 0; + for (final c in dailyCounts.values) { + if (c > maxCount) maxCount = c; + } + if (maxCount == 0) maxCount = 1; + + _cachedMovies = movies; + _cachedBooks = books; + _cachedNotes = notes; + _cachedDailyCounts = dailyCounts; + _cachedMaxCount = maxCount; + return (maxCount, dailyCounts); + } + + List<_RecentItem> _computeRecentItems(List movies, List books, List 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>((p) => p.movies); + final books = context.select>((p) => p.books); + final notes = context.select>((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); @@ -348,8 +415,6 @@ class _CustomDrawerState extends State { ], ), ); - }, - ); } Color _heatmapColor(int count, int maxCount) { @@ -369,71 +434,55 @@ class _CustomDrawerState extends State { // ─── 最近添加 ──────────────────────────────────────────────────────── Widget _buildRecentSection(BuildContext context) { - return Consumer( - builder: (context, provider, child) { - final colors = Theme.of(context).colorScheme; - final recent = _getRecentItems(provider); - if (recent.isEmpty) return const SizedBox.shrink(); + final movies = context.select>((p) => p.movies); + final books = context.select>((p) => p.books); + final notes = context.select>((p) => p.notes); + final colors = Theme.of(context).colorScheme; + final recent = _computeRecentItems(movies, books, notes); + if (recent.isEmpty) return const SizedBox.shrink(); - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.all(18), - decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(16)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(18), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(16)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Row( - children: [ - Icon(Icons.schedule, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), - const SizedBox(width: 8), - Text('最近添加', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))), - ], - ), - const SizedBox(height: 14), - ...recent.take(4).map((item) => InkWell( - onTap: () => _openRecentItem(context, item), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.only(bottom: 10, top: 2), - child: Row( - children: [ - Icon( - item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : Icons.note_outlined, - size: 14, color: colors.onSurface.withValues(alpha: 0.3), - ), - const SizedBox(width: 10), - Expanded( - child: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75))), - ), - const SizedBox(width: 8), - Text(_recentTimeAgo(item.date), style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))), - ], - ), - ), - )), + Icon(Icons.schedule, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 8), + Text('最近添加', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))), ], ), - ); - }, + const SizedBox(height: 14), + ...recent.take(4).map((item) => InkWell( + onTap: () => _openRecentItem(context, item), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.only(bottom: 10, top: 2), + child: Row( + children: [ + Icon( + item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : Icons.note_outlined, + size: 14, color: colors.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(width: 10), + Expanded( + child: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75))), + ), + const SizedBox(width: 8), + Text(_recentTimeAgo(item.date), style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))), + ], + ), + ), + )), + ], + ), ); } - 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) { Navigator.pop(context); switch (item.type) {