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 'package:flutter_svg/flutter_svg.dart'; import 'reviewed_stamp_icon.dart'; import '../pages/explore/encounter_page.dart'; import '../pages/explore/stroll_page.dart'; import '../pages/explore/reviewed_page.dart'; import '../pages/playlist/playlist_list_page.dart'; import '../pages/explore/media_calendar_page.dart'; import '../pages/people/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/movies/movie_form_page.dart'; import '../pages/book/book_detail_page.dart'; import '../pages/book/book_form_page.dart'; import '../pages/note/note_detail_page.dart'; import '../pages/note/note_form_page.dart'; import '../pages/game/game_detail_page.dart'; import '../pages/profile/settings_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 createState() => _CustomDrawerState(); } class _CustomDrawerState extends State { String _version = '0.1.5'; // 热力图缓存 int _cachedMoviesHash = 0; int _cachedBooksHash = 0; int _cachedNotesHash = 0; int _cachedGamesHash = 0; Map? _cachedDailyCounts; int? _cachedMaxCount; // 最近添加缓存 List<_RecentItem>? _cachedRecentItems; /// 用列表长度 + 首末元素ID生成轻量哈希,避免 identical() 在 Provider 返回新实例时永远失败 int _listHash(List list) { if (list.isEmpty) return 0; final first = list.first; final last = list.last; return Object.hash(list.length, first, last); } bool _isHeatmapCacheValid(List movies, List books, List notes, List games) { return _cachedDailyCounts != null && _cachedMoviesHash == _listHash(movies) && _cachedBooksHash == _listHash(books) && _cachedNotesHash == _listHash(notes) && _cachedGamesHash == _listHash(games); } bool _isRecentCacheValid(List movies, List books, List notes, List games) { return _cachedRecentItems != null && _cachedMoviesHash == _listHash(movies) && _cachedBooksHash == _listHash(books) && _cachedNotesHash == _listHash(notes) && _cachedGamesHash == _listHash(games); } @override void initState() { super.initState(); _loadVersionInfo(); } Future _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 showQuickActions = userPrefs.showSidebarQuickActions; final showTools = userPrefs.showSidebarEncounter || userPrefs.showSidebarStroll || userPrefs.showSidebarReviewed || userPrefs.showSidebarPlaylist || userPrefs.showSidebarCalendar || userPrefs.showSidebarPerson || userPrefs.showSidebarTags || userPrefs.showSidebarMdReader || userPrefs.showSidebarEpub; final content = SafeArea( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildProfileCard(context), if (showQuickActions) ...[ const SizedBox(height: 12), _buildQuickActions(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, 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('v$_version', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.2))), const SizedBox(width: 16), GestureDetector( onTap: () { if (!widget.embedded) Navigator.pop(context); Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage())); }, child: Icon(Icons.settings_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)), ), ], ), ), const SizedBox(height: 16), ], ), ), ); if (widget.embedded) return content; return Drawer( backgroundColor: colors.surfaceContainerHigh, child: content, ); } // ─── 头像 + 统计卡片 ───────────────────────────────────────────────── Widget _buildProfileCard(BuildContext context) { return Consumer( 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 = <(IconData, int, String, Color)>[]; if (userPrefs.showMovieTab) statItems.add((Icons.movie_outlined, movieCount, '观影', const Color(0xFF2563EB))); if (userPrefs.showBookTab) statItems.add((Icons.menu_book_outlined, bookCount, '阅读', const Color(0xFF16A34A))); if (userPrefs.showNoteTab) statItems.add((Icons.note_outlined, noteCount, '笔记', const Color(0xFF9333EA))); if (userPrefs.showGameTab) statItems.add((Icons.sports_esports_outlined, gameCount, '游戏', const Color(0xFFEA580C))); return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 44, height: 44, 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: 22, color: colors.onSurface.withValues(alpha: 0.3))) : Icon(Icons.person_outline, size: 22, color: colors.onSurface.withValues(alpha: 0.3)), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(nickname, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), const SizedBox(height: 2), Text(motto, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), ], ), ), ], ), if (statItems.isNotEmpty) ...[ const SizedBox(height: 14), Row( children: statItems.map((item) { final (icon, count, label, color) = item; return Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(_formatCount(count), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: color)), const SizedBox(height: 2), Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 11, color: colors.onSurface.withValues(alpha: 0.4)), const SizedBox(width: 3), Text(label, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), ], ), ], ), ); }).toList(), ), ], ], ), ); }, ); } 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 _buildQuickActions(BuildContext context) { final colors = Theme.of(context).colorScheme; final userPrefs = UserPrefs(); final actions = <(IconData, String, Color, VoidCallback)>[]; if (userPrefs.showMovieTab) actions.add(( Icons.add_photo_alternate_outlined, '影视', const Color(0xFF2563EB), () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const MovieFormPage())); }, )); if (userPrefs.showBookTab) actions.add(( Icons.menu_book_outlined, '阅读', const Color(0xFF16A34A), () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const BookFormPage())); }, )); if (userPrefs.showNoteTab) actions.add(( Icons.edit_note_outlined, '笔记', const Color(0xFF9333EA), () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const NoteFormPage())); }, )); if (actions.isEmpty) return const SizedBox.shrink(); return Container( margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(16), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions.map((action) { final (icon, label, color, onTap) = action; return Expanded( child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), child: Icon(icon, size: 18, color: color), ), const SizedBox(height: 4), Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))), ], ), ), ), ); }).toList(), ), ); } // ─── 功能入口卡片 ──────────────────────────────────────────────────── Widget _buildToolsCard(BuildContext context) { final colors = Theme.of(context).colorScheme; final userPrefs = UserPrefs(); final exploreItems = <(Widget, String, Widget)>[]; if (userPrefs.showSidebarEncounter) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '统计', const EncounterPage())); if (userPrefs.showSidebarStroll) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '漫步', const StrollPage())); if (userPrefs.showSidebarReviewed) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '已阅', const ReviewedPage())); if (userPrefs.showSidebarPlaylist) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '书影片单', const PlaylistListPage())); if (userPrefs.showSidebarCalendar) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '书影日历', const MediaCalendarPage())); final toolItems = <(Widget, String, Widget)>[]; if (userPrefs.showSidebarPerson) toolItems.add((SvgPicture.string('', color: colors.onSurface), '人物', const PersonListPage())); if (userPrefs.showSidebarTags) toolItems.add((SvgPicture.string('', color: colors.onSurface), '标签管理', const TagManagementPage())); if (userPrefs.showSidebarMdReader) toolItems.add((Icon(Icons.description_outlined, size: 20, color: colors.onSurface), 'MD阅读', const MdReaderTabPage())); if (userPrefs.showSidebarEpub) toolItems.add((SvgPicture.string('', color: colors.onSurface), 'EPUB阅读', const EpubLibraryPage())); if (exploreItems.isEmpty && toolItems.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (exploreItems.isNotEmpty) ...[ _buildGroupTitle('探索', colors), const SizedBox(height: 6), _buildGroupCard(context, exploreItems), ], if (toolItems.isNotEmpty) ...[ const SizedBox(height: 16), _buildGroupTitle('工具', colors), const SizedBox(height: 6), _buildGroupCard(context, toolItems), ], ], ); } Widget _buildGroupTitle(String title, ColorScheme colors) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Text(title, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.3), letterSpacing: 1)), ); } Widget _buildGroupCard(BuildContext context, List<(Widget, String, Widget)> items) { final colors = Theme.of(context).colorScheme; 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(Widget 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: [ SizedBox(width: 20, height: 20, child: Opacity(opacity: iconOpacity, child: icon)), 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) _computeDailyCounts(List movies, List books, List notes, List games) { if (_isHeatmapCacheValid(movies, books, notes, games)) { 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; } 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 movies, List books, List notes, List 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, imagePath: m.posterPath)); } for (final b in books.where((b) => !b.isDeleted)) { items.add(_RecentItem(type: 'book', title: b.title, date: b.createdAt, data: b, imagePath: b.coverPath)); } 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, imagePath: g.coverPath)); } 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>((p) => p.movies); final books = context.select>((p) => p.books); final notes = context.select>((p) => p.notes); final games = context.select>((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; } } // 连续打卡天数 int streak = 0; for (int i = 0; i < 365; i++) { final date = today.subtract(Duration(days: i)); if (dailyCounts[date] != null && dailyCounts[date]! > 0) { streak++; } else { break; } } final monthLabels = {}; 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 Spacer(), if (streak > 0) ...[ Icon(Icons.local_fire_department, size: 14, color: const Color(0xFFFF6D00)), const SizedBox(width: 3), Text('连续 $streak 天', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: const Color(0xFFFF6D00))), ], ], ), 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]; final date = lastSunday.subtract(Duration(days: (totalWeeks - 1 - week) * 7 + (6 - day))); return GestureDetector( onTap: count > 0 ? () => _showDayDetail(context, date, dailyCounts[date] ?? 0, movies, books, notes, games) : null, child: Tooltip( message: '${date.month}月${date.day}日${count > 0 ? ' · $count条' : ''}', child: 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), border: count > 0 ? null : Border.all(color: colors.outlineVariant.withValues(alpha: 0.3), width: 0.5), ), ), ), ); }), )), ], ), 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))), ], ), ], ), ); } void _showDayDetail(BuildContext context, DateTime date, int count, List movies, List books, List notes, List games) { final colors = Theme.of(context).colorScheme; final dayMovies = movies.where((m) => !m.isDeleted && DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day) == date).toList(); final dayBooks = books.where((b) => !b.isDeleted && DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day) == date).toList(); final dayNotes = notes.where((n) => !n.isDeleted && DateTime(n.createdAt.year, n.createdAt.month, n.createdAt.day) == date).toList(); final dayGames = games.where((g) => !g.isDeleted && DateTime(g.createdAt.year, g.createdAt.month, g.createdAt.day) == date).toList(); showModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 8), decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), child: Row( children: [ Text('${date.month}月${date.day}日', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), const SizedBox(width: 8), Text('$count条记录', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.45))), ], ), ), Flexible( child: ListView( shrinkWrap: true, padding: const EdgeInsets.only(bottom: 12), children: [ if (dayMovies.isNotEmpty) ...[ for (final m in dayMovies) _dayDetailItem(ctx, Icons.movie_outlined, m.title, colors.primary), ], if (dayBooks.isNotEmpty) ...[ for (final b in dayBooks) _dayDetailItem(ctx, Icons.menu_book_outlined, b.title, const Color(0xFF16A34A)), ], if (dayNotes.isNotEmpty) ...[ for (final n in dayNotes) _dayDetailItem(ctx, Icons.note_outlined, n.title.isNotEmpty ? n.title : '随手记', const Color(0xFF9333EA)), ], if (dayGames.isNotEmpty) ...[ for (final g in dayGames) _dayDetailItem(ctx, Icons.sports_esports_outlined, g.title, const Color(0xFFEA580C)), ], ], ), ), ], ), ), ); } Widget _dayDetailItem(BuildContext ctx, IconData icon, String title, Color color) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), child: Row( children: [ Container( width: 28, height: 28, decoration: BoxDecoration(color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(7)), child: Icon(icon, size: 15, color: color), ), const SizedBox(width: 12), Expanded(child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: Theme.of(ctx).colorScheme.onSurface))), ], ), ); } 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>((p) => p.movies); final books = context.select>((p) => p.books); final notes = context.select>((p) => p.notes); final games = context.select>((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(6).map((item) => InkWell( onTap: () => _openRecentItem(context, item), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.only(bottom: 10, top: 2), child: Row( children: [ _buildRecentLeading(item, colors), 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 '刚刚'; } Widget _buildRecentLeading(_RecentItem item, ColorScheme colors) { final imagePath = item.imagePath; if (imagePath != null && imagePath.isNotEmpty) { return Container( width: 22, height: 22, decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), ), clipBehavior: Clip.antiAlias, child: FadeInLocalImage( path: imagePath, fit: BoxFit.cover, errorWidget: _buildRecentTypeIcon(item, colors), ), ); } return _buildRecentTypeIcon(item, colors); } Widget _buildRecentTypeIcon(_RecentItem item, ColorScheme colors) { return Container( width: 22, height: 22, decoration: BoxDecoration( color: _typeColor(item.type).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(5), ), child: 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: 12, color: _typeColor(item.type), ), ); } Color _typeColor(String type) { return switch (type) { 'movie' => const Color(0xFF2563EB), 'book' => const Color(0xFF16A34A), 'note' => const Color(0xFF9333EA), 'game' => const Color(0xFFEA580C), _ => const Color(0xFF6B7280), }; } } class _RecentItem { final String type; final String title; final DateTime date; final dynamic data; final String? imagePath; _RecentItem({required this.type, required this.title, required this.date, required this.data, this.imagePath}); }