Files
MookNote/lib/pages/explore/media_calendar_page.dart
2026-08-09 14:46:50 +08:00

516 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/app_provider.dart';
import '../../models/data_models.dart';
import '../../utils/user_prefs.dart';
import '../../widgets/fade_in_local_image.dart';
import '../movies/movie_detail_page.dart';
import '../movies/movie_form_page.dart';
import '../book/book_detail_page.dart';
import '../book/book_form_page.dart';
/// 书影日历 - 按月展示影视/书籍添加记录
class MediaCalendarPage extends StatefulWidget {
const MediaCalendarPage({super.key});
@override
State<MediaCalendarPage> createState() => _MediaCalendarPageState();
}
class _MediaCalendarPageState extends State<MediaCalendarPage> {
late DateTime _currentMonth;
DateTime? _selectedDay;
int _dateMode = 0; // 0: 创建日期, 1: 观看/开始阅读日期
// {DateTime(dayOnly): [{path, title, type, data}]}
late Map<DateTime, List<_CalendarItem>> _dayItems;
@override
void initState() {
super.initState();
_dateMode = UserPrefs().calendarDateMode;
final now = DateTime.now();
_currentMonth = DateTime(now.year, now.month);
_selectedDay = DateTime(now.year, now.month, now.day);
_buildDayMap();
}
void _buildDayMap() {
final provider = context.read<AppProvider>();
final map = <DateTime, List<_CalendarItem>>{};
for (final m in provider.movies.where((m) => !m.isDeleted)) {
if (m.posterPath == null || m.posterPath!.isEmpty) continue;
final date = _dateMode == 1 ? m.watchDate : m.createdAt;
if (date == null) continue;
final day = DateTime(date.year, date.month, date.day);
map.putIfAbsent(day, () => []);
map[day]!.add(_CalendarItem(
path: m.posterPath!,
title: m.title,
type: 'movie',
data: m,
));
}
for (final b in provider.books.where((b) => !b.isDeleted)) {
if (b.coverPath == null || b.coverPath!.isEmpty) continue;
final date = _dateMode == 1 ? b.startDate : b.createdAt;
if (date == null) continue;
final day = DateTime(date.year, date.month, date.day);
map.putIfAbsent(day, () => []);
map[day]!.add(_CalendarItem(
path: b.coverPath!,
title: b.title,
type: 'book',
data: b,
));
}
_dayItems = map;
}
Widget _buildDateModeToggle(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.only(right: 4),
child: TextButton.icon(
onPressed: () {
setState(() {
_dateMode = _dateMode == 0 ? 1 : 0;
UserPrefs().setCalendarDateMode(_dateMode);
_buildDayMap();
});
},
icon: Icon(_dateMode == 0 ? Icons.calendar_today_outlined : Icons.visibility_outlined, size: 16, color: colors.primary),
label: Text(_dateMode == 0 ? '创建日期' : '观看/阅读', style: TextStyle(fontSize: 12, color: colors.primary)),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
);
}
void _prevMonth() {
setState(() {
_currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1);
_selectedDay = null;
});
}
void _nextMonth() {
setState(() {
_currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1);
_selectedDay = null;
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: const Text('书影日历'),
actions: [
_buildDateModeToggle(colors),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
// 日历区域固定,详情区独立滚动
final calendar = _buildCalendarArea(colors, today, constraints.maxWidth);
return Column(
children: [
_buildMonthHeader(colors),
_buildWeekdayLabels(colors),
calendar,
Expanded(
child: _buildSelectedDayDetail(colors),
),
],
);
},
),
floatingActionButton: _selectedDay != null
? FloatingActionButton(
onPressed: () => _showAddMenu(context),
backgroundColor: colors.primary,
child: Icon(Icons.add, color: colors.onPrimary),
)
: null,
);
}
// ─── 月份标题 ───
Widget _buildMonthHeader(ColorScheme colors) {
final months = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
IconButton(
onPressed: _prevMonth,
icon: Icon(Icons.chevron_left, color: colors.onSurface.withValues(alpha: 0.6)),
),
Expanded(
child: Text(
'${_currentMonth.year}${months[_currentMonth.month - 1]}',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface),
),
),
IconButton(
onPressed: _nextMonth,
icon: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.6)),
),
],
),
);
}
// ─── 星期头 ───
Widget _buildWeekdayLabels(ColorScheme colors) {
const weekdays = ['', '', '', '', '', '', ''];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: weekdays.map((d) => Expanded(
child: Center(child: Text(d, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.35)))),
)).toList(),
),
);
}
// ─── 日历网格 ───
/// 日历区域:横滑切月 + cell 自适应
Widget _buildCalendarArea(ColorScheme colors, DateTime today, double maxWidth) {
// 可用宽度去掉左右 padding8+8每行 7 格
final cellSize = ((maxWidth - 16) / 7).clamp(44.0, 76.0);
// 横滑切月手势
return GestureDetector(
onHorizontalDragEnd: (details) {
final v = details.primaryVelocity;
if (v == null) return;
const threshold = 300.0;
if (v < -threshold) {
_nextMonth();
} else if (v > threshold) {
_prevMonth();
}
},
child: _buildCalendarGrid(colors, today, cellSize),
);
}
Widget _buildCalendarGrid(ColorScheme colors, DateTime today, double cellSize) {
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final startOffset = firstDay.weekday - 1;
final totalDays = lastDay.day;
final totalCells = startOffset + totalDays;
final rows = (totalCells / 7).ceil();
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Column(
children: List.generate(rows, (row) {
return SizedBox(
height: cellSize,
child: Row(
children: List.generate(7, (col) {
final index = row * 7 + col;
if (index < startOffset || index >= startOffset + totalDays) {
return const Expanded(child: SizedBox());
}
final day = index - startOffset + 1;
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final isToday = date == today;
final isSelected = _selectedDay == date;
final items = _dayItems[date] ?? [];
return Expanded(child: _buildDayCell(colors, date, day, isToday, isSelected, items));
}),
),
);
}),
),
);
}
Widget _buildDayCell(ColorScheme colors, DateTime date, int day, bool isToday, bool isSelected, List<_CalendarItem> items) {
final hasItems = items.isNotEmpty;
return GestureDetector(
onTap: () => setState(() => _selectedDay = date),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSelected
? colors.primary.withValues(alpha: 0.08)
: hasItems
? colors.surfaceContainerHigh
: null,
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(color: colors.primary.withValues(alpha: 0.3), width: 1)
: null,
),
child: hasItems
? _buildImageCell(colors, day, items, isToday)
: Stack(
children: [
Center(
child: Text(
'$day',
style: TextStyle(
fontSize: 13,
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
color: isToday
? colors.primary
: colors.onSurface.withValues(alpha: 0.35),
),
),
),
if (isToday)
Positioned(
top: 4, left: 4,
child: Container(
width: 6, height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.primary,
),
),
),
],
),
),
);
}
Widget _buildImageCell(ColorScheme colors, int day, List<_CalendarItem> items, bool isToday) {
return ClipRRect(
borderRadius: BorderRadius.circular(9),
child: Stack(
fit: StackFit.expand,
children: [
FadeInLocalImage(
path: items.first.path,
fit: BoxFit.cover,
duration: const Duration(milliseconds: 250),
errorWidget: Container(
color: colors.surfaceContainerHighest,
child: Center(child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2))),
),
),
// 底部渐变 + 日期
Positioned(
left: 0, right: 0, bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 2),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.55)],
),
),
child: Text(
'$day',
style: TextStyle(
fontSize: 10,
fontWeight: isToday ? FontWeight.w700 : FontWeight.w500,
color: isToday ? const Color(0xFFFFD54F) : Colors.white,
),
),
),
),
// +N 标记
if (items.length > 1)
Positioned(
top: 3, right: 3,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(6),
),
child: Text('+${items.length - 1}', style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: Colors.white)),
),
),
// 今日小圆点(左上角)
if (isToday)
Positioned(
top: 4, left: 4,
child: Container(
width: 6, height: 6,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFFFD54F),
),
),
),
],
),
);
}
// ─── 选中日期的详情 ───
Widget _buildSelectedDayDetail(ColorScheme colors) {
if (_selectedDay == null) {
return const SizedBox.shrink();
}
final items = _dayItems[_selectedDay] ?? [];
if (items.isEmpty) {
return Container(
padding: const EdgeInsets.all(16),
child: Text(
'${_selectedDay!.month}${_selectedDay!.day}日 暂无记录',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)),
),
);
}
return Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
child: Row(
children: [
Text(
'${_selectedDay!.month}${_selectedDay!.day}',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6)),
),
const SizedBox(width: 6),
Text(
'${items.length}条记录',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length,
separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant),
itemBuilder: (_, i) {
final item = items[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 40, height: 40,
child: FadeInLocalImage(
path: item.path,
fit: BoxFit.cover,
duration: const Duration(milliseconds: 250),
errorWidget: Container(
color: colors.surfaceContainerHighest,
child: Icon(Icons.image_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.2)),
),
),
),
),
title: Text(item.title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: item.type == 'movie' ? const Color(0xFF4A90D9).withValues(alpha: 0.1) : const Color(0xFF7E57C2).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.type == 'movie' ? '影视' : '书籍',
style: TextStyle(fontSize: 11, color: item.type == 'movie' ? const Color(0xFF4A90D9) : const Color(0xFF7E57C2)),
),
),
onTap: () {
if (item.type == 'movie') {
Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: item.data as Movie)));
} else {
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
}
},
);
},
),
),
const SizedBox(height: 8),
],
),
);
}
void _showAddMenu(BuildContext context) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16),
decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))),
Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text('添加记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))),
const SizedBox(height: 8),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.movie_outlined, size: 20, color: const Color(0xFF4A90D9))),
title: Text('添加影视', style: TextStyle(fontSize: 14, color: colors.onSurface)),
subtitle: Text('记录一部影视作品', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () {
Navigator.pop(ctx);
Navigator.push(context, MaterialPageRoute(builder: (_) => const MovieFormPage()));
},
),
Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.menu_book_outlined, size: 20, color: const Color(0xFF7E57C2))),
title: Text('添加书籍', style: TextStyle(fontSize: 14, color: colors.onSurface)),
subtitle: Text('记录一本书籍', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
trailing: Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)),
onTap: () {
Navigator.pop(ctx);
Navigator.push(context, MaterialPageRoute(builder: (_) => const BookFormPage()));
},
),
const SizedBox(height: 12),
]),
),
);
}
}
class _CalendarItem {
final String path;
final String title;
final String type;
final dynamic data;
_CalendarItem({
required this.path,
required this.title,
required this.type,
required this.data,
});
}