diff --git a/apps/ios/Plotwist/Plotwist.xcodeproj/project.pbxproj b/apps/ios/Plotwist/Plotwist.xcodeproj/project.pbxproj index 183ee8df..0f6ee30b 100644 --- a/apps/ios/Plotwist/Plotwist.xcodeproj/project.pbxproj +++ b/apps/ios/Plotwist/Plotwist.xcodeproj/project.pbxproj @@ -266,7 +266,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Plotwist/Plotwist.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = 54XPVTP5PA; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -295,7 +295,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Plotwist/Plotwist.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = 54XPVTP5PA; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/apps/ios/Plotwist/Plotwist/Components/MediaDetailViewActions.swift b/apps/ios/Plotwist/Plotwist/Components/MediaDetailViewActions.swift index 73229203..6192fc9a 100644 --- a/apps/ios/Plotwist/Plotwist/Components/MediaDetailViewActions.swift +++ b/apps/ios/Plotwist/Plotwist/Components/MediaDetailViewActions.swift @@ -14,6 +14,7 @@ struct MediaDetailViewActions: View { let isLoadingStatus: Bool let onReviewTapped: () -> Void let onStatusChanged: (UserItem?) -> Void + var onLoginRequired: (() -> Void)? @State private var showStatusSheet = false @@ -27,7 +28,13 @@ struct MediaDetailViewActions: View { currentStatus: userItem?.statusEnum, rewatchCount: userItem?.watchEntries?.count ?? 0, isLoading: isLoadingStatus, - action: { showStatusSheet = true } + action: { + if AuthService.shared.isAuthenticated { + showStatusSheet = true + } else { + onLoginRequired?() + } + } ) Spacer() diff --git a/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift b/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift index 70734fa4..b81e0db4 100644 --- a/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift +++ b/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift @@ -5,83 +5,39 @@ import SwiftUI -// MARK: - UIScreen Extension for Device Corner Radius -extension UIScreen { - /// Returns the display corner radius of the device screen - var deviceCornerRadius: CGFloat { - guard let cornerRadius = value(forKey: "_displayCornerRadius") as? CGFloat else { - return 44 // Fallback for older devices or simulator - } - return cornerRadius - } -} - -// MARK: - Sheet Style Configuration -enum SheetStyle { - /// Margem horizontal do sheet flutuante - static let horizontalPadding: CGFloat = 8 - /// Margem inferior do sheet flutuante - static let bottomPadding: CGFloat = 8 - /// Raio de arredondamento do sheet - usa o raio do dispositivo - static var cornerRadius: CGFloat { - UIScreen.main.deviceCornerRadius - } - /// Altura extra para compensar o padding - static let heightOffset: CGFloat = 20 -} - // MARK: - Floating Sheet Container -/// Container que aplica o estilo flutuante com margem e arredondamento +/// Container for sheet content — pass-through that preserves existing call sites. struct FloatingSheetContainer: View { let content: Content - @State private var keyboardVisible = false init(@ViewBuilder content: () -> Content) { self.content = content() } var body: some View { - VStack { - Spacer() - content - .background(Color.appSheetBackgroundAdaptive) - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: SheetStyle.cornerRadius, - bottomLeadingRadius: keyboardVisible ? 0 : SheetStyle.cornerRadius, - bottomTrailingRadius: keyboardVisible ? 0 : SheetStyle.cornerRadius, - topTrailingRadius: SheetStyle.cornerRadius - ) - ) - .padding(.horizontal, keyboardVisible ? 0 : SheetStyle.horizontalPadding) - .padding(.bottom, keyboardVisible ? 0 : SheetStyle.bottomPadding) - .animation(.easeInOut(duration: 0.25), value: keyboardVisible) - } - .ignoresSafeArea(.container, edges: .bottom) - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in - keyboardVisible = true - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - keyboardVisible = false - } + content } } // MARK: - View Extension extension View { - /// Aplica os modificadores de apresentação para sheet flutuante + /// Sheet com altura fixa e fundo sólido (substitui Liquid Glass do iOS 26). func floatingSheetPresentation(height: CGFloat) -> some View { self - .presentationDetents([.height(height + SheetStyle.heightOffset)]) - .presentationBackground(.clear) + .presentationDetents([.height(height)]) + .presentationBackground { + Color.appSheetBackgroundAdaptive.ignoresSafeArea() + } .presentationDragIndicator(.hidden) } - /// Aplica os modificadores de apresentação para sheet flutuante com detents customizados + /// Sheet com detents customizados e fundo sólido (substitui Liquid Glass do iOS 26). func floatingSheetPresentation(detents: Set) -> some View { self .presentationDetents(detents) - .presentationBackground(.clear) + .presentationBackground { + Color.appSheetBackgroundAdaptive.ignoresSafeArea() + } .presentationDragIndicator(.hidden) } } diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index 58ea1ec8..a16f4849 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -61,10 +61,10 @@ enum L10n { recentSearches: "Recent Searches", clearAll: "Clear", youAreLookingFor: "You are looking for:", - popularMovies: "Popular Movies", - popularTVSeries: "Popular TV Series", - popularAnimes: "Popular Animes", - popularDoramas: "Popular Doramas", + popularMovies: "Popular movies", + popularTVSeries: "Popular TV series", + popularAnimes: "Popular animes", + popularDoramas: "Popular doramas", animes: "Animes", doramas: "Doramas", seeAllMovies: "See all movies", @@ -103,14 +103,14 @@ enum L10n { seeAll: "See all reviews", showMore: "Show more", // Movie Categories - nowPlaying: "Now Playing", + nowPlaying: "Now playing", popular: "Popular", - topRated: "Top Rated", + topRated: "Top rated", upcoming: "Upcoming", discover: "Discover", // TV Series Categories - airingToday: "Airing Today", - onTheAir: "On The Air", + airingToday: "Airing today", + onTheAir: "On the air", // Images images: "Images", backdrops: "Backdrops", @@ -169,8 +169,8 @@ enum L10n { goodMorning: "Good morning", goodAfternoon: "Good afternoon", goodEvening: "Good evening", - continueWatching: "Continue Watching", - upNext: "Up Next", + continueWatching: "Continue watching", + upNext: "Up next", // Collection partOf: "Part of", seeCollection: "See Collection", @@ -221,8 +221,8 @@ enum L10n { onboardingCelebrationTitle: "You're all set! 🎉", onboardingCelebrationSubtitle: "Your personalized experience is ready. Enjoy Plotwist!", onboardingGoToHome: "Go to Home", - onboardingLoginTitle: "Save your progress", - onboardingLoginSubtitle: "Sign in to sync across devices", + onboardingLoginTitle: "Sign in to Plotwist", + onboardingLoginSubtitle: "Create an account to review, track your progress, and sync across all your devices.", onboardingNotNow: "Not now", // Genres genreAction: "Action", @@ -268,7 +268,17 @@ enum L10n { itemsInCollection: "items in collection", bestReviews: "Best Reviews", daysOfContent: "days of content", - othersGenres: "Others" + othersGenres: "Others", + // Home Engagement + forYou: "For you", + basedOnYourTaste: "Because you like %@", + trendingThisWeek: "Trending this week", + inTheaters: "In theaters", + topRatedMovies: "Top rated movies", + topRatedSeries: "Top rated series", + topRatedAnimes: "Top rated animes", + topRatedDoramas: "Top rated doramas", + featured: "Featured" ), .ptBR: Strings( loginLabel: "E-mail ou nome de usuário", @@ -319,10 +329,10 @@ enum L10n { recentSearches: "Pesquisas Recentes", clearAll: "Limpar", youAreLookingFor: "Você está procurando por:", - popularMovies: "Filmes Populares", - popularTVSeries: "Séries Populares", - popularAnimes: "Animes Populares", - popularDoramas: "Doramas Populares", + popularMovies: "Filmes populares", + popularTVSeries: "Séries populares", + popularAnimes: "Animes populares", + popularDoramas: "Doramas populares", animes: "Animes", doramas: "Doramas", seeAllMovies: "Ver todos os filmes", @@ -359,13 +369,13 @@ enum L10n { beFirstToReview: "Seja o primeiro a deixar sua opinião", seeAll: "Ver todas as avaliações", showMore: "Ver mais", - nowPlaying: "Em Cartaz", + nowPlaying: "Em cartaz", popular: "Popular", - topRated: "Mais Bem Avaliados", - upcoming: "Em Breve", + topRated: "Mais bem avaliados", + upcoming: "Em breve", discover: "Descobrir", - airingToday: "No Ar Hoje", - onTheAir: "Em Exibição", + airingToday: "No ar hoje", + onTheAir: "Em exibição", images: "Imagens", backdrops: "Backdrops", posters: "Pôsteres", @@ -418,8 +428,8 @@ enum L10n { goodMorning: "Bom dia", goodAfternoon: "Boa tarde", goodEvening: "Boa noite", - continueWatching: "Continuar Assistindo", - upNext: "Para Assistir", + continueWatching: "Continuar assistindo", + upNext: "Para assistir", // Collection partOf: "Parte de", seeCollection: "Ver Coleção", @@ -470,8 +480,8 @@ enum L10n { onboardingCelebrationTitle: "Tudo pronto! 🎉", onboardingCelebrationSubtitle: "Sua experiência personalizada está pronta. Aproveite o Plotwist!", onboardingGoToHome: "Ir para Home", - onboardingLoginTitle: "Salvar seu progresso", - onboardingLoginSubtitle: "Entre para sincronizar entre dispositivos", + onboardingLoginTitle: "Entre no Plotwist", + onboardingLoginSubtitle: "Crie uma conta para avaliar, acompanhar seu progresso e sincronizar em todos os seus dispositivos.", onboardingNotNow: "Agora não", // Genres genreAction: "Ação", @@ -517,7 +527,17 @@ enum L10n { itemsInCollection: "itens na coleção", bestReviews: "Melhores Reviews", daysOfContent: "dias de conteúdo", - othersGenres: "Outros" + othersGenres: "Outros", + // Home Engagement + forYou: "Para você", + basedOnYourTaste: "Porque você gosta de %@", + trendingThisWeek: "Em alta esta semana", + inTheaters: "Nos cinemas", + topRatedMovies: "Filmes mais bem avaliados", + topRatedSeries: "Séries mais bem avaliadas", + topRatedAnimes: "Animes mais bem avaliados", + topRatedDoramas: "Doramas mais bem avaliados", + featured: "Destaque" ), .esES: Strings( loginLabel: "Correo electrónico o nombre de usuario", @@ -568,10 +588,10 @@ enum L10n { recentSearches: "Búsquedas Recientes", clearAll: "Limpiar", youAreLookingFor: "Estás buscando:", - popularMovies: "Películas Populares", - popularTVSeries: "Series Populares", - popularAnimes: "Animes Populares", - popularDoramas: "Doramas Populares", + popularMovies: "Películas populares", + popularTVSeries: "Series populares", + popularAnimes: "Animes populares", + popularDoramas: "Doramas populares", animes: "Animes", doramas: "Doramas", seeAllMovies: "Ver todas las películas", @@ -608,13 +628,13 @@ enum L10n { beFirstToReview: "Sé el primero en dejar tu opinión", seeAll: "Ver todas las reseñas", showMore: "Ver más", - nowPlaying: "En Cartelera", + nowPlaying: "En cartelera", popular: "Popular", - topRated: "Mejor Valoradas", + topRated: "Mejor valoradas", upcoming: "Próximamente", discover: "Descubrir", - airingToday: "En Emisión Hoy", - onTheAir: "En Emisión", + airingToday: "En emisión hoy", + onTheAir: "En emisión", images: "Imágenes", backdrops: "Fondos", posters: "Pósters", @@ -667,7 +687,7 @@ enum L10n { goodMorning: "Buenos días", goodAfternoon: "Buenas tardes", goodEvening: "Buenas noches", - continueWatching: "Seguir Viendo", + continueWatching: "Seguir viendo", upNext: "Próximos", // Collection partOf: "Parte de", @@ -719,8 +739,8 @@ enum L10n { onboardingCelebrationTitle: "¡Todo listo! 🎉", onboardingCelebrationSubtitle: "Tu experiencia personalizada está lista. ¡Disfruta Plotwist!", onboardingGoToHome: "Ir a Home", - onboardingLoginTitle: "Guarda tu progreso", - onboardingLoginSubtitle: "Inicia sesión para sincronizar entre dispositivos", + onboardingLoginTitle: "Inicia sesión en Plotwist", + onboardingLoginSubtitle: "Crea una cuenta para reseñar, seguir tu progreso y sincronizar en todos tus dispositivos.", onboardingNotNow: "Ahora no", // Genres genreAction: "Acción", @@ -766,7 +786,17 @@ enum L10n { itemsInCollection: "ítems en la colección", bestReviews: "Mejores Reseñas", daysOfContent: "días de contenido", - othersGenres: "Otros" + othersGenres: "Otros", + // Home Engagement + forYou: "Para ti", + basedOnYourTaste: "Porque te gusta %@", + trendingThisWeek: "Tendencias de la semana", + inTheaters: "En cartelera", + topRatedMovies: "Películas mejor valoradas", + topRatedSeries: "Series mejor valoradas", + topRatedAnimes: "Animes mejor valorados", + topRatedDoramas: "Doramas mejor valorados", + featured: "Destacado" ), .frFR: Strings( loginLabel: "E-mail ou nom d'utilisateur", @@ -817,10 +847,10 @@ enum L10n { recentSearches: "Recherches Récentes", clearAll: "Effacer", youAreLookingFor: "Vous recherchez:", - popularMovies: "Films Populaires", - popularTVSeries: "Séries Populaires", - popularAnimes: "Animes Populaires", - popularDoramas: "Doramas Populaires", + popularMovies: "Films populaires", + popularTVSeries: "Séries populaires", + popularAnimes: "Animes populaires", + popularDoramas: "Doramas populaires", animes: "Animes", doramas: "Doramas", seeAllMovies: "Voir tous les films", @@ -857,13 +887,13 @@ enum L10n { beFirstToReview: "Soyez le premier à donner votre avis", seeAll: "Voir tous les avis", showMore: "Voir plus", - nowPlaying: "À l'Affiche", + nowPlaying: "À l'affiche", popular: "Populaire", - topRated: "Mieux Notés", + topRated: "Mieux notés", upcoming: "Prochainement", discover: "Découvrir", - airingToday: "Diffusé Aujourd'hui", - onTheAir: "En Cours", + airingToday: "Diffusé aujourd'hui", + onTheAir: "En cours", images: "Images", backdrops: "Fonds d'écran", posters: "Affiches", @@ -916,8 +946,8 @@ enum L10n { goodMorning: "Bonjour", goodAfternoon: "Bon après-midi", goodEvening: "Bonsoir", - continueWatching: "Continuer à Regarder", - upNext: "À Suivre", + continueWatching: "Continuer à regarder", + upNext: "À suivre", // Collection partOf: "Fait partie de", seeCollection: "Voir la Collection", @@ -968,8 +998,8 @@ enum L10n { onboardingCelebrationTitle: "C'est prêt ! 🎉", onboardingCelebrationSubtitle: "Ton expérience personnalisée est prête. Profite de Plotwist !", onboardingGoToHome: "Aller à Home", - onboardingLoginTitle: "Sauvegardez votre progression", - onboardingLoginSubtitle: "Connectez-vous pour synchroniser entre appareils", + onboardingLoginTitle: "Connectez-vous à Plotwist", + onboardingLoginSubtitle: "Créez un compte pour évaluer, suivre votre progression et synchroniser sur tous vos appareils.", onboardingNotNow: "Pas maintenant", // Genres genreAction: "Action", @@ -1015,7 +1045,17 @@ enum L10n { itemsInCollection: "éléments dans la collection", bestReviews: "Meilleures Critiques", daysOfContent: "jours de contenu", - othersGenres: "Autres" + othersGenres: "Autres", + // Home Engagement + forYou: "Pour vous", + basedOnYourTaste: "Parce que vous aimez %@", + trendingThisWeek: "Tendances de la semaine", + inTheaters: "Au cinéma", + topRatedMovies: "Films les mieux notés", + topRatedSeries: "Séries les mieux notées", + topRatedAnimes: "Animes les mieux notés", + topRatedDoramas: "Doramas les mieux notés", + featured: "À la une" ), .deDE: Strings( loginLabel: "E-Mail oder Benutzername", @@ -1217,8 +1257,8 @@ enum L10n { onboardingCelebrationTitle: "Alles bereit! 🎉", onboardingCelebrationSubtitle: "Dein personalisiertes Erlebnis ist bereit. Genieße Plotwist!", onboardingGoToHome: "Zu Home", - onboardingLoginTitle: "Fortschritt speichern", - onboardingLoginSubtitle: "Melde dich an, um geräteübergreifend zu synchronisieren", + onboardingLoginTitle: "Bei Plotwist anmelden", + onboardingLoginSubtitle: "Erstelle ein Konto, um zu bewerten, deinen Fortschritt zu verfolgen und auf allen Geräten zu synchronisieren.", onboardingNotNow: "Jetzt nicht", // Genres genreAction: "Action", @@ -1264,7 +1304,17 @@ enum L10n { itemsInCollection: "Elemente in der Sammlung", bestReviews: "Beste Bewertungen", daysOfContent: "Tage an Inhalten", - othersGenres: "Andere" + othersGenres: "Andere", + // Home Engagement + forYou: "Für dich", + basedOnYourTaste: "Weil du %@ magst", + trendingThisWeek: "Trends dieser Woche", + inTheaters: "Im Kino", + topRatedMovies: "Bestbewertete Filme", + topRatedSeries: "Bestbewertete Serien", + topRatedAnimes: "Bestbewertete Animes", + topRatedDoramas: "Bestbewertete Doramas", + featured: "Empfohlen" ), .itIT: Strings( loginLabel: "E-mail o nome utente", @@ -1315,10 +1365,10 @@ enum L10n { recentSearches: "Ricerche Recenti", clearAll: "Cancella", youAreLookingFor: "Stai cercando:", - popularMovies: "Film Popolari", - popularTVSeries: "Serie Popolari", - popularAnimes: "Anime Popolari", - popularDoramas: "Dorama Popolari", + popularMovies: "Film popolari", + popularTVSeries: "Serie popolari", + popularAnimes: "Anime popolari", + popularDoramas: "Dorama popolari", animes: "Anime", doramas: "Dorama", seeAllMovies: "Vedi tutti i film", @@ -1355,13 +1405,13 @@ enum L10n { beFirstToReview: "Sii il primo a lasciare la tua opinione", seeAll: "Vedi tutte le recensioni", showMore: "Mostra di più", - nowPlaying: "In Sala", + nowPlaying: "In sala", popular: "Popolari", - topRated: "Più Votati", + topRated: "Più votati", upcoming: "Prossimamente", discover: "Scopri", - airingToday: "In Onda Oggi", - onTheAir: "In Onda", + airingToday: "In onda oggi", + onTheAir: "In onda", images: "Immagini", backdrops: "Sfondi", posters: "Locandine", @@ -1414,7 +1464,7 @@ enum L10n { goodMorning: "Buongiorno", goodAfternoon: "Buon pomeriggio", goodEvening: "Buonasera", - continueWatching: "Continua a Guardare", + continueWatching: "Continua a guardare", upNext: "Prossimi", // Collection partOf: "Parte di", @@ -1466,8 +1516,8 @@ enum L10n { onboardingCelebrationTitle: "Tutto pronto! 🎉", onboardingCelebrationSubtitle: "La tua esperienza personalizzata è pronta. Goditi Plotwist!", onboardingGoToHome: "Vai alla Home", - onboardingLoginTitle: "Salva i tuoi progressi", - onboardingLoginSubtitle: "Accedi per sincronizzare tra dispositivi", + onboardingLoginTitle: "Accedi a Plotwist", + onboardingLoginSubtitle: "Crea un account per recensire, monitorare i tuoi progressi e sincronizzare su tutti i tuoi dispositivi.", onboardingNotNow: "Non ora", // Genres genreAction: "Azione", @@ -1513,7 +1563,17 @@ enum L10n { itemsInCollection: "elementi nella collezione", bestReviews: "Migliori Recensioni", daysOfContent: "giorni di contenuti", - othersGenres: "Altri" + othersGenres: "Altri", + // Home Engagement + forYou: "Per te", + basedOnYourTaste: "Perché ti piace %@", + trendingThisWeek: "Tendenze della settimana", + inTheaters: "Al cinema", + topRatedMovies: "Film più votati", + topRatedSeries: "Serie più votate", + topRatedAnimes: "Anime più votati", + topRatedDoramas: "Dorama più votati", + featured: "In evidenza" ), .jaJP: Strings( loginLabel: "メールアドレスまたはユーザー名", @@ -1714,8 +1774,8 @@ enum L10n { onboardingCelebrationTitle: "準備完了!🎉", onboardingCelebrationSubtitle: "パーソナライズされた体験の準備完了。Plotwistを楽しもう!", onboardingGoToHome: "ホームへ", - onboardingLoginTitle: "進捗を保存", - onboardingLoginSubtitle: "ログインしてデバイス間で同期", + onboardingLoginTitle: "Plotwistにログイン", + onboardingLoginSubtitle: "アカウントを作成して、レビュー、進捗管理、全デバイスでの同期を利用しましょう。", onboardingNotNow: "今はしない", // Genres genreAction: "アクション", @@ -1761,7 +1821,17 @@ enum L10n { itemsInCollection: "アイテム", bestReviews: "ベストレビュー", daysOfContent: "日分のコンテンツ", - othersGenres: "その他" + othersGenres: "その他", + // Home Engagement + forYou: "あなたへ", + basedOnYourTaste: "%@が好きだから", + trendingThisWeek: "今週のトレンド", + inTheaters: "上映中", + topRatedMovies: "高評価の映画", + topRatedSeries: "高評価のシリーズ", + topRatedAnimes: "高評価のアニメ", + topRatedDoramas: "高評価のドラマ", + featured: "注目" ), ] } @@ -2032,4 +2102,14 @@ struct Strings { let bestReviews: String let daysOfContent: String let othersGenres: String + // Home Engagement + let forYou: String + let basedOnYourTaste: String + let trendingThisWeek: String + let inTheaters: String + let topRatedMovies: String + let topRatedSeries: String + let topRatedAnimes: String + let topRatedDoramas: String + let featured: String } diff --git a/apps/ios/Plotwist/Plotwist/Services/HomeDataCache.swift b/apps/ios/Plotwist/Plotwist/Services/HomeDataCache.swift index 99d6a410..248b146c 100644 --- a/apps/ios/Plotwist/Plotwist/Services/HomeDataCache.swift +++ b/apps/ios/Plotwist/Plotwist/Services/HomeDataCache.swift @@ -19,6 +19,15 @@ final class HomeDataCache { // Cache for discovery content private var popularMoviesCache: [SearchResult]? private var popularTVSeriesCache: [SearchResult]? + // Cache for new personalized sections + private var featuredItemCache: SearchResult? + private var forYouItemsCache: [SearchResult]? + private var trendingItemsCache: [SearchResult]? + private var animeItemsCache: [SearchResult]? + private var doramaItemsCache: [SearchResult]? + private var nowPlayingItemsCache: [SearchResult]? + private var airingTodayItemsCache: [SearchResult]? + private var topRatedItemsCache: [SearchResult]? // Cache timestamp private var lastUpdated: Date? private var discoveryLastUpdated: Date? @@ -100,6 +109,102 @@ final class HomeDataCache { discoveryLastUpdated = Date() } + // MARK: - Featured Item + + var featuredItem: SearchResult? { + guard !isDiscoveryCacheExpired else { return nil } + return featuredItemCache + } + + func setFeaturedItem(_ item: SearchResult?) { + featuredItemCache = item + discoveryLastUpdated = Date() + } + + // MARK: - For You Items + + var forYouItems: [SearchResult]? { + guard !isDiscoveryCacheExpired else { return nil } + return forYouItemsCache + } + + func setForYouItems(_ items: [SearchResult]) { + forYouItemsCache = items + discoveryLastUpdated = Date() + } + + // MARK: - Trending Items + + var trendingItems: [SearchResult]? { + guard !isDiscoveryCacheExpired else { return nil } + return trendingItemsCache + } + + func setTrendingItems(_ items: [SearchResult]) { + trendingItemsCache = items + discoveryLastUpdated = Date() + } + + // MARK: - Anime Items + + var animeItems: [SearchResult]? { + guard !isDiscoveryCacheExpired else { return nil } + return animeItemsCache + } + + func setAnimeItems(_ items: [SearchResult]) { + animeItemsCache = items + discoveryLastUpdated = Date() + } + + // MARK: - Dorama Items + + var doramaItems: [SearchResult]? { + guard !isDiscoveryCacheExpired else { return nil } + return doramaItemsCache + } + + func setDoramaItems(_ items: [SearchResult]) { + doramaItemsCache = items + discoveryLastUpdated = Date() + } + + // MARK: - Now Playing Items + + var nowPlayingItems: [SearchResult]? { + guard !isDiscoveryCacheExpired else { return nil } + return nowPlayingItemsCache + } + + func setNowPlayingItems(_ items: [SearchResult]) { + nowPlayingItemsCache = items + discoveryLastUpdated = Date() + } + + // MARK: - Airing Today Items + + var airingTodayItems: [SearchResult]? { + guard !isDiscoveryCacheExpired else { return nil } + return airingTodayItemsCache + } + + func setAiringTodayItems(_ items: [SearchResult]) { + airingTodayItemsCache = items + discoveryLastUpdated = Date() + } + + // MARK: - Top Rated Items + + var topRatedItems: [SearchResult]? { + guard !isDiscoveryCacheExpired else { return nil } + return topRatedItemsCache + } + + func setTopRatedItems(_ items: [SearchResult]) { + topRatedItemsCache = items + discoveryLastUpdated = Date() + } + // MARK: - Cache State var shouldShowSkeleton: Bool { @@ -134,6 +239,14 @@ final class HomeDataCache { func clearDiscoveryCache() { popularMoviesCache = nil popularTVSeriesCache = nil + featuredItemCache = nil + forYouItemsCache = nil + trendingItemsCache = nil + animeItemsCache = nil + doramaItemsCache = nil + nowPlayingItemsCache = nil + airingTodayItemsCache = nil + topRatedItemsCache = nil discoveryLastUpdated = nil } diff --git a/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift b/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift index eab7ea14..04c5fbc8 100644 --- a/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift @@ -872,14 +872,18 @@ class TMDBService { } // MARK: - Discover by Genre (for onboarding) - func discoverByGenres(mediaType: String, genreIds: [Int], language: String = "en-US", page: Int = 1) async throws -> [SearchResult] { + func discoverByGenres(mediaType: String, genreIds: [Int], language: String = "en-US", page: Int = 1, originCountry: String? = nil) async throws -> [SearchResult] { // Use | (pipe) for OR logic - matches ANY of the selected genres (more results) // Using , (comma) would be AND logic - matches ALL genres (fewer results) let genresString = genreIds.map { String($0) }.joined(separator: "|") let endpoint = mediaType == "movie" ? "discover/movie" : "discover/tv" // Sort by vote count to show most voted (well-known) content first - guard let url = URL(string: "\(baseURL)/\(endpoint)?language=\(language)&with_genres=\(genresString)&sort_by=vote_count.desc&page=\(page)") else { + var urlString = "\(baseURL)/\(endpoint)?language=\(language)&with_genres=\(genresString)&sort_by=vote_count.desc&page=\(page)" + if let country = originCountry { + urlString += "&with_origin_country=\(country)" + } + guard let url = URL(string: urlString) else { throw TMDBError.invalidURL } @@ -1167,6 +1171,11 @@ struct SearchResult: Codable, Identifiable, Equatable { guard let posterPath else { return nil } return URL(string: "https://image.tmdb.org/t/p/w780\(posterPath)") } + + var hdBackdropURL: URL? { + guard let backdropPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/original\(backdropPath)") + } } enum TMDBError: LocalizedError { diff --git a/apps/ios/Plotwist/Plotwist/Views/Auth/LoginView.swift b/apps/ios/Plotwist/Plotwist/Views/Auth/LoginView.swift index ce2d99dc..1c9d5715 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Auth/LoginView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Auth/LoginView.swift @@ -139,6 +139,21 @@ struct LoginView: View { .frame(maxWidth: .infinity) .frame(height: 52) } + + #if DEBUG + // Debug: Reset and open onboarding + Button { + OnboardingService.shared.reset() + UserDefaults.standard.set(false, forKey: "isGuestMode") + NotificationCenter.default.post(name: .authChanged, object: nil) + } label: { + Text("Debug: Open Onboarding") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.35)) + .frame(maxWidth: .infinity) + .frame(height: 36) + } + #endif } } .padding(.horizontal, 24) diff --git a/apps/ios/Plotwist/Plotwist/Views/Details/EpisodeDetailView.swift b/apps/ios/Plotwist/Plotwist/Views/Details/EpisodeDetailView.swift index e792521d..1e19899d 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Details/EpisodeDetailView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Details/EpisodeDetailView.swift @@ -37,6 +37,8 @@ struct EpisodeDetailView: View { @State private var isLoadingNextSeason = false @State private var isLoadingPreviousSeason = false + @State private var showLoginPrompt = false + // Swipe navigation state @State private var dragOffset: CGFloat = 0 @State private var showSwipeIndicator = false @@ -157,50 +159,58 @@ struct EpisodeDetailView: View { ) // Action Buttons Row (Watched + Review) - if AuthService.shared.isAuthenticated { - HStack(spacing: 8) { - // Mark as Watched Button - Button { + HStack(spacing: 8) { + // Mark as Watched Button + Button { + if AuthService.shared.isAuthenticated { Task { await toggleWatchedStatus() } - } label: { - HStack(spacing: 6) { - if isTogglingWatched || isLoadingWatchedStatus { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(0.7) - .frame(width: 13, height: 13) - } else { - Image(systemName: isWatched ? "checkmark.circle.fill" : "circle") - .font(.system(size: 13)) - .foregroundColor(isWatched ? .green : .appForegroundAdaptive) - } - - Text(isWatched ? strings.watched : strings.markAsWatched) - .font(.footnote.weight(.medium)) - .foregroundColor(.appForegroundAdaptive) + } else { + showLoginPrompt = true + } + } label: { + HStack(spacing: 6) { + if isTogglingWatched || isLoadingWatchedStatus { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.7) + .frame(width: 13, height: 13) + } else { + Image(systemName: isWatched ? "checkmark.circle.fill" : "circle") + .font(.system(size: 13)) + .foregroundColor(isWatched ? .green : .appForegroundAdaptive) } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(Color.appInputFilled) - .cornerRadius(10) - .opacity(isTogglingWatched || isLoadingWatchedStatus ? 0.5 : 1) + + Text(isWatched ? strings.watched : strings.markAsWatched) + .font(.footnote.weight(.medium)) + .foregroundColor(.appForegroundAdaptive) } - .buttonStyle(.plain) - .disabled(isTogglingWatched || isLoadingWatchedStatus) - - // Review Button - ReviewButton( - hasReview: userReview != nil, - isLoading: isLoadingUserReview, - action: { showReviewSheet = true } - ) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.appInputFilled) + .cornerRadius(10) + .opacity(isTogglingWatched || isLoadingWatchedStatus ? 0.5 : 1) } - .animation(.easeInOut(duration: 0.2), value: isWatched) - .padding(.horizontal, 24) - .padding(.top, 24) + .buttonStyle(.plain) + .disabled(isTogglingWatched || isLoadingWatchedStatus) + + // Review Button + ReviewButton( + hasReview: userReview != nil, + isLoading: isLoadingUserReview, + action: { + if AuthService.shared.isAuthenticated { + showReviewSheet = true + } else { + showLoginPrompt = true + } + } + ) } + .animation(.easeInOut(duration: 0.2), value: isWatched) + .padding(.horizontal, 24) + .padding(.top, 24) // Overview if let overview = episode.overview, !overview.isEmpty { @@ -226,6 +236,8 @@ struct EpisodeDetailView: View { onEmptyStateTapped: { if AuthService.shared.isAuthenticated { showReviewSheet = true + } else { + showLoginPrompt = true } }, onContentLoaded: { hasContent in @@ -377,6 +389,14 @@ struct EpisodeDetailView: View { } ) } + .loginPrompt(isPresented: $showLoginPrompt) { + Task { + isLoadingUserReview = true + async let reviewTask: () = loadUserReview() + async let watchedTask: () = loadWatchedStatus() + _ = await (reviewTask, watchedTask) + } + } .task { if AuthService.shared.isAuthenticated { isLoadingUserReview = true diff --git a/apps/ios/Plotwist/Plotwist/Views/Details/MediaDetailView.swift b/apps/ios/Plotwist/Plotwist/Views/Details/MediaDetailView.swift index 2d029ab4..736ec464 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Details/MediaDetailView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Details/MediaDetailView.swift @@ -32,6 +32,7 @@ struct MediaDetailView: View { @State private var collection: MovieCollection? @State private var showCollectionSheet = false @State private var selectedCollectionMovieId: Int? + @State private var showLoginPrompt = false // Layout constants private let cornerRadius: CGFloat = 24 @@ -97,22 +98,27 @@ struct MediaDetailView: View { // Content Section VStack(alignment: .leading, spacing: 20) { // Action Buttons (Review + Status) - if AuthService.shared.isAuthenticated { - MediaDetailViewActions( - mediaId: mediaId, - mediaType: mediaType, - userReview: userReview, - userItem: userItem, - isLoadingReview: isLoadingUserReview, - isLoadingStatus: isLoadingUserItem, - onReviewTapped: { + MediaDetailViewActions( + mediaId: mediaId, + mediaType: mediaType, + userReview: userReview, + userItem: userItem, + isLoadingReview: isLoadingUserReview, + isLoadingStatus: isLoadingUserItem, + onReviewTapped: { + if AuthService.shared.isAuthenticated { showReviewSheet = true - }, - onStatusChanged: { newItem in - userItem = newItem + } else { + showLoginPrompt = true } - ) - } + }, + onStatusChanged: { newItem in + userItem = newItem + }, + onLoginRequired: { + showLoginPrompt = true + } + ) // Overview if let overview = details.overview, !overview.isEmpty { @@ -167,6 +173,8 @@ struct MediaDetailView: View { onEmptyStateTapped: { if AuthService.shared.isAuthenticated { showReviewSheet = true + } else { + showLoginPrompt = true } }, onContentLoaded: { hasContent in @@ -347,6 +355,15 @@ struct MediaDetailView: View { } .hidden() ) + .loginPrompt(isPresented: $showLoginPrompt) { + // User logged in - reload user-specific data + Task { + isLoadingUserReview = true + isLoadingUserItem = true + await loadUserReview() + await loadUserItem() + } + } .task { // Start loading user data states immediately if authenticated if AuthService.shared.isAuthenticated { diff --git a/apps/ios/Plotwist/Plotwist/Views/Details/ReviewSheet.swift b/apps/ios/Plotwist/Plotwist/Views/Details/ReviewSheet.swift index 7b7ecf2a..9add50bf 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Details/ReviewSheet.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Details/ReviewSheet.swift @@ -48,7 +48,7 @@ struct ReviewSheet: View { RoundedRectangle(cornerRadius: 2.5) .fill(Color.gray.opacity(0.4)) .frame(width: 36, height: 5) - .padding(.top, 12) + .padding(.top, 8) // Title Text(L10n.current.whatDidYouThink) @@ -142,9 +142,9 @@ struct ReviewSheet: View { } } .padding(.horizontal, 24) - .padding(.bottom, 24) + .padding(.bottom, 16) } - .floatingSheetPresentation(height: existingReview != nil ? 480 : 420) + .floatingSheetPresentation(height: existingReview != nil ? 435 : 370) .preferredColorScheme(themeManager.current.colorScheme) .alert("Error", isPresented: $showErrorAlert) { Button("OK", role: .cancel) {} diff --git a/apps/ios/Plotwist/Plotwist/Views/Details/SeasonDetailView.swift b/apps/ios/Plotwist/Plotwist/Views/Details/SeasonDetailView.swift index dfa8e2fc..686fa155 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Details/SeasonDetailView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Details/SeasonDetailView.swift @@ -26,7 +26,7 @@ struct SeasonDetailView: View { // Episodes watched state (shared with header) @State private var watchedEpisodes: [UserEpisode] = [] @State private var loadingEpisodeIds: Set = [] - + @State private var showLoginPrompt = false private let scrollThreshold: CGFloat = 20 private let navigationHeaderHeight: CGFloat = 64 @@ -63,15 +63,19 @@ struct SeasonDetailView: View { ) // Review Button - if AuthService.shared.isAuthenticated { - ReviewButton( - hasReview: userReview != nil, - isLoading: isLoadingUserReview, - action: { showReviewSheet = true } - ) - .padding(.horizontal, 24) - .padding(.top, 24) - } + ReviewButton( + hasReview: userReview != nil, + isLoading: isLoadingUserReview, + action: { + if AuthService.shared.isAuthenticated { + showReviewSheet = true + } else { + showLoginPrompt = true + } + } + ) + .padding(.horizontal, 24) + .padding(.top, 24) // Overview if let overview = seasonDetails?.overview ?? season.overview, !overview.isEmpty { @@ -96,6 +100,8 @@ struct SeasonDetailView: View { onEmptyStateTapped: { if AuthService.shared.isAuthenticated { showReviewSheet = true + } else { + showLoginPrompt = true } }, onContentLoaded: { hasContent in @@ -125,7 +131,8 @@ struct SeasonDetailView: View { episodes: episodes, allSeasons: allSeasons, watchedEpisodes: $watchedEpisodes, - loadingEpisodeIds: $loadingEpisodeIds + loadingEpisodeIds: $loadingEpisodeIds, + onLoginRequired: { showLoginPrompt = true } ) Spacer() @@ -179,6 +186,13 @@ struct SeasonDetailView: View { } ) } + .loginPrompt(isPresented: $showLoginPrompt) { + Task { + isLoadingUserReview = true + await loadUserReview() + await loadWatchedEpisodes() + } + } .task { if AuthService.shared.isAuthenticated { isLoadingUserReview = true @@ -303,17 +317,15 @@ struct EpisodesHeaderView: View { Spacer() - if AuthService.shared.isAuthenticated { - Text(L10n.current.episodesWatchedCount - .replacingOccurrences(of: "%d", with: "\(watchedCount)") - .replacingOccurrences(of: "%total", with: "\(episodesCount)") - ) - .font(.caption) - .foregroundColor(.appMutedForegroundAdaptive) - } + Text(L10n.current.episodesWatchedCount + .replacingOccurrences(of: "%d", with: "\(watchedCount)") + .replacingOccurrences(of: "%total", with: "\(episodesCount)") + ) + .font(.caption) + .foregroundColor(.appMutedForegroundAdaptive) } - if AuthService.shared.isAuthenticated && episodesCount > 0 { + if episodesCount > 0 { SegmentedProgressBar( totalSegments: episodesCount, filledSegments: watchedCount @@ -383,6 +395,7 @@ struct EpisodesListView: View { var allSeasons: [Season]? @Binding var watchedEpisodes: [UserEpisode] @Binding var loadingEpisodeIds: Set + var onLoginRequired: (() -> Void)? private func isEpisodeWatched(_ episode: Episode) -> Bool { watchedEpisodes.contains { $0.episodeNumber == episode.episodeNumber && $0.seasonNumber == seasonNumber } @@ -424,8 +437,12 @@ struct EpisodesListView: View { isWatched: isEpisodeWatched(episode), isLoading: loadingEpisodeIds.contains(episode.episodeNumber), onToggleWatched: { - Task { - await toggleWatched(episode) + if AuthService.shared.isAuthenticated { + Task { + await toggleWatched(episode) + } + } else { + onLoginRequired?() } } ) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 463ff747..8a664ee0 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -20,9 +20,18 @@ struct HomeTabView: View { // Discovery sections @State private var popularMovies: [SearchResult] = [] @State private var popularTVSeries: [SearchResult] = [] - @State private var trendingItems: [SearchResult] = [] @State private var isLoadingDiscovery = true + // New personalized sections + @State private var featuredItem: SearchResult? + @State private var forYouItems: [SearchResult] = [] + @State private var trendingItems: [SearchResult] = [] + @State private var animeItems: [SearchResult] = [] + @State private var doramaItems: [SearchResult] = [] + @State private var nowPlayingItems: [SearchResult] = [] + @State private var airingTodayItems: [SearchResult] = [] + @State private var topRatedItems: [SearchResult] = [] + private let cache = HomeDataCache.shared // Guest mode username from onboarding @@ -68,12 +77,62 @@ struct HomeTabView: View { } private var showDiscoverySkeleton: Bool { - isLoadingDiscovery && popularMovies.isEmpty && popularTVSeries.isEmpty + isLoadingDiscovery && popularMovies.isEmpty && popularTVSeries.isEmpty && featuredItem == nil + } + + // Personalization helpers + private var showAnimeSection: Bool { + onboardingService.contentTypes.contains(.anime) + } + + private var showDoramaSection: Bool { + onboardingService.contentTypes.contains(.dorama) + } + + private var showMoviesContent: Bool { + onboardingService.contentTypes.contains(.movies) || onboardingService.contentTypes.isEmpty + } + + private var showSeriesContent: Bool { + onboardingService.contentTypes.contains(.series) || onboardingService.contentTypes.isEmpty + } + + private var forYouSubtitle: String { + let genreNames = onboardingService.selectedGenres.prefix(3).map { $0.name } + guard !genreNames.isEmpty else { return "" } + let joined = genreNames.joined(separator: ", ") + return String(format: strings.basedOnYourTaste, joined) + } + + // Top Rated section adapts title/type to user's content preference + private var topRatedSectionTitle: String { + let contentTypes = onboardingService.contentTypes + if contentTypes.contains(.anime) && !showMoviesContent && !showSeriesContent { + return strings.topRatedAnimes + } else if contentTypes.contains(.dorama) && !showMoviesContent && !showSeriesContent { + return strings.topRatedDoramas + } else if showMoviesContent { + return strings.topRatedMovies + } else { + return strings.topRatedSeries + } } - // Show discovery sections when user has no personal content - private var showDiscoverySections: Bool { - !isLoadingDiscovery || !popularMovies.isEmpty || !popularTVSeries.isEmpty + private var topRatedMediaType: String { + showMoviesContent ? "movie" : "tv" + } + + private var topRatedCategoryType: HomeCategoryType { + let contentTypes = onboardingService.contentTypes + if contentTypes.contains(.anime) && !showMoviesContent && !showSeriesContent { + return .animes + } else if contentTypes.contains(.dorama) && !showMoviesContent && !showSeriesContent { + return .doramas + } else if showMoviesContent { + return .movies + } else { + return .tvSeries + } } var body: some View { @@ -82,7 +141,7 @@ struct HomeTabView: View { Color.appBackgroundAdaptive.ignoresSafeArea() ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 32) { // Header with greeting // DEBUG: Long press (3s) on avatar to reset onboarding HomeHeaderView( @@ -103,6 +162,16 @@ struct HomeTabView: View { .padding(.horizontal, 24) .padding(.top, 16) + // Featured Hero Card + if let featured = featuredItem { + FeaturedHeroCard(item: featured, label: strings.featured) + .padding(.horizontal, 20) + .transition(.opacity) + } else if showDiscoverySkeleton { + FeaturedHeroSkeleton() + .padding(.horizontal, 20) + } + // Continue Watching Section if showWatchingSkeleton { HomeSectionSkeleton() @@ -123,13 +192,76 @@ struct HomeTabView: View { ) } - // Discovery Sections - Always show for engagement - if showDiscoverySkeleton { + // For You - Personalized by genre + if !forYouItems.isEmpty { + ForYouSection( + items: forYouItems, + title: strings.forYou, + subtitle: forYouSubtitle + ) + } else if showDiscoverySkeleton && !onboardingService.selectedGenres.isEmpty { + HomeSectionSkeleton() + } + + // Trending This Week + if !trendingItems.isEmpty { + TrendingSection( + items: trendingItems, + title: strings.trendingThisWeek + ) + } else if showDiscoverySkeleton { + HomeSectionSkeleton() + } + + // Anime Section (conditional) + if showAnimeSection && !animeItems.isEmpty { + HomeSectionView( + title: strings.popularAnimes, + items: animeItems, + mediaType: "tv", + categoryType: .animes + ) + } + + // Now Playing / Airing Today (contextual) + if showMoviesContent && !nowPlayingItems.isEmpty { + HomeSectionView( + title: strings.inTheaters, + items: nowPlayingItems, + mediaType: "movie", + categoryType: .movies, + initialMovieSubcategory: .nowPlaying + ) + } + + if showSeriesContent && !airingTodayItems.isEmpty { + HomeSectionView( + title: strings.airingToday, + items: airingTodayItems, + mediaType: "tv", + categoryType: .tvSeries, + initialTVSeriesSubcategory: .airingToday + ) + } + + // Dorama Section (conditional) + if showDoramaSection && !doramaItems.isEmpty { + HomeSectionView( + title: strings.popularDoramas, + items: doramaItems, + mediaType: "tv", + categoryType: .doramas + ) + } + + // Discovery Sections - Show only for relevant content types + // Skip if contextual sections (In Theaters / Airing Today) already cover this + if showDiscoverySkeleton && (showMoviesContent || showSeriesContent) { HomeSectionSkeleton() HomeSectionSkeleton() } else { - // Popular Movies - if !popularMovies.isEmpty { + // Popular Movies -- only if user likes movies AND "In Theaters" isn't visible + if showMoviesContent && !popularMovies.isEmpty && nowPlayingItems.isEmpty { HomeSectionView( title: strings.popularMovies, items: popularMovies, @@ -139,8 +271,8 @@ struct HomeTabView: View { ) } - // Popular TV Series - if !popularTVSeries.isEmpty { + // Popular TV Series -- only if user likes series AND "Airing Today" isn't visible + if showSeriesContent && !popularTVSeries.isEmpty && airingTodayItems.isEmpty { HomeSectionView( title: strings.popularTVSeries, items: popularTVSeries, @@ -151,6 +283,18 @@ struct HomeTabView: View { } } + // Top Rated (adapts to user's content type) + if !topRatedItems.isEmpty { + HomeSectionView( + title: topRatedSectionTitle, + items: topRatedItems, + mediaType: topRatedMediaType, + categoryType: topRatedCategoryType, + initialMovieSubcategory: showMoviesContent ? .topRated : nil, + initialTVSeriesSubcategory: !showMoviesContent ? .topRated : nil + ) + } + Spacer(minLength: 100) } } @@ -210,6 +354,34 @@ struct HomeTabView: View { popularTVSeries = cachedTVSeries isLoadingDiscovery = false } + // Restore new section caches + if let cached = cache.featuredItem { + featuredItem = cached + isLoadingDiscovery = false + } + if let cached = cache.forYouItems { + forYouItems = cached + isLoadingDiscovery = false + } + if let cached = cache.trendingItems { + trendingItems = cached + isLoadingDiscovery = false + } + if let cached = cache.animeItems { + animeItems = cached + } + if let cached = cache.doramaItems { + doramaItems = cached + } + if let cached = cache.nowPlayingItems { + nowPlayingItems = cached + } + if let cached = cache.airingTodayItems { + airingTodayItems = cached + } + if let cached = cache.topRatedItems { + topRatedItems = cached + } } private func loadData() async { @@ -223,11 +395,385 @@ struct HomeTabView: View { group.addTask { await loadWatchingItems() } group.addTask { await loadWatchlistItems() } group.addTask { await loadDiscoveryContent() } + group.addTask { await loadFeaturedItem() } + group.addTask { await loadForYouItems() } + group.addTask { await loadTrendingItems() } + group.addTask { await loadContentTypeItems() } + group.addTask { await loadContextualSections() } + group.addTask { await loadTopRatedItems() } } isInitialLoad = false } + // MARK: - New Section Loaders + + @MainActor + private func loadFeaturedItem() async { + // Check cache first + if let cached = cache.featuredItem { + featuredItem = cached + return + } + + let language = Language.current.rawValue + + do { + // Load content matching user's preferences, then pick the best one + let candidates = try await loadContentForPreferences(language: language) + + // Only consider items with a backdrop image + let withBackdrop = candidates.filter { $0.backdropPath != nil } + + if let best = withBackdrop.first { + featuredItem = best + cache.setFeaturedItem(best) + + // Prefetch the HD backdrop for instant display + if let url = best.hdBackdropURL ?? best.backdropURL { + ImageCache.shared.prefetch(urls: [url], priority: .high) + } + } + } catch { + print("Error loading featured item: \(error)") + } + } + + /// Loads content that matches the user's content type preferences. + /// Used by both Featured Hero and Trending sections for consistent filtering. + private func loadContentForPreferences(language: String) async throws -> [SearchResult] { + let contentTypes = onboardingService.contentTypes + + // No preferences: show everything trending + guard !contentTypes.isEmpty else { + return try await TMDBService.shared.getTrending( + mediaType: "all", + timeWindow: "week", + language: language + ) + } + + var allItems: [SearchResult] = [] + + // Fetch content for each selected type in parallel + try await withThrowingTaskGroup(of: [SearchResult].self) { group in + if contentTypes.contains(.movies) { + group.addTask { + try await TMDBService.shared.getTrending( + mediaType: "movie", + timeWindow: "week", + language: language + ) + } + } + + if contentTypes.contains(.series) { + group.addTask { + try await TMDBService.shared.getTrending( + mediaType: "tv", + timeWindow: "week", + language: language + ) + } + } + + if contentTypes.contains(.anime) { + group.addTask { + let result = try await TMDBService.shared.getPopularAnimes(language: language) + return result.results + } + } + + if contentTypes.contains(.dorama) { + group.addTask { + let result = try await TMDBService.shared.getPopularDoramas(language: language) + return result.results + } + } + + for try await items in group { + allItems.append(contentsOf: items) + } + } + + // Deduplicate + var seen = Set() + return allItems.filter { item in + if seen.contains(item.id) { return false } + seen.insert(item.id) + return true + } + } + + @MainActor + private func loadForYouItems() async { + // Check cache first + if let cached = cache.forYouItems, !cached.isEmpty { + forYouItems = cached + return + } + + let genreIds = onboardingService.selectedGenres.map { $0.id } + let language = Language.current.rawValue + let contentTypes = onboardingService.contentTypes + + // Need either genre preferences or content type preferences + guard !genreIds.isEmpty || !contentTypes.isEmpty else { return } + + do { + var allItems: [SearchResult] = [] + + // Movie genres + if contentTypes.contains(.movies) || contentTypes.isEmpty { + let movieGenreIds = genreIds.filter { + [28, 12, 16, 35, 80, 99, 18, 10751, 14, 36, 27, 10402, 9648, 10749, 878, 53, 10752, 37].contains($0) + } + if !movieGenreIds.isEmpty { + let movies = try await TMDBService.shared.discoverByGenres( + mediaType: "movie", + genreIds: movieGenreIds, + language: language + ) + allItems.append(contentsOf: movies) + } + } + + // TV series genres + if contentTypes.contains(.series) || contentTypes.isEmpty { + let tvGenreIds = genreIds.filter { + [10759, 16, 35, 80, 18, 10765, 10768, 9648, 10751, 10764, 10749].contains($0) + } + if !tvGenreIds.isEmpty { + let series = try await TMDBService.shared.discoverByGenres( + mediaType: "tv", + genreIds: tvGenreIds, + language: language + ) + allItems.append(contentsOf: series) + } + } + + // Anime: use discover with user's genre preferences or fallback to popular + if contentTypes.contains(.anime) { + let animeGenreIds = genreIds.filter { + [16, 10759, 35, 18, 10765, 10749, 878, 9648].contains($0) + } + if !animeGenreIds.isEmpty { + let animes = try await TMDBService.shared.discoverByGenres( + mediaType: "tv", + genreIds: animeGenreIds, + language: language, + originCountry: "JP" + ) + allItems.append(contentsOf: animes) + } else { + let animes = try await TMDBService.shared.getPopularAnimes(language: language) + allItems.append(contentsOf: animes.results) + } + } + + // Dorama: use discover with user's genre preferences or fallback to popular + if contentTypes.contains(.dorama) { + let doramaGenreIds = genreIds.filter { + [10759, 35, 80, 18, 10765, 10768, 9648, 10751, 10764, 10749].contains($0) + } + if !doramaGenreIds.isEmpty { + let doramas = try await TMDBService.shared.discoverByGenres( + mediaType: "tv", + genreIds: doramaGenreIds, + language: language, + originCountry: "KR" + ) + allItems.append(contentsOf: doramas) + } else { + let doramas = try await TMDBService.shared.getPopularDoramas(language: language) + allItems.append(contentsOf: doramas.results) + } + } + + // Remove duplicates, keep unique by ID + var seen = Set() + let unique = allItems.filter { item in + if seen.contains(item.id) { return false } + seen.insert(item.id) + return true + } + + // Exclude featured item to avoid repetition + var filtered = unique.filter { $0.id != featuredItem?.id } + + // Exclude items already in the user's collection (watchlist, watching, watched, dropped) + let collectionIds = await loadUserCollectionIds() + if !collectionIds.isEmpty { + filtered = filtered.filter { !collectionIds.contains($0.id) } + } + + forYouItems = Array(filtered.prefix(20)) + cache.setForYouItems(forYouItems) + } catch { + print("Error loading For You items: \(error)") + } + } + + /// Returns the set of TMDB IDs that the user already has in their collection + /// (watchlist, watching, watched, dropped). Used to filter "For You" recommendations. + @MainActor + private func loadUserCollectionIds() async -> Set { + // Guest mode: use local saved titles + if isGuestMode { + return Set(onboardingService.localSavedTitles.map { $0.tmdbId }) + } + + // Authenticated: fetch all statuses in parallel + guard AuthService.shared.isAuthenticated else { return [] } + + let fetchedUser: User? = try? await AuthService.shared.getCurrentUser() + guard let currentUser = user ?? fetchedUser else { return [] } + + var allIds = Set() + + await withTaskGroup(of: [UserItemSummary].self) { group in + for status in UserItemStatus.allCases { + group.addTask { + (try? await UserItemService.shared.getAllUserItems( + userId: currentUser.id, + status: status.rawValue + )) ?? [] + } + } + + for await items in group { + for item in items { + allIds.insert(item.tmdbId) + } + } + } + + return allIds + } + + @MainActor + private func loadTrendingItems() async { + // Check cache first + if let cached = cache.trendingItems, !cached.isEmpty { + trendingItems = cached + return + } + + do { + let items = try await loadContentForPreferences(language: Language.current.rawValue) + + // Exclude featured item to avoid repetition + let filtered = items.filter { $0.id != featuredItem?.id } + trendingItems = Array(filtered.prefix(10)) + cache.setTrendingItems(trendingItems) + } catch { + print("Error loading trending items: \(error)") + } + } + + @MainActor + private func loadContentTypeItems() async { + let language = Language.current.rawValue + + // Load anime section + if showAnimeSection { + if let cached = cache.animeItems, !cached.isEmpty { + animeItems = cached + } else { + do { + let result = try await TMDBService.shared.getPopularAnimes(language: language) + animeItems = result.results + cache.setAnimeItems(animeItems) + } catch { + print("Error loading anime items: \(error)") + } + } + } + + // Load dorama section + if showDoramaSection { + if let cached = cache.doramaItems, !cached.isEmpty { + doramaItems = cached + } else { + do { + let result = try await TMDBService.shared.getPopularDoramas(language: language) + doramaItems = result.results + cache.setDoramaItems(doramaItems) + } catch { + print("Error loading dorama items: \(error)") + } + } + } + } + + @MainActor + private func loadContextualSections() async { + let language = Language.current.rawValue + + // Now Playing (movies) + if showMoviesContent { + if let cached = cache.nowPlayingItems, !cached.isEmpty { + nowPlayingItems = cached + } else { + do { + let result = try await TMDBService.shared.getNowPlayingMovies(language: language) + nowPlayingItems = result.results + cache.setNowPlayingItems(nowPlayingItems) + } catch { + print("Error loading now playing items: \(error)") + } + } + } + + // Airing Today (series) + if showSeriesContent { + if let cached = cache.airingTodayItems, !cached.isEmpty { + airingTodayItems = cached + } else { + do { + let result = try await TMDBService.shared.getAiringTodayTVSeries(language: language) + airingTodayItems = result.results + cache.setAiringTodayItems(airingTodayItems) + } catch { + print("Error loading airing today items: \(error)") + } + } + } + } + + @MainActor + private func loadTopRatedItems() async { + if let cached = cache.topRatedItems, !cached.isEmpty { + topRatedItems = cached + return + } + + let language = Language.current.rawValue + let contentTypes = onboardingService.contentTypes + + do { + let result: PaginatedResult + if contentTypes.contains(.anime) && !showMoviesContent && !showSeriesContent { + // Anime-focused user + result = try await TMDBService.shared.getTopRatedAnimes(language: language) + } else if contentTypes.contains(.dorama) && !showMoviesContent && !showSeriesContent { + // Dorama-focused user + result = try await TMDBService.shared.getTopRatedDoramas(language: language) + } else if showMoviesContent { + result = try await TMDBService.shared.getTopRatedMovies(language: language) + } else { + result = try await TMDBService.shared.getTopRatedTVSeries(language: language) + } + topRatedItems = result.results + cache.setTopRatedItems(topRatedItems) + } catch { + print("Error loading top rated items: \(error)") + } + } + + // MARK: - Existing Loaders + @MainActor private func loadDiscoveryContent() async { // Check cache first @@ -502,6 +1048,273 @@ struct HomeTabView: View { } } +// MARK: - Featured Hero Card (Apple TV+ style) +struct FeaturedHeroCard: View { + let item: SearchResult + var label: String = "Featured" + + var body: some View { + NavigationLink { + MediaDetailView( + mediaId: item.id, + mediaType: item.mediaType ?? "movie" + ) + } label: { + GeometryReader { geometry in + ZStack(alignment: .bottomLeading) { + // Backdrop image (full resolution) + CachedAsyncImage(url: item.hdBackdropURL ?? item.backdropURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } placeholder: { + Rectangle() + .fill(Color.appInputFilled) + .frame(width: geometry.size.width, height: geometry.size.height) + } + + // Gradient overlay + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .clear, location: 0.3), + .init(color: Color.black.opacity(0.4), location: 0.6), + .init(color: Color.black.opacity(0.85), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + + // Content overlay + VStack(alignment: .leading, spacing: 6) { + // Label pill + Text(label.uppercased()) + .font(.system(size: 10, weight: .bold)) + .tracking(1.2) + .foregroundColor(.white.opacity(0.8)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.white.opacity(0.15)) + .clipShape(Capsule()) + + Spacer() + + // Title + Text(item.displayTitle) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + .lineLimit(2) + + // Year + media type + if let year = item.year { + HStack(spacing: 6) { + Text(year) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + if let mediaType = item.mediaType { + Circle() + .fill(Color.white.opacity(0.4)) + .frame(width: 3, height: 3) + + Text(mediaType == "movie" ? L10n.current.movies : L10n.current.tvSeries) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + } + } + } + .padding(20) + } + } + .frame(height: 280) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .posterBorder(cornerRadius: 24) + .shadow(color: .black.opacity(0.2), radius: 16, x: 0, y: 8) + } + .buttonStyle(.plain) + } +} + +// MARK: - Featured Hero Skeleton +struct FeaturedHeroSkeleton: View { + var body: some View { + RoundedRectangle(cornerRadius: 24) + .fill(Color.appBorderAdaptive) + .frame(height: 280) + } +} + +// MARK: - For You Section +struct ForYouSection: View { + let items: [SearchResult] + let title: String + let subtitle: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.title3.bold()) + .foregroundColor(.appForegroundAdaptive) + + if !subtitle.isEmpty { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + .padding(.horizontal, 24) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(items.prefix(15)) { item in + NavigationLink { + MediaDetailView( + mediaId: item.id, + mediaType: item.mediaType ?? "movie" + ) + } label: { + HomePosterCard(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 4) + } + .scrollClipDisabled() + } + } +} + +// MARK: - Trending Section (backdrop cards, 80% width peek) +struct TrendingSection: View { + let items: [SearchResult] + let title: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title3.bold()) + .foregroundColor(.appForegroundAdaptive) + .padding(.horizontal, 24) + + GeometryReader { proxy in + let cardWidth = proxy.size.width * 0.80 + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(Array(items.prefix(10).enumerated()), id: \.element.id) { index, item in + NavigationLink { + MediaDetailView( + mediaId: item.id, + mediaType: item.mediaType ?? "movie" + ) + } label: { + TrendingCard(item: item, rank: index + 1) + .frame(width: cardWidth) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 4) + } + .scrollClipDisabled() + } + .frame(height: 240) + } + } +} + +// MARK: - Trending Card (backdrop hero with rank badge) +struct TrendingCard: View { + let item: SearchResult + let rank: Int + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .bottomLeading) { + // Backdrop image (full resolution, same as MediaDetailView) + CachedAsyncImage(url: item.hdBackdropURL ?? item.backdropURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } placeholder: { + Rectangle() + .fill(Color.appInputFilled) + .frame(width: geometry.size.width, height: geometry.size.height) + } + + // Bottom gradient + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .clear, location: 0.35), + .init(color: Color.black.opacity(0.5), location: 0.65), + .init(color: Color.black.opacity(0.9), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + + // Content overlay + VStack(alignment: .leading, spacing: 4) { + // Rank badge + HStack { + Text("#\(rank)") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.white.opacity(0.2)) + .clipShape(Capsule()) + + Spacer() + } + + Spacer() + + // Title + Text(item.displayTitle) + .font(.system(size: 17, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + + // Year + type + if let year = item.year { + HStack(spacing: 5) { + Text(year) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + if let mediaType = item.mediaType { + Circle() + .fill(Color.white.opacity(0.4)) + .frame(width: 3, height: 3) + + Text(mediaType == "movie" ? L10n.current.movies : L10n.current.tvSeries) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + } + } + } + .padding(14) + } + } + .frame(height: 230) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .posterBorder(cornerRadius: 20) + .shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 6) + } +} + // MARK: - Home Header View struct HomeHeaderView: View { let greeting: String @@ -590,7 +1403,7 @@ struct ContinueWatchingSection: View { var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title) - .font(.headline) + .font(.title3.bold()) .foregroundColor(.appForegroundAdaptive) .padding(.horizontal, 24) @@ -624,7 +1437,7 @@ struct WatchlistSection: View { var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title) - .font(.headline) + .font(.title3.bold()) .foregroundColor(.appForegroundAdaptive) .padding(.horizontal, 24) @@ -678,12 +1491,12 @@ struct HomeSectionView: View { } label: { HStack(spacing: 6) { Text(title) - .font(.headline) + .font(.title3.bold()) .foregroundColor(.appForegroundAdaptive) Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.appForegroundAdaptive) + .foregroundColor(.appMutedForegroundAdaptive) Spacer() } @@ -736,10 +1549,10 @@ struct HomePosterCard: View { struct HomeSectionSkeleton: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - // Title skeleton - matches .font(.headline) height + // Title skeleton - matches .font(.title3) height RoundedRectangle(cornerRadius: 4) .fill(Color.appBorderAdaptive) - .frame(width: 140, height: 17) + .frame(width: 160, height: 20) .padding(.horizontal, 24) ScrollView(.horizontal, showsIndicators: false) { diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/SearchTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/SearchTabView.swift index a1488acb..1efd0c33 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/SearchTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/SearchTabView.swift @@ -308,35 +308,43 @@ struct SearchTabView: View { .padding(.horizontal, 24) .padding(.top, 16) - HomeSectionView( - title: strings.movies, - items: popularMovies, - mediaType: "movie", - categoryType: .movies, - initialMovieSubcategory: .popular - ) - - HomeSectionView( - title: strings.tvSeries, - items: popularTVSeries, - mediaType: "tv", - categoryType: .tvSeries, - initialTVSeriesSubcategory: .popular - ) - - HomeSectionView( - title: strings.animes, - items: popularAnimes, - mediaType: "tv", - categoryType: .animes - ) - - HomeSectionView( - title: strings.doramas, - items: popularDoramas, - mediaType: "tv", - categoryType: .doramas - ) + if !popularMovies.isEmpty { + HomeSectionView( + title: strings.movies, + items: popularMovies, + mediaType: "movie", + categoryType: .movies, + initialMovieSubcategory: .popular + ) + } + + if !popularTVSeries.isEmpty { + HomeSectionView( + title: strings.tvSeries, + items: popularTVSeries, + mediaType: "tv", + categoryType: .tvSeries, + initialTVSeriesSubcategory: .popular + ) + } + + if !popularAnimes.isEmpty { + HomeSectionView( + title: strings.animes, + items: popularAnimes, + mediaType: "tv", + categoryType: .animes + ) + } + + if !popularDoramas.isEmpty { + HomeSectionView( + title: strings.doramas, + items: popularDoramas, + mediaType: "tv", + categoryType: .doramas + ) + } } .padding(.bottom, 80) } diff --git a/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift b/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift index 8a3e31b2..c99fb126 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift @@ -25,7 +25,7 @@ struct LoginPromptSheet: View { .padding(.top, 12) // Icon - Image(systemName: "icloud.and.arrow.up") + Image(systemName: "person.crop.circle") .font(.system(size: 48)) .foregroundColor(.appForegroundAdaptive) .padding(.top, 8) @@ -79,7 +79,7 @@ struct LoginPromptSheet: View { } .padding(.horizontal, 24) } - .floatingSheetPresentation(height: 420) + .floatingSheetPresentation(height: 390) .preferredColorScheme(themeManager.current.colorScheme) .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) {} diff --git a/apps/ios/Plotwist/Plotwist/Views/Onboarding/OnboardingAddTitlesContent.swift b/apps/ios/Plotwist/Plotwist/Views/Onboarding/OnboardingAddTitlesContent.swift index 6953a310..2551bbd5 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Onboarding/OnboardingAddTitlesContent.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Onboarding/OnboardingAddTitlesContent.swift @@ -465,13 +465,39 @@ struct OnboardingAddTitlesContent: View { } if contentTypes.contains(.anime) { - let animes = try await TMDBService.shared.getPopularAnimes(language: language) - allItems.append(contentsOf: animes.results) + let animeGenres = getCompatibleGenreIds(for: "tv", from: allGenreIds) + if !animeGenres.isEmpty { + // Discover anime filtered by user's genre preferences + let animes = try await TMDBService.shared.discoverByGenres( + mediaType: "tv", + genreIds: animeGenres, + language: language, + page: 1, + originCountry: "JP" + ) + allItems.append(contentsOf: animes) + } else { + let animes = try await TMDBService.shared.getPopularAnimes(language: language) + allItems.append(contentsOf: animes.results) + } } if contentTypes.contains(.dorama) { - let doramas = try await TMDBService.shared.getPopularDoramas(language: language) - allItems.append(contentsOf: doramas.results) + let doramaGenres = getCompatibleGenreIds(for: "tv", from: allGenreIds) + if !doramaGenres.isEmpty { + // Discover dorama filtered by user's genre preferences + let doramas = try await TMDBService.shared.discoverByGenres( + mediaType: "tv", + genreIds: doramaGenres, + language: language, + page: 1, + originCountry: "KR" + ) + allItems.append(contentsOf: doramas) + } else { + let doramas = try await TMDBService.shared.getPopularDoramas(language: language) + allItems.append(contentsOf: doramas.results) + } } } @@ -532,13 +558,37 @@ struct OnboardingAddTitlesContent: View { } if contentTypes.contains(.anime) { - let animes = try await TMDBService.shared.getPopularAnimes(language: language, page: currentPage) - newItems.append(contentsOf: animes.results) + let animeGenres = getCompatibleGenreIds(for: "tv", from: allGenreIds) + if !animeGenres.isEmpty { + let animes = try await TMDBService.shared.discoverByGenres( + mediaType: "tv", + genreIds: animeGenres, + language: language, + page: currentPage, + originCountry: "JP" + ) + newItems.append(contentsOf: animes) + } else { + let animes = try await TMDBService.shared.getPopularAnimes(language: language, page: currentPage) + newItems.append(contentsOf: animes.results) + } } if contentTypes.contains(.dorama) { - let doramas = try await TMDBService.shared.getPopularDoramas(language: language, page: currentPage) - newItems.append(contentsOf: doramas.results) + let doramaGenres = getCompatibleGenreIds(for: "tv", from: allGenreIds) + if !doramaGenres.isEmpty { + let doramas = try await TMDBService.shared.discoverByGenres( + mediaType: "tv", + genreIds: doramaGenres, + language: language, + page: currentPage, + originCountry: "KR" + ) + newItems.append(contentsOf: doramas) + } else { + let doramas = try await TMDBService.shared.getPopularDoramas(language: language, page: currentPage) + newItems.append(contentsOf: doramas.results) + } } // Filter out already seen/dismissed items