import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; import '../core/contracts/trakt/trakt_calendar_entry.dart'; import '../core/domain/ports/tmdb_gateway.dart'; import '../shared/mappers/tmdb_image_url.dart'; import '../shared/utils/app_logger.dart'; import 'di_providers.dart'; import 'sm_account_provider.dart'; import 'tmdb_settings_provider.dart'; const _tag = 'TraktCalendar'; enum TraktCalendarFilter { all, shows, movies } final traktCalendarFilterProvider = StateProvider( (ref) => TraktCalendarFilter.all, ); final traktCalendarProvider = FutureProvider>(( ref, ) async { final authed = ref.read(authedTraktGatewayProvider); if (!await authed.isConnected()) return const []; final filter = ref.watch(traktCalendarFilterProvider); final entries = []; if (filter != TraktCalendarFilter.movies) { try { final raw = await authed.fetchCalendarShows(); entries.addAll( raw .map(TraktCalendarEntry.fromShowJson) .whereType(), ); } catch (e) { AppLogger.warn(_tag, 'fetchCalendarShows failed', e); } } if (filter != TraktCalendarFilter.shows) { try { final raw = await authed.fetchCalendarMovies(); entries.addAll( raw .map(TraktCalendarEntry.fromMovieJson) .whereType(), ); } catch (e) { AppLogger.warn(_tag, 'fetchCalendarMovies failed', e); } } entries.sort((a, b) => a.firstAired.compareTo(b.firstAired)); if (entries.isEmpty) return const []; final tmdbConfig = await _resolveTmdbConfig(ref); if (tmdbConfig == null) return entries; final gateway = ref.read(tmdbGatewayProvider); final cache = {}; const enrichConcurrency = 5; final enriched = []; for (var i = 0; i < entries.length; i += enrichConcurrency) { final chunk = entries.skip(i).take(enrichConcurrency); final results = await Future.wait( chunk.map((entry) async { final id = entry.tmdbId; if (id == null || id <= 0) return entry; final cacheKey = '${entry.mediaType}:$id'; try { final cached = cache[cacheKey] ??= await _fetchPoster( gateway, tmdbConfig.config, tmdbConfig.language, id.toString(), entry.mediaType, ); var result = entry; if (cached.posterUrl != null) { result = result.withPoster(cached.posterUrl); } if (cached.title != null && cached.title!.isNotEmpty) { result = result.withTitle(cached.title!); } return result; } catch (_) { return entry; } }), ); enriched.addAll(results); } return enriched; }); Future<_TmdbReadyConfig?> _resolveTmdbConfig(Ref ref) async { final settings = await ref.watch(tmdbSettingsProvider.future); if (!settings.canRequest) return null; if (settings.accessMode == TmdbAccessMode.smAccount) { final account = await ref.watch(smAccountProvider.future); if (!account.canUseDataPlane) return null; final notifier = ref.read(smAccountProvider.notifier); final token = await notifier.getAccessToken(); if (token == null) return null; return _TmdbReadyConfig( config: TmdbRequestConfig( apiBaseUrl: account.tmdbApiBaseUrl, imageBaseUrl: TmdbSettingsState.officialImageBaseUrl, accessToken: token, apiKey: '', onUnauthorized: () => notifier.getAccessToken(force: true), ), language: settings.language, ); } return _TmdbReadyConfig( config: TmdbRequestConfig( apiBaseUrl: settings.effectiveApiBaseUrl, imageBaseUrl: settings.effectiveImageBaseUrl, accessToken: settings.effectiveAccessToken, apiKey: settings.effectiveApiKey, ), language: settings.language, ); } Future<_TmdbPosterResult> _fetchPoster( TmdbGateway gateway, TmdbRequestConfig config, String language, String tmdbId, String mediaType, ) async { final detail = await gateway.getMediaDetail( config, tmdbId, mediaType, language: language, ); final posterUrl = TmdbImageUrl.poster( config.imageBaseUrl, detail?.posterPath, size: 'w342', ); return _TmdbPosterResult(posterUrl: posterUrl, title: detail?.title); } class _TmdbReadyConfig { final TmdbRequestConfig config; final String language; const _TmdbReadyConfig({required this.config, required this.language}); } class _TmdbPosterResult { final String? posterUrl; final String? title; const _TmdbPosterResult({this.posterUrl, this.title}); } List groupByDate(List entries) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final groups = >{}; final groupOrder = {}; for (final e in entries) { final local = e.firstAired.toLocal(); final date = DateTime(local.year, local.month, local.day); final label = _dateLabel(date, today); (groups[label] ??= []).add(e); groupOrder.putIfAbsent(label, () => date); } final sorted = groups.entries.toList() ..sort((a, b) => groupOrder[a.key]!.compareTo(groupOrder[b.key]!)); return [ for (final e in sorted) CalendarDateGroup(label: e.key, entries: e.value), ]; } String _dateLabel(DateTime date, DateTime today) { final diff = date.difference(today).inDays; if (diff == 0) return '今天'; if (diff == 1) return '明天'; final weekday = _weekdayName(date.weekday); return '$weekday ${date.month}/${date.day}'; } String _weekdayName(int weekday) => switch (weekday) { 1 => '周一', 2 => '周二', 3 => '周三', 4 => '周四', 5 => '周五', 6 => '周六', _ => '周日', }; class CalendarDateGroup { final String label; final List entries; const CalendarDateGroup({required this.label, required this.entries}); }