generated from dellevin/template
功能优化
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
@@ -19,7 +18,6 @@ class _BackupPageState extends State<BackupPage> {
|
|||||||
bool _isImporting = false;
|
bool _isImporting = false;
|
||||||
bool _autoBackupEnabled = false;
|
bool _autoBackupEnabled = false;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
List<File> _autoBackupFiles = [];
|
|
||||||
String? _backupDirPath;
|
String? _backupDirPath;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -30,12 +28,10 @@ class _BackupPageState extends State<BackupPage> {
|
|||||||
|
|
||||||
Future<void> _loadAutoBackupStatus() async {
|
Future<void> _loadAutoBackupStatus() async {
|
||||||
final enabled = await AutoBackupService.instance.getEnabled();
|
final enabled = await AutoBackupService.instance.getEnabled();
|
||||||
final files = await AutoBackupService.instance.getBackupFiles();
|
|
||||||
final dirPath = await AutoBackupService.instance.getBackupDirectoryPath();
|
final dirPath = await AutoBackupService.instance.getBackupDirectoryPath();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_autoBackupEnabled = enabled;
|
_autoBackupEnabled = enabled;
|
||||||
_autoBackupFiles = files;
|
|
||||||
_backupDirPath = dirPath;
|
_backupDirPath = dirPath;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
@@ -59,12 +55,6 @@ class _BackupPageState extends State<BackupPage> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// 自动备份文件列表
|
|
||||||
if (_autoBackupFiles.isNotEmpty) ...[
|
|
||||||
_buildBackupFilesSection(),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
|
|
||||||
// 导出数据
|
// 导出数据
|
||||||
_buildSection(
|
_buildSection(
|
||||||
title: '导出数据',
|
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')}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
// 左侧弹出菜单(仅在主页显示)
|
// 左侧弹出菜单(仅在主页显示)
|
||||||
drawer: context.watch<AppProvider>().bottomNavIndex == 0
|
drawer: context.watch<AppProvider>().bottomNavIndex == 0
|
||||||
? const CustomDrawer()
|
? CustomDrawer()
|
||||||
: null,
|
: null,
|
||||||
|
|
||||||
// 主体内容 - 根据底部导航切换
|
// 主体内容 - 根据底部导航切换
|
||||||
|
|||||||
@@ -5,14 +5,86 @@ import '../models/data_models.dart';
|
|||||||
import '../widgets/note_list_item.dart';
|
import '../widgets/note_list_item.dart';
|
||||||
|
|
||||||
/// 笔记标签页
|
/// 笔记标签页
|
||||||
class NoteTabPage extends StatelessWidget {
|
class NoteTabPage extends StatefulWidget {
|
||||||
const NoteTabPage({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// 笔记列表(无状态筛选,显示所有笔记)
|
// 笔记列表(分页加载)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildNoteList(context),
|
child: _buildNoteList(context),
|
||||||
),
|
),
|
||||||
@@ -24,21 +96,38 @@ class NoteTabPage extends StatelessWidget {
|
|||||||
Widget _buildNoteList(BuildContext context) {
|
Widget _buildNoteList(BuildContext context) {
|
||||||
return Consumer<AppProvider>(
|
return Consumer<AppProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
final notes = provider.notes;
|
final allNotes = provider.notes;
|
||||||
|
|
||||||
if (notes.isEmpty) {
|
if (allNotes.isEmpty && _displayedNotes.isEmpty) {
|
||||||
return _buildEmptyState(context);
|
return _buildEmptyState(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async => await provider.loadNotes(),
|
onRefresh: _refresh,
|
||||||
color: const Color(0xFF1A1A1A),
|
color: const Color(0xFF1A1A1A),
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: notes.length,
|
itemCount: _displayedNotes.length + (_hasMore ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
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]);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
int _selectedType = 0; // 0: 影视, 1: 书籍, 2: 笔记
|
int _selectedType = 0; // 0: 影视, 1: 书籍, 2: 笔记
|
||||||
List<dynamic> _results = [];
|
List<dynamic> _results = [];
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
|
String? _selectedTag; // 选中的标签
|
||||||
|
|
||||||
final List<String> _typeLabels = ['影视', '书籍', '笔记'];
|
final List<String> _typeLabels = ['影视', '书籍', '笔记'];
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
|
|
||||||
Future<void> _performSearch() async {
|
Future<void> _performSearch() async {
|
||||||
final keyword = _searchController.text.trim();
|
final keyword = _searchController.text.trim();
|
||||||
if (keyword.isEmpty) return;
|
// 笔记搜索允许空关键词(用于标签筛选)
|
||||||
|
if (keyword.isEmpty && _selectedType != 2 && _selectedTag == null) return;
|
||||||
|
|
||||||
setState(() => _isSearching = true);
|
setState(() => _isSearching = true);
|
||||||
|
|
||||||
@@ -85,9 +87,88 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _matchNote(Note note, String keyword) {
|
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());
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -113,10 +194,9 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedType = index;
|
_selectedType = index;
|
||||||
_results = [];
|
_results = [];
|
||||||
|
_selectedTag = null; // 切换类型时重置标签
|
||||||
});
|
});
|
||||||
if (_searchController.text.isNotEmpty) {
|
_searchController.clear();
|
||||||
_performSearch();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(right: 12),
|
margin: const EdgeInsets.only(right: 12),
|
||||||
@@ -173,6 +253,9 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 笔记标签筛选(仅在笔记搜索时显示)
|
||||||
|
if (_selectedType == 2) _buildTagFilter(),
|
||||||
|
|
||||||
// 搜索结果
|
// 搜索结果
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isSearching
|
child: _isSearching
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:math';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../models/data_models.dart';
|
import '../models/data_models.dart';
|
||||||
|
|
||||||
@@ -13,58 +11,23 @@ class StatisticsPage extends StatefulWidget {
|
|||||||
State<StatisticsPage> createState() => _StatisticsPageState();
|
State<StatisticsPage> createState() => _StatisticsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatisticsPageState extends State<StatisticsPage> with SingleTickerProviderStateMixin {
|
class _StatisticsPageState extends State<StatisticsPage> {
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DefaultTabController(
|
return Scaffold(
|
||||||
length: 3,
|
backgroundColor: Colors.white,
|
||||||
child: Scaffold(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
title: const Text('数据统计'),
|
||||||
appBar: AppBar(
|
),
|
||||||
title: const Text('数据统计'),
|
body: Consumer<AppProvider>(
|
||||||
bottom: TabBar(
|
builder: (context, provider, child) {
|
||||||
controller: _tabController,
|
final movies = provider.movies;
|
||||||
tabs: const [
|
final books = provider.books;
|
||||||
Tab(text: '概览'),
|
final notes = provider.notes;
|
||||||
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 TabBarView(
|
return _buildOverviewTab(movies, books, notes);
|
||||||
controller: _tabController,
|
},
|
||||||
children: [
|
|
||||||
_buildOverviewTab(movies, books, notes),
|
|
||||||
_buildCalendarTab(movies, books, notes),
|
|
||||||
_buildTrendTab(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) {
|
Widget _buildStatCard(String title, int count, IconData icon, Color color) {
|
||||||
return Container(
|
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) {
|
Widget _buildSectionTitle(String title) {
|
||||||
return Text(
|
return Text(
|
||||||
title,
|
title,
|
||||||
@@ -719,19 +213,4 @@ class _StatusData {
|
|||||||
_StatusData(this.label, this.count, this.color);
|
_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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '../models/data_models.dart';
|
|||||||
|
|
||||||
/// 自定义左侧弹出菜单 - 极简主义设计
|
/// 自定义左侧弹出菜单 - 极简主义设计
|
||||||
class CustomDrawer extends StatelessWidget {
|
class CustomDrawer extends StatelessWidget {
|
||||||
const CustomDrawer({super.key});
|
CustomDrawer({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -27,6 +27,11 @@ class CustomDrawer extends StatelessWidget {
|
|||||||
|
|
||||||
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
|
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(
|
Expanded(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
@@ -411,6 +416,197 @@ class CustomDrawer extends StatelessWidget {
|
|||||||
void _showToast(BuildContext context, String message) {
|
void _showToast(BuildContext context, String message) {
|
||||||
ToastUtil.show(context, 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,
|
this.imagePath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 每日数据
|
||||||
|
class _DailyData {
|
||||||
|
int movies = 0;
|
||||||
|
int books = 0;
|
||||||
|
int notes = 0;
|
||||||
|
|
||||||
|
int get total => movies + books + notes;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ class NoteListItem extends StatelessWidget {
|
|||||||
|
|
||||||
const NoteListItem({super.key, required this.note});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isPlainText = note.contentType == 'plain_text';
|
final isPlainText = note.contentType == 'plain_text';
|
||||||
@@ -50,9 +65,9 @@ class NoteListItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// 时间
|
// 时间 - 使用缓存的格式化结果
|
||||||
Text(
|
Text(
|
||||||
_formatDate(note.updatedAt),
|
_formatDateCached(note.updatedAt),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Color(0xFF999999),
|
color: Color(0xFF999999),
|
||||||
@@ -100,18 +115,11 @@ class NoteListItem extends StatelessWidget {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: note.images.length > 2 ? 2 : note.images.length,
|
itemCount: note.images.length > 2 ? 2 : note.images.length,
|
||||||
|
physics: const NeverScrollableScrollPhysics(), // 禁用滚动,提高性能
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return Container(
|
return _NoteImage(
|
||||||
width: 60,
|
imagePath: note.images[index],
|
||||||
height: 60,
|
index: index,
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -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) {
|
void _showDeleteDialog(BuildContext context) {
|
||||||
showDialog(
|
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')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user