generated from dellevin/template
635 lines
21 KiB
Dart
635 lines
21 KiB
Dart
import 'dart:io';
|
||
import 'dart:math';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '../providers/app_provider.dart';
|
||
import '../utils/user_prefs.dart';
|
||
import '../utils/toast_util.dart';
|
||
import '../models/data_models.dart';
|
||
|
||
/// 自定义左侧弹出菜单 - 极简主义设计
|
||
class CustomDrawer extends StatelessWidget {
|
||
CustomDrawer({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Drawer(
|
||
backgroundColor: Colors.white,
|
||
child: Column(
|
||
children: [
|
||
// 顶部用户信息区域
|
||
_buildHeader(context),
|
||
|
||
const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
|
||
|
||
// 回顾功能区域
|
||
_buildMemorySection(context),
|
||
|
||
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(
|
||
padding: EdgeInsets.zero,
|
||
children: [],
|
||
),
|
||
),
|
||
|
||
// 底部版本信息
|
||
Container(
|
||
padding: const EdgeInsets.all(24),
|
||
child: const Text(
|
||
'MookNote v0.1.5',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Color(0xFF999999),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 构建回顾功能区域
|
||
Widget _buildMemorySection(BuildContext context) {
|
||
return Consumer<AppProvider>(
|
||
builder: (context, provider, child) {
|
||
final memoryItem = _getRandomMemoryItem(provider);
|
||
|
||
if (memoryItem == null) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
final memoryText = _buildMemoryText(memoryItem);
|
||
final timeAgo = _getTimeAgoText(memoryItem.date);
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 标题
|
||
Row(
|
||
children: [
|
||
const Icon(
|
||
Icons.history,
|
||
size: 16,
|
||
color: Color(0xFF666666),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Text(
|
||
'回顾',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
color: Color(0xFF666666),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
// 内容卡片(带头图)
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 头图(影视/书籍显示,笔记不显示)
|
||
if (memoryItem.imagePath != null && memoryItem.imagePath!.isNotEmpty)
|
||
Container(
|
||
width: 60,
|
||
height: 80,
|
||
margin: const EdgeInsets.only(right: 12),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF5F5F5),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: Image.file(
|
||
File(memoryItem.imagePath!),
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => const Icon(
|
||
Icons.image,
|
||
color: Color(0xFFCCCCCC),
|
||
),
|
||
),
|
||
),
|
||
)
|
||
else if (memoryItem.type != 'note')
|
||
Container(
|
||
width: 60,
|
||
height: 80,
|
||
margin: const EdgeInsets.only(right: 12),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF5F5F5),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Icon(
|
||
memoryItem.type == 'movie' ? Icons.movie : Icons.menu_book,
|
||
color: const Color(0xFFCCCCCC),
|
||
size: 24,
|
||
),
|
||
),
|
||
|
||
// 文字内容
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 时间标签
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF5F5F5),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
timeAgo,
|
||
style: const TextStyle(
|
||
fontSize: 11,
|
||
color: Color(0xFF999999),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
|
||
// 内容文字
|
||
Text(
|
||
memoryText,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
color: Color(0xFF1A1A1A),
|
||
height: 1.5,
|
||
),
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 获取随机回顾项
|
||
_MemoryItem? _getRandomMemoryItem(AppProvider provider) {
|
||
final now = DateTime.now();
|
||
final candidates = <_MemoryItem>[];
|
||
|
||
// 收集所有未删除的影视、书籍(去掉笔记)
|
||
for (final movie in provider.movies.where((m) => !m.isDeleted)) {
|
||
candidates.add(_MemoryItem(
|
||
type: 'movie',
|
||
title: movie.title,
|
||
date: movie.createdAt,
|
||
imagePath: movie.posterPath,
|
||
));
|
||
}
|
||
|
||
for (final book in provider.books.where((b) => !b.isDeleted)) {
|
||
candidates.add(_MemoryItem(
|
||
type: 'book',
|
||
title: book.title,
|
||
date: book.createdAt,
|
||
imagePath: book.coverPath,
|
||
));
|
||
}
|
||
|
||
if (candidates.isEmpty) return null;
|
||
|
||
// 优先选择1个月、3个月、6个月、1年前的数据
|
||
final oneMonthAgo = now.subtract(const Duration(days: 30));
|
||
final threeMonthsAgo = now.subtract(const Duration(days: 90));
|
||
final sixMonthsAgo = now.subtract(const Duration(days: 180));
|
||
final oneYearAgo = now.subtract(const Duration(days: 365));
|
||
|
||
final memoryCandidates = candidates.where((item) {
|
||
return _isInTimeRange(item.date, oneMonthAgo, threeMonthsAgo, sixMonthsAgo, oneYearAgo);
|
||
}).toList();
|
||
|
||
// 如果有符合时间范围的,从中随机选择;否则从所有数据中随机选择
|
||
final random = Random();
|
||
final selectedList = memoryCandidates.isNotEmpty ? memoryCandidates : candidates;
|
||
return selectedList[random.nextInt(selectedList.length)];
|
||
}
|
||
|
||
/// 检查时间是否在范围内(1月、3月、6月、1年前)
|
||
bool _isInTimeRange(DateTime date, DateTime oneMonth, DateTime threeMonths,
|
||
DateTime sixMonths, DateTime oneYear) {
|
||
// 检查是否在1个月前左右(±7天)
|
||
if (_isCloseTo(date, oneMonth)) return true;
|
||
// 检查是否在3个月前左右(±14天)
|
||
if (_isCloseTo(date, threeMonths, days: 14)) return true;
|
||
// 检查是否在6个月前左右(±30天)
|
||
if (_isCloseTo(date, sixMonths, days: 30)) return true;
|
||
// 检查是否在1年前左右(±30天)
|
||
if (_isCloseTo(date, oneYear, days: 30)) return true;
|
||
return false;
|
||
}
|
||
|
||
/// 检查两个日期是否接近
|
||
bool _isCloseTo(DateTime date, DateTime target, {int days = 7}) {
|
||
final diff = date.difference(target).inDays.abs();
|
||
return diff <= days;
|
||
}
|
||
|
||
/// 构建回顾文本
|
||
String _buildMemoryText(_MemoryItem item) {
|
||
// 只显示标题,不添加描述前缀
|
||
return '《${item.title}》';
|
||
}
|
||
|
||
/// 获取时间描述文本
|
||
String _getTimeAgoText(DateTime date) {
|
||
final now = DateTime.now();
|
||
final diff = now.difference(date);
|
||
|
||
if (diff.inDays >= 365) {
|
||
return '1年前';
|
||
} else if (diff.inDays >= 180) {
|
||
return '6个月前';
|
||
} else if (diff.inDays >= 90) {
|
||
return '3个月前';
|
||
} else if (diff.inDays >= 30) {
|
||
return '1个月前';
|
||
} else {
|
||
return '${diff.inDays}天前';
|
||
}
|
||
}
|
||
|
||
/// 构建头部
|
||
Widget _buildHeader(BuildContext context) {
|
||
return Consumer<AppProvider>(
|
||
builder: (context, provider, child) {
|
||
// 统计数据
|
||
final movieCount = provider.movies.where((m) => !m.isDeleted).length;
|
||
final bookCount = provider.books.length;
|
||
final noteCount = provider.notes.length;
|
||
|
||
// 获取用户信息
|
||
final userPrefs = UserPrefs();
|
||
final nickname = userPrefs.nickname;
|
||
final motto = userPrefs.motto;
|
||
final avatarPath = userPrefs.avatarPath;
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 用户头像
|
||
Container(
|
||
width: 64,
|
||
height: 64,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF5F5F5),
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
|
||
),
|
||
child: avatarPath != null && avatarPath.isNotEmpty
|
||
? ClipOval(
|
||
child: Image.file(
|
||
File(avatarPath),
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => _buildAvatarPlaceholder(),
|
||
),
|
||
)
|
||
: _buildAvatarPlaceholder(),
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
// 用户名称
|
||
Text(
|
||
nickname,
|
||
style: const TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w600,
|
||
color: Color(0xFF1A1A1A),
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 4),
|
||
|
||
// 座右铭
|
||
Text(
|
||
motto,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
color: Color(0xFF666666),
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// 简化统计
|
||
Row(
|
||
children: [
|
||
_buildStatItem('观影', movieCount),
|
||
const SizedBox(width: 24),
|
||
_buildStatItem('阅读', bookCount),
|
||
const SizedBox(width: 24),
|
||
_buildStatItem('笔记', noteCount),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildAvatarPlaceholder() {
|
||
return const Center(
|
||
child: Icon(
|
||
Icons.person_outline,
|
||
size: 32,
|
||
color: Color(0xFF999999),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 统计项
|
||
Widget _buildStatItem(String label, int count) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'$count',
|
||
style: const TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w600,
|
||
color: Color(0xFF1A1A1A),
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: Color(0xFF999999),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 菜单项
|
||
Widget _buildMenuItem({
|
||
required IconData icon,
|
||
required String title,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
return ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||
leading: Icon(icon, size: 22, color: const Color(0xFF666666)),
|
||
title: Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
color: Color(0xFF1A1A1A),
|
||
),
|
||
),
|
||
trailing: const Icon(
|
||
Icons.chevron_right,
|
||
size: 20,
|
||
color: Color(0xFFCCCCCC),
|
||
),
|
||
onTap: onTap,
|
||
);
|
||
}
|
||
|
||
/// 显示提示
|
||
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;
|
||
}
|
||
|
||
/// 回顾项数据类
|
||
class _MemoryItem {
|
||
final String type; // 'movie', 'book', 'note'
|
||
final String title;
|
||
final DateTime date;
|
||
final String? imagePath; // 头图路径(影视/书籍有,笔记无)
|
||
|
||
_MemoryItem({
|
||
required this.type,
|
||
required this.title,
|
||
required this.date,
|
||
this.imagePath,
|
||
});
|
||
}
|
||
|
||
/// 每日数据
|
||
class _DailyData {
|
||
int movies = 0;
|
||
int books = 0;
|
||
int notes = 0;
|
||
|
||
int get total => movies + books + notes;
|
||
}
|