From 7c970cde49c5020c02a6007b67486191d0ae3296 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Mon, 9 Mar 2026 14:14:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/backup_page.dart | 125 -------- lib/pages/home_page.dart | 2 +- lib/pages/note_tab_page.dart | 103 +++++- lib/pages/search_page.dart | 91 +++++- lib/pages/statistics_page.dart | 547 +------------------------------- lib/widgets/custom_drawer.dart | 207 +++++++++++- lib/widgets/note_list_item.dart | 126 ++++++-- 7 files changed, 496 insertions(+), 705 deletions(-) diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart index e90178e..cdd7675 100644 --- a/lib/pages/backup_page.dart +++ b/lib/pages/backup_page.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; @@ -19,7 +18,6 @@ class _BackupPageState extends State { bool _isImporting = false; bool _autoBackupEnabled = false; bool _isLoading = true; - List _autoBackupFiles = []; String? _backupDirPath; @override @@ -30,12 +28,10 @@ class _BackupPageState extends State { Future _loadAutoBackupStatus() async { final enabled = await AutoBackupService.instance.getEnabled(); - final files = await AutoBackupService.instance.getBackupFiles(); final dirPath = await AutoBackupService.instance.getBackupDirectoryPath(); if (mounted) { setState(() { _autoBackupEnabled = enabled; - _autoBackupFiles = files; _backupDirPath = dirPath; _isLoading = false; }); @@ -59,12 +55,6 @@ class _BackupPageState extends State { const SizedBox(height: 32), - // 自动备份文件列表 - if (_autoBackupFiles.isNotEmpty) ...[ - _buildBackupFilesSection(), - const SizedBox(height: 32), - ], - // 导出数据 _buildSection( title: '导出数据', @@ -406,119 +396,4 @@ class _BackupPageState extends State { ); } - /// 构建备份文件列表区域 - Widget _buildBackupFilesSection() { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE5E5E5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.folder_outlined, - size: 24, - color: Color(0xFF1A1A1A), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - '自动备份文件 (${_autoBackupFiles.length}/10)', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - ..._autoBackupFiles.asMap().entries.map((entry) { - final index = entry.key; - final file = entry.value; - final fileName = file.path.split('/').last; - final stat = file.statSync(); - final size = _formatFileSize(stat.size); - final modified = _formatDateTime(stat.modified); - - return Container( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), - decoration: BoxDecoration( - border: Border( - bottom: index < _autoBackupFiles.length - 1 - ? const BorderSide(color: Color(0xFFE5E5E5)) - : BorderSide.none, - ), - ), - child: Row( - children: [ - const Icon( - Icons.backup, - size: 18, - color: Color(0xFF666666), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - fileName, - style: const TextStyle( - fontSize: 13, - color: Color(0xFF1A1A1A), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - '$modified · $size', - style: const TextStyle( - fontSize: 11, - color: Color(0xFF999999), - ), - ), - ], - ), - ), - if (index == 0) - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - border: Border.all(color: const Color(0xFFE5E5E5)), - ), - child: const Text( - '最新', - style: TextStyle( - fontSize: 10, - color: Color(0xFF666666), - ), - ), - ), - ], - ), - ); - }), - ], - ), - ); - } - - /// 格式化文件大小 - String _formatFileSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - - /// 格式化日期时间 - String _formatDateTime(DateTime dateTime) { - return '${dateTime.month}/${dateTime.day} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; - } } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index a0145f7..f0be66a 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -20,7 +20,7 @@ class _HomePageState extends State { return Scaffold( // 左侧弹出菜单(仅在主页显示) drawer: context.watch().bottomNavIndex == 0 - ? const CustomDrawer() + ? CustomDrawer() : null, // 主体内容 - 根据底部导航切换 diff --git a/lib/pages/note_tab_page.dart b/lib/pages/note_tab_page.dart index 779044e..fc8041a 100644 --- a/lib/pages/note_tab_page.dart +++ b/lib/pages/note_tab_page.dart @@ -5,14 +5,86 @@ import '../models/data_models.dart'; import '../widgets/note_list_item.dart'; /// 笔记标签页 -class NoteTabPage extends StatelessWidget { +class NoteTabPage extends StatefulWidget { const NoteTabPage({super.key}); + @override + State createState() => _NoteTabPageState(); +} + +class _NoteTabPageState extends State { + // 使用分页加载 + static const int _pageSize = 50; + final List _displayedNotes = []; + bool _isLoading = false; + bool _hasMore = true; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + // 延迟加载初始数据,避免阻塞UI + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadMoreNotes(); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + _loadMoreNotes(); + } + } + + Future _loadMoreNotes() async { + if (_isLoading || !_hasMore) return; + + setState(() => _isLoading = true); + + // 使用微任务延迟加载,避免阻塞UI + await Future.microtask(() { + final provider = context.read(); + final allNotes = provider.notes; + + final startIndex = _displayedNotes.length; + final endIndex = (startIndex + _pageSize).clamp(0, allNotes.length); + + if (startIndex >= allNotes.length) { + _hasMore = false; + } else { + final newNotes = allNotes.sublist(startIndex, endIndex); + _displayedNotes.addAll(newNotes); + _hasMore = endIndex < allNotes.length; + } + }); + + if (mounted) { + setState(() => _isLoading = false); + } + } + + Future _refresh() async { + final provider = context.read(); + await provider.loadNotes(); + setState(() { + _displayedNotes.clear(); + _hasMore = true; + }); + await _loadMoreNotes(); + } + @override Widget build(BuildContext context) { return Column( children: [ - // 笔记列表(无状态筛选,显示所有笔记) + // 笔记列表(分页加载) Expanded( child: _buildNoteList(context), ), @@ -24,21 +96,38 @@ class NoteTabPage extends StatelessWidget { Widget _buildNoteList(BuildContext context) { return Consumer( builder: (context, provider, child) { - final notes = provider.notes; + final allNotes = provider.notes; - if (notes.isEmpty) { + if (allNotes.isEmpty && _displayedNotes.isEmpty) { return _buildEmptyState(context); } return RefreshIndicator( - onRefresh: () async => await provider.loadNotes(), + onRefresh: _refresh, color: const Color(0xFF1A1A1A), backgroundColor: Colors.white, child: ListView.builder( + controller: _scrollController, padding: const EdgeInsets.all(16), - itemCount: notes.length, + itemCount: _displayedNotes.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { - return NoteListItem(note: notes[index]); + if (index >= _displayedNotes.length) { + // 底部加载指示器 + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF1A1A1A), + ), + ), + ), + ); + } + return NoteListItem(note: _displayedNotes[index]); }, ), ); diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index 590d50a..d0d0c42 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -21,6 +21,7 @@ class _SearchPageState extends State { int _selectedType = 0; // 0: 影视, 1: 书籍, 2: 笔记 List _results = []; bool _isSearching = false; + String? _selectedTag; // 选中的标签 final List _typeLabels = ['影视', '书籍', '笔记']; @@ -32,7 +33,8 @@ class _SearchPageState extends State { Future _performSearch() async { final keyword = _searchController.text.trim(); - if (keyword.isEmpty) return; + // 笔记搜索允许空关键词(用于标签筛选) + if (keyword.isEmpty && _selectedType != 2 && _selectedTag == null) return; setState(() => _isSearching = true); @@ -85,9 +87,88 @@ class _SearchPageState extends State { } bool _matchNote(Note note, String keyword) { + // 如果有选中的标签,先按标签筛选 + if (_selectedTag != null) { + if (!note.tags.contains(_selectedTag)) { + return false; + } + } + // 关键词为空时,只按标签筛选 + if (keyword.isEmpty) { + return true; + } + // 按内容搜索 return note.content.toLowerCase().contains(keyword.toLowerCase()); } + /// 构建标签筛选区域 + Widget _buildTagFilter() { + return Consumer( + builder: (context, provider, child) { + // 获取所有笔记的标签 + final allTags = {}; + for (final note in provider.notes.where((n) => !n.isDeleted)) { + allTags.addAll(note.tags); + } + + if (allTags.isEmpty) { + return const SizedBox.shrink(); + } + + final tags = allTags.toList()..sort(); + + return Container( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.start, + children: tags.map((tag) => GestureDetector( + onTap: () { + setState(() { + // 再次点击同一标签则取消筛选 + if (_selectedTag == tag) { + _selectedTag = null; + } else { + _selectedTag = tag; + } + _performSearch(); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _selectedTag == tag + ? const Color(0xFF1A1A1A) + : const Color(0xFFF5F5F5), + border: Border.all( + color: _selectedTag == tag + ? const Color(0xFF1A1A1A) + : const Color(0xFFE5E5E5), + ), + ), + child: Text( + tag, + style: TextStyle( + fontSize: 12, + color: _selectedTag == tag + ? Colors.white + : const Color(0xFF666666), + ), + ), + ), + )).toList(), + ), + ], + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -113,10 +194,9 @@ class _SearchPageState extends State { setState(() { _selectedType = index; _results = []; + _selectedTag = null; // 切换类型时重置标签 }); - if (_searchController.text.isNotEmpty) { - _performSearch(); - } + _searchController.clear(); }, child: Container( margin: const EdgeInsets.only(right: 12), @@ -173,6 +253,9 @@ class _SearchPageState extends State { ), ), + // 笔记标签筛选(仅在笔记搜索时显示) + if (_selectedType == 2) _buildTagFilter(), + // 搜索结果 Expanded( child: _isSearching diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart index 06878e7..68ba9de 100644 --- a/lib/pages/statistics_page.dart +++ b/lib/pages/statistics_page.dart @@ -1,7 +1,5 @@ -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:fl_chart/fl_chart.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; @@ -13,58 +11,23 @@ class StatisticsPage extends StatefulWidget { State createState() => _StatisticsPageState(); } -class _StatisticsPageState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; - DateTime _selectedMonth = DateTime.now(); - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 3, vsync: this); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } +class _StatisticsPageState extends State { @override Widget build(BuildContext context) { - return DefaultTabController( - length: 3, - child: Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - title: const Text('数据统计'), - bottom: TabBar( - controller: _tabController, - tabs: const [ - Tab(text: '概览'), - Tab(text: '日历'), - Tab(text: '趋势'), - ], - labelColor: const Color(0xFF1A1A1A), - unselectedLabelColor: const Color(0xFF999999), - indicatorColor: const Color(0xFF1A1A1A), - ), - ), - body: Consumer( - builder: (context, provider, child) { - final movies = provider.movies; - final books = provider.books; - final notes = provider.notes; + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('数据统计'), + ), + body: Consumer( + builder: (context, provider, child) { + final movies = provider.movies; + final books = provider.books; + final notes = provider.notes; - return TabBarView( - controller: _tabController, - children: [ - _buildOverviewTab(movies, books, notes), - _buildCalendarTab(movies, books, notes), - _buildTrendTab(movies, books, notes), - ], - ); - }, - ), + return _buildOverviewTab(movies, books, notes); + }, ), ); } @@ -128,165 +91,6 @@ class _StatisticsPageState extends State with SingleTickerProvid ); } - /// 日历标签页 - Widget _buildCalendarTab(List movies, List books, List notes) { - // 合并所有数据按日期 - final Map dailyData = {}; - - for (final movie in movies) { - final date = DateTime(movie.createdAt.year, movie.createdAt.month, movie.createdAt.day); - dailyData.putIfAbsent(date, () => _DailyData()).movies++; - } - - for (final book in books) { - final date = DateTime(book.createdAt.year, book.createdAt.month, book.createdAt.day); - dailyData.putIfAbsent(date, () => _DailyData()).books++; - } - - for (final note in notes) { - final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day); - dailyData.putIfAbsent(date, () => _DailyData()).notes++; - } - - return Column( - children: [ - // 月份选择器 - Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: () { - setState(() { - _selectedMonth = DateTime(_selectedMonth.year, _selectedMonth.month - 1); - }); - }, - ), - Text( - '${_selectedMonth.year}年${_selectedMonth.month}月', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - ), - IconButton( - icon: const Icon(Icons.chevron_right), - onPressed: () { - setState(() { - _selectedMonth = DateTime(_selectedMonth.year, _selectedMonth.month + 1); - }); - }, - ), - ], - ), - ), - - // 热力图图例 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Text('少', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - const SizedBox(width: 8), - ...List.generate(4, (index) { - final opacity = 0.2 + (index * 0.2); - return Container( - width: 12, - height: 12, - margin: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A).withOpacity(opacity), - borderRadius: BorderRadius.circular(2), - ), - ); - }), - const SizedBox(width: 8), - const Text('多', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - ], - ), - ), - const SizedBox(height: 16), - - // 日历热力图 - Expanded( - child: _buildHeatMapCalendar(dailyData), - ), - ], - ); - } - - /// 趋势标签页 - Widget _buildTrendTab(List movies, List books, List notes) { - // 近30天每日新增数据 - final now = DateTime.now(); - final thirtyDaysAgo = now.subtract(const Duration(days: 29)); - - final List<_DailyTrend> movieTrend = []; - final List<_DailyTrend> bookTrend = []; - final List<_DailyTrend> noteTrend = []; - - for (int i = 0; i < 30; i++) { - final date = DateTime(thirtyDaysAgo.year, thirtyDaysAgo.month, thirtyDaysAgo.day + i); - - final movieCount = movies.where((m) { - final mDate = DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day); - return mDate.isAtSameMomentAs(date); - }).length; - - final bookCount = books.where((b) { - final bDate = DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day); - return bDate.isAtSameMomentAs(date); - }).length; - - final noteCount = notes.where((n) { - final nDate = DateTime(n.createdAt.year, n.createdAt.month, n.createdAt.day); - return nDate.isAtSameMomentAs(date); - }).length; - - movieTrend.add(_DailyTrend(date, movieCount)); - bookTrend.add(_DailyTrend(date, bookCount)); - noteTrend.add(_DailyTrend(date, noteCount)); - } - - return ListView( - padding: const EdgeInsets.all(24), - children: [ - _buildSectionTitle('近30天新增趋势'), - const SizedBox(height: 8), - const Text( - '每日新增数据统计', - style: TextStyle(fontSize: 13, color: Color(0xFF999999)), - ), - const SizedBox(height: 24), - - // 折线图 - SizedBox( - height: 250, - child: _buildLineChart(movieTrend, bookTrend, noteTrend), - ), - const SizedBox(height: 32), - - // 图例 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildLegendItem('影视', const Color(0xFFE53935)), - const SizedBox(width: 24), - _buildLegendItem('书籍', const Color(0xFF1E88E5)), - const SizedBox(width: 24), - _buildLegendItem('笔记', const Color(0xFF43A047)), - ], - ), - const SizedBox(height: 32), - - // 近7天统计 - _buildSectionTitle('近7天新增统计'), - const SizedBox(height: 16), - _buildWeeklyStats(movieTrend.sublist(23), bookTrend.sublist(23), noteTrend.sublist(23)), - ], - ); - } - /// 构建统计卡片 Widget _buildStatCard(String title, int count, IconData icon, Color color) { return Container( @@ -388,316 +192,6 @@ class _StatisticsPageState extends State with SingleTickerProvid ); } - /// 构建热力图日历 - Widget _buildHeatMapCalendar(Map dailyData) { - final year = _selectedMonth.year; - final month = _selectedMonth.month; - final daysInMonth = DateTime(year, month + 1, 0).day; - final firstWeekday = DateTime(year, month, 1).weekday % 7; - - // 计算最大数量用于颜色强度 - int maxCount = 0; - for (final data in dailyData.values) { - final count = data.total; - if (count > maxCount) maxCount = count; - } - if (maxCount == 0) maxCount = 1; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - // 星期标题 - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: const ['日', '一', '二', '三', '四', '五', '六'] - .map((d) => SizedBox( - width: 36, - child: Text( - d, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: Color(0xFF999999)), - ), - )) - .toList(), - ), - const SizedBox(height: 8), - - // 日历网格 - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - // 空白填充 - ...List.generate(firstWeekday, (_) => const SizedBox(width: 36, height: 36)), - - // 日期 - ...List.generate(daysInMonth, (index) { - final day = index + 1; - final date = DateTime(year, month, day); - final data = dailyData[date]; - final count = data?.total ?? 0; - - // 计算颜色强度 - double opacity = 0.1; - if (count > 0) { - opacity = 0.3 + (count / maxCount * 0.7); - opacity = opacity.clamp(0.3, 1.0); - } - - return GestureDetector( - onTap: () { - if (count > 0) { - _showDayDetail(context, date, data!); - } - }, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: count > 0 - ? const Color(0xFF1A1A1A).withOpacity(opacity) - : const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(4), - ), - child: Center( - child: Text( - day.toString(), - style: TextStyle( - fontSize: 12, - color: count > 0 ? Colors.white : const Color(0xFF666666), - ), - ), - ), - ), - ); - }), - ], - ), - ], - ), - ); - } - - /// 显示某天详情 - void _showDayDetail(BuildContext context, DateTime date, _DailyData data) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), - builder: (context) => Container( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${date.year}年${date.month}月${date.day}日', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - if (data.movies > 0) - _buildDetailRow(Icons.movie_outlined, '影视', data.movies), - if (data.books > 0) - _buildDetailRow(Icons.menu_book_outlined, '书籍', data.books), - if (data.notes > 0) - _buildDetailRow(Icons.note_outlined, '笔记', data.notes), - const SizedBox(height: 16), - Text( - '总计: ${data.total} 项', - style: const TextStyle(fontSize: 14, color: Color(0xFF666666)), - ), - ], - ), - ), - ); - } - - Widget _buildDetailRow(IconData icon, String label, int count) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Icon(icon, size: 18, color: const Color(0xFF666666)), - const SizedBox(width: 8), - Text(label, style: const TextStyle(fontSize: 14)), - const Spacer(), - Text('$count 个', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), - ], - ), - ); - } - - /// 构建折线图 - Widget _buildLineChart(List<_DailyTrend> movieTrend, List<_DailyTrend> bookTrend, List<_DailyTrend> noteTrend) { - final spotsMovie = movieTrend.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value.count.toDouble()); - }).toList(); - - final spotsBook = bookTrend.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value.count.toDouble()); - }).toList(); - - final spotsNote = noteTrend.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value.count.toDouble()); - }).toList(); - - return LineChart( - LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: 1, - getDrawingHorizontalLine: (value) { - return FlLine( - color: const Color(0xFFE5E5E5), - strokeWidth: 0.5, - ); - }, - ), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, - getTitlesWidget: (value, meta) { - return Text( - value.toInt().toString(), - style: const TextStyle(fontSize: 10, color: Color(0xFF999999)), - ); - }, - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, - interval: 5, - getTitlesWidget: (value, meta) { - if (value.toInt() >= 0 && value.toInt() < movieTrend.length) { - final date = movieTrend[value.toInt()].date; - return Text( - '${date.month}/${date.day}', - style: const TextStyle(fontSize: 10, color: Color(0xFF999999)), - ); - } - return const Text(''); - }, - ), - ), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - ), - borderData: FlBorderData(show: false), - lineBarsData: [ - // 影视折线 - 红色 - LineChartBarData( - spots: spotsMovie, - isCurved: true, - color: const Color(0xFFE53935), - barWidth: 2.5, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - color: const Color(0xFFE53935).withOpacity(0.1), - ), - ), - // 书籍折线 - 蓝色 - LineChartBarData( - spots: spotsBook, - isCurved: true, - color: const Color(0xFF1E88E5), - barWidth: 2.5, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - color: const Color(0xFF1E88E5).withOpacity(0.1), - ), - ), - // 笔记折线 - 绿色 - LineChartBarData( - spots: spotsNote, - isCurved: true, - color: const Color(0xFF43A047), - barWidth: 2.5, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - color: const Color(0xFF43A047).withOpacity(0.1), - ), - ), - ], - ), - ); - } - - /// 构建图例项 - Widget _buildLegendItem(String label, Color color) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 12, - height: 3, - decoration: BoxDecoration(color: color), - ), - const SizedBox(width: 8), - Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF666666))), - ], - ); - } - - /// 构建近7天统计 - Widget _buildWeeklyStats(List<_DailyTrend> movieTrend, List<_DailyTrend> bookTrend, List<_DailyTrend> noteTrend) { - final totalMovies = movieTrend.fold(0, (sum, item) => sum + item.count); - final totalBooks = bookTrend.fold(0, (sum, item) => sum + item.count); - final totalNotes = noteTrend.fold(0, (sum, item) => sum + item.count); - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE5E5E5)), - ), - child: Column( - children: [ - _buildWeeklyRow('影视新增', totalMovies, const Color(0xFFE53935)), - const Divider(height: 24), - _buildWeeklyRow('书籍新增', totalBooks, const Color(0xFF1E88E5)), - const Divider(height: 24), - _buildWeeklyRow('笔记新增', totalNotes, const Color(0xFF43A047)), - const Divider(height: 24), - _buildWeeklyRow('总计', totalMovies + totalBooks + totalNotes, const Color(0xFF1A1A1A), isTotal: true), - ], - ), - ); - } - - Widget _buildWeeklyRow(String label, int count, Color color, {bool isTotal = false}) { - return Row( - children: [ - Text( - label, - style: TextStyle( - fontSize: isTotal ? 14 : 13, - fontWeight: isTotal ? FontWeight.w600 : FontWeight.normal, - ), - ), - const Spacer(), - Text( - '$count 个', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ], - ); - } - Widget _buildSectionTitle(String title) { return Text( title, @@ -719,19 +213,4 @@ class _StatusData { _StatusData(this.label, this.count, this.color); } -/// 每日数据 -class _DailyData { - int movies = 0; - int books = 0; - int notes = 0; - - int get total => movies + books + notes; -} -/// 每日趋势 -class _DailyTrend { - final DateTime date; - final int count; - - _DailyTrend(this.date, this.count); -} diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index b205091..372cdb1 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -9,7 +9,7 @@ import '../models/data_models.dart'; /// 自定义左侧弹出菜单 - 极简主义设计 class CustomDrawer extends StatelessWidget { - const CustomDrawer({super.key}); + CustomDrawer({super.key}); @override Widget build(BuildContext context) { @@ -27,6 +27,11 @@ class CustomDrawer extends StatelessWidget { const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + // 日历热力图区域 + _buildCalendarSection(context), + + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)), + // 菜单项列表 Expanded( child: ListView( @@ -411,6 +416,197 @@ class CustomDrawer extends StatelessWidget { void _showToast(BuildContext context, String message) { ToastUtil.show(context, message); } + + /// 构建日历热力图区域 + Widget _buildCalendarSection(BuildContext context) { + return StatefulBuilder( + builder: (context, setState) { + return Consumer( + builder: (context, provider, child) { + // 使用 StatefulBuilder 的状态来管理选中的月份 + final selectedMonth = _calendarSelectedMonth ?? DateTime.now(); + + // 合并所有数据按日期 + final Map dailyData = {}; + + for (final movie in provider.movies.where((m) => !m.isDeleted)) { + final date = DateTime(movie.createdAt.year, movie.createdAt.month, movie.createdAt.day); + dailyData.putIfAbsent(date, () => _DailyData()).movies++; + } + + for (final book in provider.books.where((b) => !b.isDeleted)) { + final date = DateTime(book.createdAt.year, book.createdAt.month, book.createdAt.day); + dailyData.putIfAbsent(date, () => _DailyData()).books++; + } + + for (final note in provider.notes.where((n) => !n.isDeleted)) { + final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day); + dailyData.putIfAbsent(date, () => _DailyData()).notes++; + } + + // 计算最大数量用于颜色强度 + int maxCount = 0; + for (final data in dailyData.values) { + final count = data.total; + if (count > maxCount) maxCount = count; + } + if (maxCount == 0) maxCount = 1; + + final year = selectedMonth.year; + final month = selectedMonth.month; + final daysInMonth = DateTime(year, month + 1, 0).day; + final firstWeekday = DateTime(year, month, 1).weekday % 7; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题和月份切换 + Row( + children: [ + const Icon( + Icons.calendar_today, + size: 16, + color: Color(0xFF666666), + ), + const SizedBox(width: 8), + // 上个月按钮 + GestureDetector( + onTap: () { + setState(() { + _calendarSelectedMonth = DateTime(year, month - 1); + }); + }, + child: const Icon( + Icons.chevron_left, + size: 20, + color: Color(0xFF666666), + ), + ), + const SizedBox(width: 8), + Text( + '$year年$month月', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF666666), + ), + ), + const SizedBox(width: 8), + // 下个月按钮 + GestureDetector( + onTap: () { + setState(() { + _calendarSelectedMonth = DateTime(year, month + 1); + }); + }, + child: const Icon( + Icons.chevron_right, + size: 20, + color: Color(0xFF666666), + ), + ), + ], + ), + const SizedBox(height: 12), + + // 星期标题 - 使用与日期相同的Wrap布局 + Wrap( + spacing: 4, + runSpacing: 4, + children: const ['日', '一', '二', '三', '四', '五', '六'] + .map((d) => SizedBox( + width: 32, + height: 20, + child: Text( + d, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 10, color: Color(0xFF999999)), + ), + )) + .toList(), + ), + const SizedBox(height: 4), + + // 日历网格 + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + // 空白填充 + ...List.generate(firstWeekday, (_) => const SizedBox(width: 32, height: 28)), + + // 日期 + ...List.generate(daysInMonth, (index) { + final day = index + 1; + final date = DateTime(year, month, day); + final data = dailyData[date]; + final count = data?.total ?? 0; + + // 计算颜色强度 + double opacity = 0.1; + if (count > 0) { + opacity = 0.3 + (count / maxCount * 0.7); + opacity = opacity.clamp(0.3, 1.0); + } + + return Container( + width: 32, + height: 28, + decoration: BoxDecoration( + color: count > 0 + ? const Color(0xFF1A1A1A).withOpacity(opacity) + : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Text( + day.toString(), + style: TextStyle( + fontSize: 10, + color: count > 0 ? Colors.white : const Color(0xFF666666), + ), + ), + ), + ); + }), + ], + ), + + // 图例 + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text('少', style: TextStyle(fontSize: 10, color: Color(0xFF999999))), + const SizedBox(width: 4), + ...List.generate(4, (index) { + final opacity = 0.2 + (index * 0.2); + return Container( + width: 10, + height: 10, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A).withOpacity(opacity), + borderRadius: BorderRadius.circular(2), + ), + ); + }), + const SizedBox(width: 4), + const Text('多', style: TextStyle(fontSize: 10, color: Color(0xFF999999))), + ], + ), + ], + ), + ); + }); + }); + } + + // 日历选中的月份状态(用于 StatefulBuilder) + static DateTime? _calendarSelectedMonth; } /// 回顾项数据类 @@ -427,3 +623,12 @@ class _MemoryItem { this.imagePath, }); } + +/// 每日数据 +class _DailyData { + int movies = 0; + int books = 0; + int notes = 0; + + int get total => movies + books + notes; +} diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index 8a5ad83..70cdc6c 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -11,6 +11,21 @@ class NoteListItem extends StatelessWidget { const NoteListItem({super.key, required this.note}); + @override + Widget build(BuildContext context) { + // 使用 RepaintBoundary 减少重绘 + return RepaintBoundary( + child: _NoteListItemContent(note: note), + ); + } +} + +/// 笔记列表项内容 - 分离出来便于优化 +class _NoteListItemContent extends StatelessWidget { + final Note note; + + const _NoteListItemContent({required this.note}); + @override Widget build(BuildContext context) { final isPlainText = note.contentType == 'plain_text'; @@ -50,9 +65,9 @@ class NoteListItem extends StatelessWidget { ), ), const SizedBox(width: 8), - // 时间 + // 时间 - 使用缓存的格式化结果 Text( - _formatDate(note.updatedAt), + _formatDateCached(note.updatedAt), style: const TextStyle( fontSize: 11, color: Color(0xFF999999), @@ -100,18 +115,11 @@ class NoteListItem extends StatelessWidget { child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: note.images.length > 2 ? 2 : note.images.length, + physics: const NeverScrollableScrollPhysics(), // 禁用滚动,提高性能 itemBuilder: (context, index) { - return Container( - width: 60, - height: 60, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE5E5E5)), - ), - child: Image.file( - File(note.images[index]), - fit: BoxFit.cover, - ), + return _NoteImage( + imagePath: note.images[index], + index: index, ); }, ), @@ -148,26 +156,6 @@ class NoteListItem extends StatelessWidget { ); } - /// 格式化日期 - String _formatDate(DateTime date) { - final now = DateTime.now(); - final difference = now.difference(date); - - if (difference.inDays == 0) { - if (difference.inHours == 0) { - if (difference.inMinutes == 0) { - return '刚刚'; - } - return '${difference.inMinutes}分钟前'; - } - return '${difference.inHours}小时前'; - } else if (difference.inDays < 7) { - return '${difference.inDays}天前'; - } else { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - } - } - /// 显示删除确认对话框 void _showDeleteDialog(BuildContext context) { showDialog( @@ -196,3 +184,75 @@ class NoteListItem extends StatelessWidget { ); } } + +/// 笔记图片组件 - 独立出来便于优化 +class _NoteImage extends StatelessWidget { + final String imagePath; + final int index; + + const _NoteImage({ + required this.imagePath, + required this.index, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 60, + height: 60, + margin: EdgeInsets.only(right: index < 1 ? 8 : 0), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Image.file( + File(imagePath), + fit: BoxFit.cover, + // 使用低内存缓存 + cacheWidth: 120, + cacheHeight: 120, + errorBuilder: (_, __, ___) => const Icon( + Icons.broken_image, + size: 24, + color: Color(0xFFCCCCCC), + ), + ), + ); + } +} + +// 日期格式化缓存 +final Map _dateFormatCache = {}; + +/// 格式化日期(带缓存) +String _formatDateCached(DateTime date) { + // 使用日期部分作为缓存键(忽略时分秒) + final cacheKey = DateTime(date.year, date.month, date.day); + + if (_dateFormatCache.containsKey(cacheKey)) { + return _dateFormatCache[cacheKey]!; + } + + final result = _formatDate(date); + _dateFormatCache[cacheKey] = result; + return result; +} + +/// 格式化日期 +String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + if (difference.inHours == 0) { + if (difference.inMinutes == 0) { + return '刚刚'; + } + return '${difference.inMinutes}分钟前'; + } + return '${difference.inHours}小时前'; + } else if (difference.inDays < 7) { + return '${difference.inDays}天前'; + } else { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +}