import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/contracts/trakt/trakt_calendar_entry.dart'; import '../../providers/di_providers.dart'; import '../../providers/trakt_calendar_provider.dart'; import '../../shared/utils/media_nav.dart'; import '../../shared/widgets/app_loading_ring.dart'; import '../../shared/widgets/app_error_state.dart'; import '../../shared/widgets/app_sliver_header.dart'; import '../../shared/widgets/page_background.dart'; import '../../shared/widgets/pill_tab_bar.dart'; import '../../shared/widgets/smart_image.dart'; const _kTabs = ['全部', '剧集', '电影']; class TraktCalendarPage extends ConsumerStatefulWidget { const TraktCalendarPage({super.key}); @override ConsumerState createState() => _TraktCalendarPageState(); } class _TraktCalendarPageState extends ConsumerState { int _tabIndex = 0; void _onTabChanged(int index) { if (index == _tabIndex) return; setState(() => _tabIndex = index); final filter = switch (index) { 1 => TraktCalendarFilter.shows, 2 => TraktCalendarFilter.movies, _ => TraktCalendarFilter.all, }; ref.read(traktCalendarFilterProvider.notifier).state = filter; } @override Widget build(BuildContext context) { final asyncEntries = ref.watch(traktCalendarProvider); return Scaffold( extendBodyBehindAppBar: true, backgroundColor: Colors.transparent, body: Stack( children: [ const Positioned.fill(child: PageBackground()), RefreshIndicator( onRefresh: () async { ref.invalidate(traktCalendarProvider); await ref.read(traktCalendarProvider.future); }, child: CustomScrollView( slivers: [ AppSliverHeader( title: '日历', automaticallyImplyLeading: false, bottom: PreferredSize( preferredSize: const Size.fromHeight(48), child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: PillTabBar( tabs: _kTabs, selectedIndex: _tabIndex, onSelect: _onTabChanged, ), ), ), ), asyncEntries.when( loading: () => const SliverFillRemaining( child: Center(child: AppLoadingRing()), ), error: (e, _) => SliverFillRemaining( child: AppErrorState( title: '加载失败', message: '$e', onRetry: () => ref.invalidate(traktCalendarProvider), ), ), data: (entries) { if (entries.isEmpty) { return const SliverFillRemaining( child: AppErrorState( title: '暂无内容', message: '未来 7 天没有新内容', icon: Icons.calendar_today_outlined, ), ); } final groups = groupByDate(entries); return SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _buildGroupItem(context, groups, index), childCount: groups.fold( 0, (sum, g) => sum + 1 + g.entries.length, ), ), ); }, ), ], ), ), ], ), ); } Widget _buildGroupItem( BuildContext context, List groups, int flatIndex, ) { var cursor = 0; for (final group in groups) { if (flatIndex == cursor) { return _DateHeader(label: group.label, count: group.entries.length); } cursor++; final entryIndex = flatIndex - cursor; if (entryIndex < group.entries.length) { return _CalendarEntryTile(entry: group.entries[entryIndex]); } cursor += group.entries.length; } return const SizedBox.shrink(); } } class _DateHeader extends StatelessWidget { final String label; final int count; const _DateHeader({required this.label, required this.count}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.fromLTRB(24, 20, 24, 8), child: Row( children: [ Text( label, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: 8), Text( '$count', style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.45), ), ), ], ), ); } } class _CalendarEntryTile extends ConsumerWidget { final TraktCalendarEntry entry; const _CalendarEntryTile({required this.entry}); @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final hasTmdb = entry.tmdbId != null && entry.tmdbId! > 0; final airTime = _formatAirTime(entry.firstAired.toLocal()); String? networkName; if (hasTmdb) { final TmdbMediaKey key = ( tmdbId: entry.tmdbId!.toString(), mediaType: entry.mediaType, ); final enriched = ref.watch(tmdbEnrichedDetailProvider(key)).value; networkName = enriched?.networks.firstOrNull?.name; } return InkWell( onTap: hasTmdb ? () => goTmdbDetail( context, tmdbId: entry.tmdbId!, mediaType: entry.mediaType, ) : null, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), child: Row( children: [ SizedBox( width: 48, height: 72, child: SmartImage( url: entry.posterUrl, borderRadius: 6, fallbackIcon: entry.type == 'movie' ? Icons.movie_outlined : Icons.tv_outlined, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( entry.displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), ), const SizedBox(height: 3), Text( [ entry.displaySubtitle, airTime, if (networkName != null) networkName, ].where((s) => s.isNotEmpty).join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues( alpha: 0.55, ), ), ), ], ), ), if (!hasTmdb) Icon( Icons.link_off, size: 16, color: theme.colorScheme.onSurface.withValues(alpha: 0.25), ), ], ), ), ); } static String _formatAirTime(DateTime local) { return '${local.hour.toString().padLeft(2, '0')}:' '${local.minute.toString().padLeft(2, '0')}'; } }