From 2713a87a76c8d5895f7a1d3c86219ffb4ee93080 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 10:46:32 -0300 Subject: [PATCH 01/19] feat(home): redesign home screen with personalized sections for post-onboarding engagement Leverage onboarding data (content types, genres) to deliver a personalized home experience inspired by Apple TV+ design patterns. New sections: - Featured Hero Card: full-width backdrop card from trending content - For You: genre-based recommendations using onboarding preferences - Trending This Week: Apple Music-style ranked horizontal list - Anime/Dorama: conditional sections based on content type selection - In Theaters / Airing Today: contextual sections per user preferences - Top Rated: curated top-rated content based on preferred media type Also includes: - HomeDataCache extended with 8 new cache slots - Localization keys added for all 7 languages - Visual polish: larger headers (.title3.bold), 32pt section spacing Co-authored-by: Cursor --- .../Plotwist/Localization/Strings.swift | 78 ++- .../Plotwist/Services/HomeDataCache.swift | 113 ++++ .../Plotwist/Views/Home/HomeTabView.swift | 597 +++++++++++++++++- 3 files changed, 769 insertions(+), 19 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index 58ea1ec8..10bad32e 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -268,7 +268,15 @@ 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", + featured: "Featured" ), .ptBR: Strings( loginLabel: "E-mail ou nome de usuário", @@ -517,7 +525,15 @@ 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", + featured: "Destaque" ), .esES: Strings( loginLabel: "Correo electrónico o nombre de usuario", @@ -766,7 +782,15 @@ 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", + featured: "Destacado" ), .frFR: Strings( loginLabel: "E-mail ou nom d'utilisateur", @@ -1015,7 +1039,15 @@ 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", + featured: "À la Une" ), .deDE: Strings( loginLabel: "E-Mail oder Benutzername", @@ -1264,7 +1296,15 @@ 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", + featured: "Empfohlen" ), .itIT: Strings( loginLabel: "E-mail o nome utente", @@ -1513,7 +1553,15 @@ 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", + featured: "In Evidenza" ), .jaJP: Strings( loginLabel: "メールアドレスまたはユーザー名", @@ -1761,7 +1809,15 @@ enum L10n { itemsInCollection: "アイテム", bestReviews: "ベストレビュー", daysOfContent: "日分のコンテンツ", - othersGenres: "その他" + othersGenres: "その他", + // Home Engagement + forYou: "あなたへ", + basedOnYourTaste: "%@が好きだから", + trendingThisWeek: "今週のトレンド", + inTheaters: "上映中", + topRatedMovies: "高評価の映画", + topRatedSeries: "高評価のシリーズ", + featured: "注目" ), ] } @@ -2032,4 +2088,12 @@ 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 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/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 463ff747..28636589 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,31 @@ 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 } - // Show discovery sections when user has no personal content - private var showDiscoverySections: Bool { - !isLoadingDiscovery || !popularMovies.isEmpty || !popularTVSeries.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) } var body: some View { @@ -82,7 +110,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 +131,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,6 +161,68 @@ struct HomeTabView: View { ) } + // 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 - Always show for engagement if showDiscoverySkeleton { HomeSectionSkeleton() @@ -151,6 +251,18 @@ struct HomeTabView: View { } } + // Top Rated + if !topRatedItems.isEmpty { + HomeSectionView( + title: showMoviesContent ? strings.topRatedMovies : strings.topRatedSeries, + items: topRatedItems, + mediaType: showMoviesContent ? "movie" : "tv", + categoryType: showMoviesContent ? .movies : .tvSeries, + initialMovieSubcategory: showMoviesContent ? .topRated : nil, + initialTVSeriesSubcategory: showMoviesContent ? nil : .topRated + ) + } + Spacer(minLength: 100) } } @@ -210,6 +322,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 +363,236 @@ 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 + } + + do { + let language = Language.current.rawValue + let trending = try await TMDBService.shared.getTrending( + mediaType: "all", + timeWindow: "week", + language: language + ) + + // Filter by user genre preferences if available + let genreIds = Set(onboardingService.selectedGenres.map { $0.id }) + let filtered: [SearchResult] + if !genreIds.isEmpty { + // Pick from trending items that have a backdrop + filtered = trending.filter { $0.backdropPath != nil } + } else { + filtered = trending.filter { $0.backdropPath != nil } + } + + if let best = filtered.first { + featuredItem = best + cache.setFeaturedItem(best) + } + } catch { + print("Error loading featured item: \(error)") + } + } + + @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 } + guard !genreIds.isEmpty else { return } + + let language = Language.current.rawValue + let contentTypes = onboardingService.contentTypes + + do { + var allItems: [SearchResult] = [] + + 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) + } + } + + 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) + } + } + + // 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 + let finalItems = unique.filter { $0.id != featuredItem?.id } + + forYouItems = Array(finalItems.prefix(20)) + cache.setForYouItems(forYouItems) + } catch { + print("Error loading For You items: \(error)") + } + } + + @MainActor + private func loadTrendingItems() async { + // Check cache first + if let cached = cache.trendingItems, !cached.isEmpty { + trendingItems = cached + return + } + + do { + let items = try await TMDBService.shared.getTrending( + mediaType: "all", + timeWindow: "week", + language: Language.current.rawValue + ) + + // Exclude featured item + 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 + + do { + let result: PaginatedResult + 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 +867,214 @@ 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 + CachedAsyncImage(url: item.backdropURL ?? item.hdPosterURL) { 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 (Apple Music ranked style) +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) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + 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) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 4) + } + .scrollClipDisabled() + } + } +} + +// MARK: - Trending Card +struct TrendingCard: View { + let item: SearchResult + let rank: Int + + var body: some View { + HStack(alignment: .bottom, spacing: -8) { + // Large ranking number + Text("\(rank)") + .font(.system(size: 80, weight: .black, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive.opacity(0.3)) + .frame(width: 52, alignment: .trailing) + .offset(y: 10) + + // Poster + CachedAsyncImage(url: item.imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster) + .fill(Color.appBorderAdaptive) + } + .frame(width: 100, height: 150) + .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster)) + .posterBorder() + .posterShadow() + } + .frame(width: 130) + } +} + // MARK: - Home Header View struct HomeHeaderView: View { let greeting: String @@ -590,7 +1163,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 +1197,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 +1251,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 +1309,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) { From 93b53f569a015150a5a444406c23ae7f27ca146a Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 10:51:18 -0300 Subject: [PATCH 02/19] fix(home): use HD backdrop (original resolution) for featured hero card - Add hdBackdropURL property to SearchResult for original resolution backdrops - FeaturedHeroCard now uses hdBackdropURL instead of w780 backdrop - Prefetch backdrop image with high priority for instant display Co-authored-by: Cursor --- .../Plotwist/Services/TMDBService.swift | 5 +++++ .../Plotwist/Views/Home/HomeTabView.swift | 22 +++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift b/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift index eab7ea14..9c0ad06e 100644 --- a/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift @@ -1167,6 +1167,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/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 28636589..a3a7c742 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -392,19 +392,17 @@ struct HomeTabView: View { language: language ) - // Filter by user genre preferences if available - let genreIds = Set(onboardingService.selectedGenres.map { $0.id }) - let filtered: [SearchResult] - if !genreIds.isEmpty { - // Pick from trending items that have a backdrop - filtered = trending.filter { $0.backdropPath != nil } - } else { - filtered = trending.filter { $0.backdropPath != nil } - } + // Only consider items with a backdrop image + let withBackdrop = trending.filter { $0.backdropPath != nil } - if let best = filtered.first { + 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)") @@ -881,8 +879,8 @@ struct FeaturedHeroCard: View { } label: { GeometryReader { geometry in ZStack(alignment: .bottomLeading) { - // Backdrop image - CachedAsyncImage(url: item.backdropURL ?? item.hdPosterURL) { image in + // Backdrop image (full resolution) + CachedAsyncImage(url: item.hdBackdropURL ?? item.backdropURL) { image in image .resizable() .aspectRatio(contentMode: .fill) From 07f86f3e263c8c4243567ec1ee570a525aa7d359 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 10:52:40 -0300 Subject: [PATCH 03/19] feat(home): redesign trending section with backdrop cards and rank badges TrendingCard now uses backdrop images (like the featured hero card) with a gradient overlay, rank badge (#1, #2...), title, and year/type info. Cards occupy 80% of the viewport width so the next item peeks through, encouraging horizontal scroll discovery. Co-authored-by: Cursor --- .../Plotwist/Views/Home/HomeTabView.swift | 133 +++++++++++++----- 1 file changed, 96 insertions(+), 37 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index a3a7c742..b696b70c 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -1007,7 +1007,7 @@ struct ForYouSection: View { } } -// MARK: - Trending Section (Apple Music ranked style) +// MARK: - Trending Section (backdrop cards, 80% width peek) struct TrendingSection: View { let items: [SearchResult] let title: String @@ -1019,57 +1019,116 @@ struct TrendingSection: View { .foregroundColor(.appForegroundAdaptive) .padding(.horizontal, 24) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - 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) + 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) } - .buttonStyle(.plain) } + .padding(.horizontal, 24) + .padding(.vertical, 4) } - .padding(.horizontal, 24) - .padding(.vertical, 4) + .scrollClipDisabled() } - .scrollClipDisabled() + .frame(height: 200) } } } -// MARK: - Trending Card +// MARK: - Trending Card (backdrop hero with rank badge) struct TrendingCard: View { let item: SearchResult let rank: Int var body: some View { - HStack(alignment: .bottom, spacing: -8) { - // Large ranking number - Text("\(rank)") - .font(.system(size: 80, weight: .black, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive.opacity(0.3)) - .frame(width: 52, alignment: .trailing) - .offset(y: 10) - - // Poster - CachedAsyncImage(url: item.imageURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster) - .fill(Color.appBorderAdaptive) + GeometryReader { geometry in + ZStack(alignment: .bottomLeading) { + // Backdrop image + CachedAsyncImage(url: item.backdropURL ?? item.hdPosterURL) { 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(width: 100, height: 150) - .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster)) - .posterBorder() - .posterShadow() } - .frame(width: 130) + .frame(height: 190) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .posterBorder(cornerRadius: 20) + .shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 6) } } From da80bca3f52cd9ed54e9d3f47633b67defae0e3e Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 10:55:16 -0300 Subject: [PATCH 04/19] fix(home): hide Popular Movies/Series when In Theaters/Airing Today is shown Avoids redundant sections with overlapping content. Popular Movies is hidden when "In Theaters" (Now Playing) is active, and Popular TV Series is hidden when "Airing Today" is active. Co-authored-by: Cursor --- apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index b696b70c..90fe8196 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -224,12 +224,13 @@ struct HomeTabView: View { } // Discovery Sections - Always show for engagement + // Skip Popular Movies if "In Theaters" is already showing (overlapping content) if showDiscoverySkeleton { HomeSectionSkeleton() HomeSectionSkeleton() } else { - // Popular Movies - if !popularMovies.isEmpty { + // Popular Movies -- only if "In Theaters" isn't already visible + if !popularMovies.isEmpty && nowPlayingItems.isEmpty { HomeSectionView( title: strings.popularMovies, items: popularMovies, @@ -239,8 +240,8 @@ struct HomeTabView: View { ) } - // Popular TV Series - if !popularTVSeries.isEmpty { + // Popular TV Series -- only if "Airing Today" isn't already visible + if !popularTVSeries.isEmpty && airingTodayItems.isEmpty { HomeSectionView( title: strings.popularTVSeries, items: popularTVSeries, From 5140efc0eaaa3a026e0e7d1c3d1c7dfd475a982e Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:01:10 -0300 Subject: [PATCH 05/19] fix(home): personalize home for anime and dorama users Previously, users who selected only Anime or Dorama during onboarding would see a generic home with Popular Movies/TV Series and trending content unrelated to their preferences. Now: - Featured Hero shows popular anime/dorama instead of generic trending - For You section includes anime/dorama content via discover endpoints - Popular Movies/TV Series hidden when user didn't select those types - Top Rated adapts to show top rated anime or dorama accordingly - All sections respect the user's content type preferences from onboarding Co-authored-by: Cursor --- .../Plotwist/Views/Home/HomeTabView.swift | 127 +++++++++++++++--- 1 file changed, 105 insertions(+), 22 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 90fe8196..b9fa89f3 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -104,6 +104,37 @@ struct HomeTabView: View { 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.topRated + " " + strings.animes + } else if contentTypes.contains(.dorama) && !showMoviesContent && !showSeriesContent { + return strings.topRated + " " + strings.doramas + } else if showMoviesContent { + return strings.topRatedMovies + } else { + return strings.topRatedSeries + } + } + + 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 { NavigationView { ZStack { @@ -223,14 +254,14 @@ struct HomeTabView: View { ) } - // Discovery Sections - Always show for engagement - // Skip Popular Movies if "In Theaters" is already showing (overlapping content) - if showDiscoverySkeleton { + // 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 -- only if "In Theaters" isn't already visible - if !popularMovies.isEmpty && nowPlayingItems.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, @@ -240,8 +271,8 @@ struct HomeTabView: View { ) } - // Popular TV Series -- only if "Airing Today" isn't already visible - if !popularTVSeries.isEmpty && airingTodayItems.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, @@ -252,15 +283,15 @@ struct HomeTabView: View { } } - // Top Rated + // Top Rated (adapts to user's content type) if !topRatedItems.isEmpty { HomeSectionView( - title: showMoviesContent ? strings.topRatedMovies : strings.topRatedSeries, + title: topRatedSectionTitle, items: topRatedItems, - mediaType: showMoviesContent ? "movie" : "tv", - categoryType: showMoviesContent ? .movies : .tvSeries, + mediaType: topRatedMediaType, + categoryType: topRatedCategoryType, initialMovieSubcategory: showMoviesContent ? .topRated : nil, - initialTVSeriesSubcategory: showMoviesContent ? nil : .topRated + initialTVSeriesSubcategory: !showMoviesContent ? .topRated : nil ) } @@ -385,16 +416,32 @@ struct HomeTabView: View { return } + let language = Language.current.rawValue + let contentTypes = onboardingService.contentTypes + do { - let language = Language.current.rawValue - let trending = try await TMDBService.shared.getTrending( - mediaType: "all", - timeWindow: "week", - language: language - ) + var candidates: [SearchResult] = [] + + // Pick featured content that matches user's content type preferences + if contentTypes.contains(.anime) && !contentTypes.contains(.movies) && !contentTypes.contains(.series) { + // Anime-only user: feature a popular anime + let result = try await TMDBService.shared.getPopularAnimes(language: language) + candidates = result.results + } else if contentTypes.contains(.dorama) && !contentTypes.contains(.movies) && !contentTypes.contains(.series) { + // Dorama-only user: feature a popular dorama + let result = try await TMDBService.shared.getPopularDoramas(language: language) + candidates = result.results + } else { + // General user or mixed preferences: use trending + candidates = try await TMDBService.shared.getTrending( + mediaType: "all", + timeWindow: "week", + language: language + ) + } // Only consider items with a backdrop image - let withBackdrop = trending.filter { $0.backdropPath != nil } + let withBackdrop = candidates.filter { $0.backdropPath != nil } if let best = withBackdrop.first { featuredItem = best @@ -419,14 +466,16 @@ struct HomeTabView: View { } let genreIds = onboardingService.selectedGenres.map { $0.id } - guard !genreIds.isEmpty else { return } - 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) @@ -441,6 +490,7 @@ struct HomeTabView: View { } } + // 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) @@ -455,6 +505,32 @@ struct HomeTabView: View { } } + // Anime: use discover with anime-compatible genres 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.discoverAnimes( + language: language, + page: 1, + watchRegion: nil, + withWatchProviders: nil + ) + allItems.append(contentsOf: animes.results) + } else { + // No matching genres, just use popular animes + let animes = try await TMDBService.shared.getPopularAnimes(language: language) + allItems.append(contentsOf: animes.results) + } + } + + // Dorama: use discover or fallback to popular + if contentTypes.contains(.dorama) { + 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 @@ -575,10 +651,17 @@ struct HomeTabView: View { } let language = Language.current.rawValue + let contentTypes = onboardingService.contentTypes do { let result: PaginatedResult - if showMoviesContent { + 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) From 71575c177167024bc95f3ae46ee0de65e7bf32ae Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:01:50 -0300 Subject: [PATCH 06/19] feat(debug): add onboarding debug button on login screen Adds a subtle "Debug: Open Onboarding" button below "Create Account" on the login view. Only visible in DEBUG builds. Resets onboarding state and navigates to the onboarding flow for testing. Co-authored-by: Cursor --- .../Plotwist/Plotwist/Views/Auth/LoginView.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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) From b7643deef148f2c4ab831abd0713eafd41ed29b5 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:06:33 -0300 Subject: [PATCH 07/19] fix: filter Featured and Trending sections by user content type preferences Previously, Featured Hero and Trending This Week always used `getTrending(mediaType: "all")` which mixed movies, TV, anime, and dorama regardless of what the user selected during onboarding. Now both sections use a shared `loadContentForPreferences()` helper that fetches only the content types the user actually selected (e.g. anime-only users see only anime in trending). Each content type is fetched in parallel for performance. Co-authored-by: Cursor --- .../Plotwist/Views/Home/HomeTabView.swift | 96 ++++++++++++++----- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index b9fa89f3..198eb799 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -417,28 +417,10 @@ struct HomeTabView: View { } let language = Language.current.rawValue - let contentTypes = onboardingService.contentTypes do { - var candidates: [SearchResult] = [] - - // Pick featured content that matches user's content type preferences - if contentTypes.contains(.anime) && !contentTypes.contains(.movies) && !contentTypes.contains(.series) { - // Anime-only user: feature a popular anime - let result = try await TMDBService.shared.getPopularAnimes(language: language) - candidates = result.results - } else if contentTypes.contains(.dorama) && !contentTypes.contains(.movies) && !contentTypes.contains(.series) { - // Dorama-only user: feature a popular dorama - let result = try await TMDBService.shared.getPopularDoramas(language: language) - candidates = result.results - } else { - // General user or mixed preferences: use trending - candidates = try await TMDBService.shared.getTrending( - mediaType: "all", - timeWindow: "week", - language: language - ) - } + // 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 } @@ -457,6 +439,72 @@ struct HomeTabView: View { } } + /// 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 @@ -558,13 +606,9 @@ struct HomeTabView: View { } do { - let items = try await TMDBService.shared.getTrending( - mediaType: "all", - timeWindow: "week", - language: Language.current.rawValue - ) + let items = try await loadContentForPreferences(language: Language.current.rawValue) - // Exclude featured item + // Exclude featured item to avoid repetition let filtered = items.filter { $0.id != featuredItem?.id } trendingItems = Array(filtered.prefix(10)) cache.setTrendingItems(trendingItems) From 7185df5d27cec4dbced4f813ac4306adc426f5d1 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:10:25 -0300 Subject: [PATCH 08/19] feat: filter "For You" section by user's existing collection Excludes items the user already has in their collection (watchlist, watching, watched, dropped) from the "For You" recommendations so they only see fresh content they haven't interacted with yet. For authenticated users, fetches all statuses in parallel via the API. For guest mode, uses local saved titles from onboarding. Co-authored-by: Cursor --- .../Plotwist/Views/Home/HomeTabView.swift | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 198eb799..8fbce04b 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -588,15 +588,57 @@ struct HomeTabView: View { } // Exclude featured item to avoid repetition - let finalItems = unique.filter { $0.id != featuredItem?.id } + var filtered = unique.filter { $0.id != featuredItem?.id } - forYouItems = Array(finalItems.prefix(20)) + // 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. + 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, + let currentUser = user ?? (try? await AuthService.shared.getCurrentUser()) 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 From a1aa38ac32fd02ec12069a55f356c5a5c0014d86 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:15:22 -0300 Subject: [PATCH 09/19] style: use sentence case for all home section titles Changes all home screen section titles from Title Case to sentence case (only first letter capitalized) across all 6 languages (EN, PT, ES, FR, DE, IT). Japanese is unaffected as it has no letter casing. German nouns remain capitalized per language rules. Co-authored-by: Cursor --- .../Plotwist/Localization/Strings.swift | 154 +++++++++--------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index 10bad32e..5a95c380 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", @@ -270,12 +270,12 @@ enum L10n { daysOfContent: "days of content", othersGenres: "Others", // Home Engagement - forYou: "For You", + forYou: "For you", basedOnYourTaste: "Because you like %@", - trendingThisWeek: "Trending This Week", - inTheaters: "In Theaters", - topRatedMovies: "Top Rated Movies", - topRatedSeries: "Top Rated Series", + trendingThisWeek: "Trending this week", + inTheaters: "In theaters", + topRatedMovies: "Top rated movies", + topRatedSeries: "Top rated series", featured: "Featured" ), .ptBR: Strings( @@ -327,10 +327,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", @@ -367,13 +367,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", @@ -426,8 +426,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", @@ -527,12 +527,12 @@ enum L10n { daysOfContent: "dias de conteúdo", othersGenres: "Outros", // Home Engagement - forYou: "Para Você", + 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", + trendingThisWeek: "Em alta esta semana", + inTheaters: "Nos cinemas", + topRatedMovies: "Filmes mais bem avaliados", + topRatedSeries: "Séries mais bem avaliadas", featured: "Destaque" ), .esES: Strings( @@ -584,10 +584,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", @@ -624,13 +624,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", @@ -683,7 +683,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", @@ -784,12 +784,12 @@ enum L10n { daysOfContent: "días de contenido", othersGenres: "Otros", // Home Engagement - forYou: "Para Ti", + forYou: "Para ti", basedOnYourTaste: "Porque te gusta %@", - trendingThisWeek: "Tendencias de la Semana", - inTheaters: "En Cartelera", - topRatedMovies: "Películas Mejor Valoradas", - topRatedSeries: "Series Mejor Valoradas", + trendingThisWeek: "Tendencias de la semana", + inTheaters: "En cartelera", + topRatedMovies: "Películas mejor valoradas", + topRatedSeries: "Series mejor valoradas", featured: "Destacado" ), .frFR: Strings( @@ -841,10 +841,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", @@ -881,13 +881,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", @@ -940,8 +940,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", @@ -1041,13 +1041,13 @@ enum L10n { daysOfContent: "jours de contenu", othersGenres: "Autres", // Home Engagement - forYou: "Pour Vous", + 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", - featured: "À la Une" + trendingThisWeek: "Tendances de la semaine", + inTheaters: "Au cinéma", + topRatedMovies: "Films les mieux notés", + topRatedSeries: "Séries les mieux notées", + featured: "À la une" ), .deDE: Strings( loginLabel: "E-Mail oder Benutzername", @@ -1298,7 +1298,7 @@ enum L10n { daysOfContent: "Tage an Inhalten", othersGenres: "Andere", // Home Engagement - forYou: "Für Dich", + forYou: "Für dich", basedOnYourTaste: "Weil du %@ magst", trendingThisWeek: "Trends dieser Woche", inTheaters: "Im Kino", @@ -1355,10 +1355,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", @@ -1395,13 +1395,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", @@ -1454,7 +1454,7 @@ enum L10n { goodMorning: "Buongiorno", goodAfternoon: "Buon pomeriggio", goodEvening: "Buonasera", - continueWatching: "Continua a Guardare", + continueWatching: "Continua a guardare", upNext: "Prossimi", // Collection partOf: "Parte di", @@ -1555,13 +1555,13 @@ enum L10n { daysOfContent: "giorni di contenuti", othersGenres: "Altri", // Home Engagement - forYou: "Per Te", + forYou: "Per te", basedOnYourTaste: "Perché ti piace %@", - trendingThisWeek: "Tendenze della Settimana", - inTheaters: "Al Cinema", - topRatedMovies: "Film Più Votati", - topRatedSeries: "Serie Più Votate", - featured: "In Evidenza" + trendingThisWeek: "Tendenze della settimana", + inTheaters: "Al cinema", + topRatedMovies: "Film più votati", + topRatedSeries: "Serie più votate", + featured: "In evidenza" ), .jaJP: Strings( loginLabel: "メールアドレスまたはユーザー名", From 4d5049282c36cb23daf09a0d820b34f7002a54bd Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:15:57 -0300 Subject: [PATCH 10/19] fix: add @MainActor to loadUserCollectionIds to fix async concurrency error The function accesses @State properties and uses try? await in a guard statement, which requires MainActor context. Co-authored-by: Cursor --- apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 8fbce04b..f2f991de 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -605,6 +605,7 @@ struct HomeTabView: View { /// 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 { From 816f005bb21c7fa91bdf5bcdaa15dedc709f10e6 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:16:48 -0300 Subject: [PATCH 11/19] fix: move async call out of guard-let to fix concurrency error Swift doesn't support try? await inside guard-let conditions. Extracted the async getCurrentUser() call to a separate let binding. Co-authored-by: Cursor --- apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index f2f991de..e5438dea 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -613,10 +613,10 @@ struct HomeTabView: View { } // Authenticated: fetch all statuses in parallel - guard AuthService.shared.isAuthenticated, - let currentUser = user ?? (try? await AuthService.shared.getCurrentUser()) else { - return [] - } + guard AuthService.shared.isAuthenticated else { return [] } + + let fetchedUser: User? = try? await AuthService.shared.getCurrentUser() + guard let currentUser = user ?? fetchedUser else { return [] } var allIds = Set() From 59f8eae4015c3035f137973eef35bc7f72963184 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:22:52 -0300 Subject: [PATCH 12/19] fix: use selected genres to filter anime/dorama in title selector and home Previously, anime and dorama content ignored the user's genre preferences and always showed generic popular content. Now: - TMDBService.discoverByGenres accepts an optional originCountry parameter - Onboarding title selector filters anime (JP) and dorama (KR) by the user's selected genres via the discover API - Home "For You" section applies the same genre-based filtering for anime and dorama content Falls back to popular content when no genres are selected. Co-authored-by: Cursor --- .../Plotwist/Services/TMDBService.swift | 8 ++- .../Plotwist/Views/Home/HomeTabView.swift | 32 ++++++--- .../OnboardingAddTitlesContent.swift | 66 ++++++++++++++++--- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift b/apps/ios/Plotwist/Plotwist/Services/TMDBService.swift index 9c0ad06e..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 } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index e5438dea..435cb7eb 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -553,30 +553,42 @@ struct HomeTabView: View { } } - // Anime: use discover with anime-compatible genres or fallback to popular + // 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.discoverAnimes( + let animes = try await TMDBService.shared.discoverByGenres( + mediaType: "tv", + genreIds: animeGenreIds, language: language, - page: 1, - watchRegion: nil, - withWatchProviders: nil + originCountry: "JP" ) - allItems.append(contentsOf: animes.results) + allItems.append(contentsOf: animes) } else { - // No matching genres, just use popular animes let animes = try await TMDBService.shared.getPopularAnimes(language: language) allItems.append(contentsOf: animes.results) } } - // Dorama: use discover or fallback to popular + // Dorama: use discover with user's genre preferences or fallback to popular if contentTypes.contains(.dorama) { - let doramas = try await TMDBService.shared.getPopularDoramas(language: language) - allItems.append(contentsOf: doramas.results) + 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 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 From 6e5625a9722520d16f393b8aa373a46986a5d48e Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:24:41 -0300 Subject: [PATCH 13/19] fix: add dedicated localized strings for top rated animes/doramas The title was built by concatenating topRated + animes which produced incorrect word order in Portuguese ("Mais bem avaliados Animes" instead of "Animes mais bem avaliados"). Added topRatedAnimes and topRatedDoramas as dedicated strings with correct grammar in all 7 languages. Co-authored-by: Cursor --- .../Plotwist/Plotwist/Localization/Strings.swift | 16 ++++++++++++++++ .../Plotwist/Views/Home/HomeTabView.swift | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index 5a95c380..f9f9b059 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -276,6 +276,8 @@ enum L10n { inTheaters: "In theaters", topRatedMovies: "Top rated movies", topRatedSeries: "Top rated series", + topRatedAnimes: "Top rated animes", + topRatedDoramas: "Top rated doramas", featured: "Featured" ), .ptBR: Strings( @@ -533,6 +535,8 @@ enum L10n { 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( @@ -790,6 +794,8 @@ enum L10n { inTheaters: "En cartelera", topRatedMovies: "Películas mejor valoradas", topRatedSeries: "Series mejor valoradas", + topRatedAnimes: "Animes mejor valorados", + topRatedDoramas: "Doramas mejor valorados", featured: "Destacado" ), .frFR: Strings( @@ -1047,6 +1053,8 @@ enum L10n { 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( @@ -1304,6 +1312,8 @@ enum L10n { inTheaters: "Im Kino", topRatedMovies: "Bestbewertete Filme", topRatedSeries: "Bestbewertete Serien", + topRatedAnimes: "Bestbewertete Animes", + topRatedDoramas: "Bestbewertete Doramas", featured: "Empfohlen" ), .itIT: Strings( @@ -1561,6 +1571,8 @@ enum L10n { inTheaters: "Al cinema", topRatedMovies: "Film più votati", topRatedSeries: "Serie più votate", + topRatedAnimes: "Anime più votati", + topRatedDoramas: "Dorama più votati", featured: "In evidenza" ), .jaJP: Strings( @@ -1817,6 +1829,8 @@ enum L10n { inTheaters: "上映中", topRatedMovies: "高評価の映画", topRatedSeries: "高評価のシリーズ", + topRatedAnimes: "高評価のアニメ", + topRatedDoramas: "高評価のドラマ", featured: "注目" ), ] @@ -2095,5 +2109,7 @@ struct Strings { 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/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 435cb7eb..580dd061 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -108,9 +108,9 @@ struct HomeTabView: View { private var topRatedSectionTitle: String { let contentTypes = onboardingService.contentTypes if contentTypes.contains(.anime) && !showMoviesContent && !showSeriesContent { - return strings.topRated + " " + strings.animes + return strings.topRatedAnimes } else if contentTypes.contains(.dorama) && !showMoviesContent && !showSeriesContent { - return strings.topRated + " " + strings.doramas + return strings.topRatedDoramas } else if showMoviesContent { return strings.topRatedMovies } else { From 6b2303e3b6573671e4d1ed8335087aa351ed810d Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:29:00 -0300 Subject: [PATCH 14/19] feat: show review/status/watched buttons for guest users with login prompt Previously, action buttons (review, status, mark as watched) were completely hidden for non-authenticated users. Now they are always visible but tapping them as a guest triggers the login prompt sheet. Changes across 4 files: - MediaDetailView: shows review + status buttons, prompts login on tap - SeasonDetailView: shows review button + episode progress bar for all users, prompts login on review tap and episode checkbox toggle - EpisodeDetailView: shows watched + review buttons, prompts login - MediaDetailViewActions: added onLoginRequired callback for status button After successful login via the prompt, user data is automatically reloaded so the UI updates immediately. Co-authored-by: Cursor --- .../Components/MediaDetailViewActions.swift | 9 +- .../Views/Details/EpisodeDetailView.swift | 94 +++++++++++-------- .../Views/Details/MediaDetailView.swift | 45 ++++++--- .../Views/Details/SeasonDetailView.swift | 61 +++++++----- 4 files changed, 135 insertions(+), 74 deletions(-) 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/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/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?() } } ) From 384e28526cf1fc873313fc86bdde9cdccc1666db Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:30:03 -0300 Subject: [PATCH 15/19] style: make trending cards taller for a more square aspect ratio Changed trending card height from 190 to 230 and container from 200 to 240, bringing the aspect ratio closer to the featured hero card instead of the previous 16:9-like proportion. Co-authored-by: Cursor --- apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 580dd061..6d5a96cf 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -1225,7 +1225,7 @@ struct TrendingSection: View { } .scrollClipDisabled() } - .frame(height: 200) + .frame(height: 240) } } } @@ -1308,7 +1308,7 @@ struct TrendingCard: View { .padding(14) } } - .frame(height: 190) + .frame(height: 230) .clipShape(RoundedRectangle(cornerRadius: 20)) .posterBorder(cornerRadius: 20) .shadow(color: .black.opacity(0.15), radius: 10, x: 0, y: 6) From ccdabb04e255e2bf2e17eed096508b1af99f3433 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:34:04 -0300 Subject: [PATCH 16/19] fix: make login prompt text universal for all contexts The login prompt was using onboarding-specific text ("Save your progress" / "Sign in to sync across devices") which didn't make sense when triggered from review/status/watched buttons. Updated to universal text that works everywhere: - Title: "Sign in to Plotwist" (brand-focused) - Subtitle: explains all benefits (review, track, sync) - Icon: changed from icloud.and.arrow.up to person.crop.circle Updated across all 7 languages. Co-authored-by: Cursor --- .../Plotwist/Localization/Strings.swift | 28 +++++++++---------- .../Views/Onboarding/LoginPromptSheet.swift | 4 +-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index f9f9b059..a16f4849 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -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", @@ -480,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", @@ -739,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", @@ -998,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", @@ -1257,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", @@ -1516,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", @@ -1774,8 +1774,8 @@ enum L10n { onboardingCelebrationTitle: "準備完了!🎉", onboardingCelebrationSubtitle: "パーソナライズされた体験の準備完了。Plotwistを楽しもう!", onboardingGoToHome: "ホームへ", - onboardingLoginTitle: "進捗を保存", - onboardingLoginSubtitle: "ログインしてデバイス間で同期", + onboardingLoginTitle: "Plotwistにログイン", + onboardingLoginSubtitle: "アカウントを作成して、レビュー、進捗管理、全デバイスでの同期を利用しましょう。", onboardingNotNow: "今はしない", // Genres genreAction: "アクション", diff --git a/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift b/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift index 8a3e31b2..12bbb73e 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: 450) .preferredColorScheme(themeManager.current.colorScheme) .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) {} From 7ada6ce149057c9be9d3b17cb6f86088b79d1f9b Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 11:44:17 -0300 Subject: [PATCH 17/19] fix: remove translucent glass material from floating sheets on iOS 26 On iOS 26 (Liquid Glass), .presentationBackground(.clear) as a ShapeStyle doesn't fully remove the system glass material behind sheets. Fixed by: - Using the view-based .presentationBackground { Color.clear } overload which explicitly renders a transparent view instead of a style hint - Adding .presentationCornerRadius(0) to prevent the system from applying its own rounded corner chrome/material layer The FloatingSheetContainer already handles its own corner radius and background, so the system-level ones are unnecessary. Co-authored-by: Cursor --- apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift b/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift index 70734fa4..c8debff6 100644 --- a/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift +++ b/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift @@ -73,7 +73,10 @@ extension View { func floatingSheetPresentation(height: CGFloat) -> some View { self .presentationDetents([.height(height + SheetStyle.heightOffset)]) - .presentationBackground(.clear) + .presentationBackground { + Color.clear + } + .presentationCornerRadius(0) .presentationDragIndicator(.hidden) } @@ -81,7 +84,10 @@ extension View { func floatingSheetPresentation(detents: Set) -> some View { self .presentationDetents(detents) - .presentationBackground(.clear) + .presentationBackground { + Color.clear + } + .presentationCornerRadius(0) .presentationDragIndicator(.hidden) } } From 9c4696606be2658e02482983392af9b8881641f1 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 12:11:50 -0300 Subject: [PATCH 18/19] fix: replace Liquid Glass with solid background on sheets and adjust login prompt height Use presentationBackground with solid color to override iOS 26 Liquid Glass on all floating sheets. Simplify FloatingSheetContainer to a pass-through since system now handles sheet styling. Reduce login prompt sheet height from 450 to 390 to better fit content. Bump build to 19. Co-authored-by: Cursor --- .../Plotwist.xcodeproj/project.pbxproj | 4 +- .../Plotwist/Extensions/View+Sheet.swift | 64 ++----------------- .../Views/Onboarding/LoginPromptSheet.swift | 2 +- 3 files changed, 10 insertions(+), 60 deletions(-) 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/Extensions/View+Sheet.swift b/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift index c8debff6..b81e0db4 100644 --- a/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift +++ b/apps/ios/Plotwist/Plotwist/Extensions/View+Sheet.swift @@ -5,89 +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)]) + .presentationDetents([.height(height)]) .presentationBackground { - Color.clear + Color.appSheetBackgroundAdaptive.ignoresSafeArea() } - .presentationCornerRadius(0) .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 { - Color.clear + Color.appSheetBackgroundAdaptive.ignoresSafeArea() } - .presentationCornerRadius(0) .presentationDragIndicator(.hidden) } } diff --git a/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift b/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift index 12bbb73e..c99fb126 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Onboarding/LoginPromptSheet.swift @@ -79,7 +79,7 @@ struct LoginPromptSheet: View { } .padding(.horizontal, 24) } - .floatingSheetPresentation(height: 450) + .floatingSheetPresentation(height: 390) .preferredColorScheme(themeManager.current.colorScheme) .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) {} From 0695b0e251058ae09e234df139c2a164e13c7bd9 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Sat, 7 Feb 2026 12:16:57 -0300 Subject: [PATCH 19/19] refactor: adjust layout and improve content display in ReviewSheet and SearchTabView - Reduced padding in ReviewSheet for a more compact layout. - Updated floating sheet presentation heights for better content fit. - Enhanced SearchTabView to conditionally display HomeSectionView components only when items are available, improving UI responsiveness. Co-authored-by: Cursor --- .../Plotwist/Views/Details/ReviewSheet.swift | 6 +- .../Plotwist/Views/Home/HomeTabView.swift | 4 +- .../Plotwist/Views/Home/SearchTabView.swift | 66 +++++++++++-------- 3 files changed, 42 insertions(+), 34 deletions(-) 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/Home/HomeTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift index 6d5a96cf..8a664ee0 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/HomeTabView.swift @@ -1238,8 +1238,8 @@ struct TrendingCard: View { var body: some View { GeometryReader { geometry in ZStack(alignment: .bottomLeading) { - // Backdrop image - CachedAsyncImage(url: item.backdropURL ?? item.hdPosterURL) { image in + // Backdrop image (full resolution, same as MediaDetailView) + CachedAsyncImage(url: item.hdBackdropURL ?? item.backdropURL) { image in image .resizable() .aspectRatio(contentMode: .fill) 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) }