Files
MookNote/lib/widgets/custom_drawer.dart
2026-07-07 01:13:53 +08:00

565 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
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/explore/encounter_page.dart';
import '../pages/explore/stroll_page.dart';
import '../pages/explore/media_calendar_page.dart';
import '../pages/explore/person_list_page.dart';
import '../pages/markdown_reader/md_reader_tab_page.dart';
import '../pages/epub_reader/epub_library_page.dart';
import '../pages/settings/tag_management_page.dart';
import '../pages/movies/movie_detail_page.dart';
import '../pages/book/book_detail_page.dart';
import '../pages/note/note_detail_page.dart';
import '../pages/game/game_detail_page.dart';
import '../models/data_models.dart';
import 'fade_in_local_image.dart';
/// 自定义侧边栏
class CustomDrawer extends StatefulWidget {
final bool embedded;
const CustomDrawer({super.key, this.embedded = false});
@override
State<CustomDrawer> createState() => _CustomDrawerState();
}
class _CustomDrawerState extends State<CustomDrawer> {
String _version = '0.1.5';
// 热力图缓存
int _cachedMoviesHash = 0;
int _cachedBooksHash = 0;
int _cachedNotesHash = 0;
int _cachedGamesHash = 0;
Map<DateTime, int>? _cachedDailyCounts;
int? _cachedMaxCount;
// 最近添加缓存
List<_RecentItem>? _cachedRecentItems;
/// 用列表长度 + 首末元素ID生成轻量哈希避免 identical() 在 Provider 返回新实例时永远失败
int _listHash<T>(List<T> list) {
if (list.isEmpty) return 0;
final first = list.first;
final last = list.last;
return Object.hash(list.length, first, last);
}
bool _isHeatmapCacheValid(List<Movie> movies, List<Book> books, List<Note> notes, List<Game> games) {
return _cachedDailyCounts != null &&
_cachedMoviesHash == _listHash(movies) &&
_cachedBooksHash == _listHash(books) &&
_cachedNotesHash == _listHash(notes) &&
_cachedGamesHash == _listHash(games);
}
bool _isRecentCacheValid(List<Movie> movies, List<Book> books, List<Note> notes, List<Game> games) {
return _cachedRecentItems != null &&
_cachedMoviesHash == _listHash(movies) &&
_cachedBooksHash == _listHash(books) &&
_cachedNotesHash == _listHash(notes) &&
_cachedGamesHash == _listHash(games);
}
@override
void initState() {
super.initState();
_loadVersionInfo();
}
Future<void> _loadVersionInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
if (mounted) setState(() => _version = packageInfo.version);
}
@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;
final content = SafeArea(
child: SingleChildScrollView(
child: Column(
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(
child: Text('v$_version', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.2))),
),
),
],
),
),
);
if (widget.embedded) return content;
return Drawer(
backgroundColor: colors.surfaceContainerHigh,
child: content,
);
}
// ─── 头像 + 统计卡片 ─────────────────────────────────────────────────
Widget _buildProfileCard(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, provider, child) {
final colors = Theme.of(context).colorScheme;
final userPrefs = UserPrefs();
final nickname = userPrefs.nickname;
final motto = userPrefs.motto;
final avatarPath = userPrefs.avatarPath;
final movieCount = provider.movies.where((m) => !m.isDeleted).length;
final bookCount = provider.books.length;
final noteCount = provider.notes.length;
final gameCount = provider.games.where((g) => !g.isDeleted).length;
// 根据功能开关过滤显示的统计项
final statItems = <Widget>[];
if (userPrefs.showMovieTab) statItems.add(_buildProfileStatRow(Icons.movie_outlined, movieCount, '观影'));
if (userPrefs.showBookTab) statItems.add(_buildProfileStatRow(Icons.menu_book_outlined, bookCount, '阅读'));
if (userPrefs.showNoteTab) statItems.add(_buildProfileStatRow(Icons.note_outlined, noteCount, '笔记'));
if (userPrefs.showGameTab) statItems.add(_buildProfileStatRow(Icons.sports_esports_outlined, gameCount, '游戏'));
return Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.surfaceContainerHighest,
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: avatarPath != null && avatarPath.isNotEmpty
? FadeInLocalImage(path: avatarPath, fit: BoxFit.cover,
errorWidget: Icon(Icons.person_outline, size: 26, color: colors.onSurface.withValues(alpha: 0.3)))
: Icon(Icons.person_outline, size: 26, color: colors.onSurface.withValues(alpha: 0.3)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(nickname, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 2),
Text(motto, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
),
],
),
if (statItems.isNotEmpty) ...[
const SizedBox(height: 16),
Divider(height: 1, color: colors.outlineVariant),
const SizedBox(height: 14),
for (int i = 0; i < statItems.length; i++) ...[
statItems[i],
if (i < statItems.length - 1) const SizedBox(height: 12),
],
],
],
),
);
},
);
}
Widget _buildProfileStatRow(IconData icon, int count, String label) {
final colors = Theme.of(context).colorScheme;
return Row(
children: [
Icon(icon, size: 16, color: colors.onSurface.withValues(alpha: 0.5)),
const SizedBox(width: 10),
Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))),
const Spacer(),
Text(_formatCount(count), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
);
}
String _formatCount(int count) {
if (count >= 10000) return '${(count / 10000).toStringAsFixed(1)}';
if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}k';
return count.toString();
}
// ─── 功能入口卡片 ────────────────────────────────────────────────────
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(
color: colors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
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, () {
if (!widget.embedded) Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => page));
}, topRounded: idx == 0, bottomRounded: idx == items.length - 1);
}),
),
);
}
Widget _buildToolItem(IconData icon, String title, VoidCallback onTap, {bool topRounded = false, bool bottomRounded = false, bool enabled = true}) {
final colors = Theme.of(context).colorScheme;
final effectiveOnTap = enabled ? onTap : null;
final iconOpacity = enabled ? 0.7 : 0.25;
final textOpacity = enabled ? 1.0 : 0.35;
return InkWell(
onTap: effectiveOnTap,
borderRadius: BorderRadius.only(
topLeft: topRounded ? const Radius.circular(16) : Radius.zero,
topRight: topRounded ? const Radius.circular(16) : Radius.zero,
bottomLeft: bottomRounded ? const Radius.circular(16) : Radius.zero,
bottomRight: bottomRounded ? const Radius.circular(16) : Radius.zero,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20),
child: Row(
children: [
Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: iconOpacity)),
const SizedBox(width: 12),
Expanded(child: Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: textOpacity)))),
Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.2)),
],
),
),
);
}
// ─── 热力图 ──────────────────────────────────────────────────────────
// ─── 热力图缓存计算 ───
(int, Map<DateTime, int>) _computeDailyCounts(List<Movie> movies, List<Book> books, List<Note> notes, List<Game> games) {
if (_isHeatmapCacheValid(movies, books, notes, games)) {
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 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;
}
for (final game in games.where((g) => !g.isDeleted)) {
final date = DateTime(game.createdAt.year, game.createdAt.month, game.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;
_cachedMoviesHash = _listHash(movies);
_cachedBooksHash = _listHash(books);
_cachedNotesHash = _listHash(notes);
_cachedGamesHash = _listHash(games);
_cachedDailyCounts = dailyCounts;
_cachedMaxCount = maxCount;
return (maxCount, dailyCounts);
}
List<_RecentItem> _computeRecentItems(List<Movie> movies, List<Book> books, List<Note> notes, List<Game> games) {
if (_isRecentCacheValid(movies, books, notes, games)) {
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));
}
for (final g in games.where((g) => !g.isDeleted)) {
items.add(_RecentItem(type: 'game', title: g.title, date: g.createdAt, data: g));
}
items.sort((a, b) => b.date.compareTo(a.date));
_cachedMoviesHash = _listHash(movies);
_cachedBooksHash = _listHash(books);
_cachedNotesHash = _listHash(notes);
_cachedGamesHash = _listHash(games);
_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 games = context.select<AppProvider, List<Game>>((p) => p.games);
final colors = Theme.of(context).colorScheme;
final (maxCount, dailyCounts) = _computeDailyCounts(movies, books, notes, games);
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final daysSinceSunday = today.weekday % 7;
final lastSunday = today.subtract(Duration(days: daysSinceSunday));
const totalWeeks = 20;
const weekDays = 7;
const cellGap = 1.5;
// 可用宽度 = drawer宽度 - margin(16*2) - container padding(18*2)
final drawerWidth = widget.embedded ? 260.0 : MediaQuery.sizeOf(context).width;
final availableWidth = drawerWidth - 32 - 36;
final cellSize = ((availableWidth - (totalWeeks - 1) * cellGap) / totalWeeks).clamp(6.0, 10.0);
final cells = List.generate(weekDays, (_) => List.generate(totalWeeks, (_) => 0));
for (int week = 0; week < totalWeeks; week++) {
for (int day = 0; day < weekDays; day++) {
final date = lastSunday.subtract(Duration(days: (totalWeeks - 1 - week) * 7 + (6 - day)));
cells[day][week] = dailyCounts[date] ?? 0;
}
}
final monthLabels = <int, String>{};
for (int week = 0; week < totalWeeks; week++) {
final date = lastSunday.subtract(Duration(days: (totalWeeks - 1 - week) * 7));
final key = date.month;
if (!monthLabels.containsKey(key) || date.day <= 7) {
monthLabels[week] = '${date.month}';
}
}
final sortedWeeks = monthLabels.keys.toList()..sort();
final keepWeeks = {sortedWeeks.first, sortedWeeks[sortedWeeks.length ~/ 2], sortedWeeks.last};
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: [
Icon(Icons.calendar_today, 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),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 16,
child: Row(
children: List.generate(totalWeeks, (week) {
final label = keepWeeks.contains(week) ? monthLabels[week] : null;
return SizedBox(
width: week < totalWeeks - 1 ? cellSize + cellGap : cellSize,
child: label != null ? Text(label, style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3))) : null,
);
}),
),
),
const SizedBox(height: 2),
...List.generate(weekDays, (day) => Row(
children: List.generate(totalWeeks, (week) {
final count = cells[day][week];
return Container(
width: cellSize, height: cellSize,
margin: EdgeInsets.only(right: week < totalWeeks - 1 ? cellGap : 0, bottom: day < weekDays - 1 ? cellGap : 0),
decoration: BoxDecoration(color: _heatmapColor(count, maxCount), borderRadius: BorderRadius.circular(2)),
);
}),
)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('', style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3))),
const SizedBox(width: 3),
_legendCell(const Color(0xFFF0F0F0)),
_legendCell(const Color(0xFFC8E6C9)),
_legendCell(const Color(0xFF66BB6A)),
_legendCell(const Color(0xFF2E7D32)),
_legendCell(const Color(0xFF1B5E20)),
const SizedBox(width: 3),
Text('', style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
],
),
);
}
Color _heatmapColor(int count, int maxCount) {
if (count == 0) return const Color(0xFFF0F0F0);
final ratio = count / maxCount;
if (ratio <= 0.25) return const Color(0xFFC8E6C9);
if (ratio <= 0.50) return const Color(0xFF66BB6A);
if (ratio <= 0.75) return const Color(0xFF2E7D32);
return const Color(0xFF1B5E20);
}
Widget _legendCell(Color color) {
return Container(width: 10, height: 10, margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2)));
}
// ─── 最近添加 ────────────────────────────────────────────────────────
Widget _buildRecentSection(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 games = context.select<AppProvider, List<Game>>((p) => p.games);
final colors = Theme.of(context).colorScheme;
final recent = _computeRecentItems(movies, books, notes, games);
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,
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 : item.type == 'game' ? Icons.sports_esports_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))),
],
),
),
)),
],
),
);
}
void _openRecentItem(BuildContext context, _RecentItem item) {
if (!widget.embedded) Navigator.pop(context);
switch (item.type) {
case 'movie':
Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
case 'book':
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
case 'note':
Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as Note)));
case 'game':
Navigator.push(context, MaterialPageRoute(builder: (_) => GameDetailPage(game: item.data as Game)));
}
}
String _recentTimeAgo(DateTime date) {
final diff = DateTime.now().difference(date);
if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前';
if (diff.inDays >= 30) return '${(diff.inDays / 30).floor()}月前';
if (diff.inDays > 0) return '${diff.inDays}天前';
if (diff.inHours > 0) return '${diff.inHours}小时前';
return '刚刚';
}
}
class _RecentItem {
final String type;
final String title;
final DateTime date;
final dynamic data;
_RecentItem({required this.type, required this.title, required this.date, required this.data});
}