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 createState() => _MediaCalendarPageState(); } class _MediaCalendarPageState extends State { late DateTime _currentMonth; DateTime? _selectedDay; int _dateMode = 0; // 0: 创建日期, 1: 观看/开始阅读日期 // {DateTime(dayOnly): [{path, title, type, data}]} late Map> _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(); final map = >{}; 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) { // 可用宽度去掉左右 padding(8+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, }); }