功能优化

This commit is contained in:
DelLevin-Home
2026-03-09 14:14:38 +08:00
parent c894921472
commit 7c970cde49
7 changed files with 496 additions and 705 deletions

View File

@@ -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<BackupPage> {
bool _isImporting = false;
bool _autoBackupEnabled = false;
bool _isLoading = true;
List<File> _autoBackupFiles = [];
String? _backupDirPath;
@override
@@ -30,12 +28,10 @@ class _BackupPageState extends State<BackupPage> {
Future<void> _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<BackupPage> {
const SizedBox(height: 32),
// 自动备份文件列表
if (_autoBackupFiles.isNotEmpty) ...[
_buildBackupFilesSection(),
const SizedBox(height: 32),
],
// 导出数据
_buildSection(
title: '导出数据',
@@ -406,119 +396,4 @@ class _BackupPageState extends State<BackupPage> {
);
}
/// 构建备份文件列表区域
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')}';
}
}

View File

@@ -20,7 +20,7 @@ class _HomePageState extends State<HomePage> {
return Scaffold(
// 左侧弹出菜单(仅在主页显示)
drawer: context.watch<AppProvider>().bottomNavIndex == 0
? const CustomDrawer()
? CustomDrawer()
: null,
// 主体内容 - 根据底部导航切换

View File

@@ -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<NoteTabPage> createState() => _NoteTabPageState();
}
class _NoteTabPageState extends State<NoteTabPage> {
// 使用分页加载
static const int _pageSize = 50;
final List<Note> _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<void> _loadMoreNotes() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
// 使用微任务延迟加载避免阻塞UI
await Future.microtask(() {
final provider = context.read<AppProvider>();
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<void> _refresh() async {
final provider = context.read<AppProvider>();
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<AppProvider>(
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]);
},
),
);

View File

@@ -21,6 +21,7 @@ class _SearchPageState extends State<SearchPage> {
int _selectedType = 0; // 0: 影视, 1: 书籍, 2: 笔记
List<dynamic> _results = [];
bool _isSearching = false;
String? _selectedTag; // 选中的标签
final List<String> _typeLabels = ['影视', '书籍', '笔记'];
@@ -32,7 +33,8 @@ class _SearchPageState extends State<SearchPage> {
Future<void> _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<SearchPage> {
}
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<AppProvider>(
builder: (context, provider, child) {
// 获取所有笔记的标签
final allTags = <String>{};
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<SearchPage> {
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<SearchPage> {
),
),
// 笔记标签筛选(仅在笔记搜索时显示)
if (_selectedType == 2) _buildTagFilter(),
// 搜索结果
Expanded(
child: _isSearching

View File

@@ -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<StatisticsPage> createState() => _StatisticsPageState();
}
class _StatisticsPageState extends State<StatisticsPage> 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<StatisticsPage> {
@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<AppProvider>(
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<AppProvider>(
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<StatisticsPage> with SingleTickerProvid
);
}
/// 日历标签页
Widget _buildCalendarTab(List<Movie> movies, List<Book> books, List<Note> notes) {
// 合并所有数据按日期
final Map<DateTime, _DailyData> 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<Movie> movies, List<Book> books, List<Note> 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<StatisticsPage> with SingleTickerProvid
);
}
/// 构建热力图日历
Widget _buildHeatMapCalendar(Map<DateTime, _DailyData> 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);
}

View File

@@ -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<AppProvider>(
builder: (context, provider, child) {
// 使用 StatefulBuilder 的状态来管理选中的月份
final selectedMonth = _calendarSelectedMonth ?? DateTime.now();
// 合并所有数据按日期
final Map<DateTime, _DailyData> 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;
}

View File

@@ -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<DateTime, String> _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')}';
}
}