diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EndOfYearDataManager.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EndOfYearDataManager.swift index 3350b68e6..4098bfc39 100644 --- a/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EndOfYearDataManager.swift +++ b/Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/EndOfYearDataManager.swift @@ -376,6 +376,17 @@ class EndOfYearDataManager { return EpisodesStartedAndCompleted(started: started, completed: completed) } + + func summarizedRatings(in year: Int) -> [UInt32: Int]? { + let calendar = Calendar.current + let ratings = DataManager.sharedManager.ratings.ratings?.filter { rating in + calendar.component(.year, from: rating.modifiedAt) == year + } + let groupedRatings = ratings?.reduce(into: [:]) { counts, rating in + counts[rating.podcastRating, default: 0] += 1 + } + return groupedRatings + } } public struct ListenedCategory { diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift index 850946342..b81fc3fac 100644 --- a/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift +++ b/Modules/DataModel/Sources/PocketCastsDataModel/Public/DataManager.swift @@ -24,6 +24,7 @@ public class DataManager { public let autoAddCandidates: AutoAddCandidatesDataManager public let bookmarks: BookmarkDataManager + public let ratings: RatingsDataManager private let dbQueue: FMDatabaseQueue @@ -60,6 +61,7 @@ public class DataManager { autoAddCandidates = AutoAddCandidatesDataManager(dbQueue: dbQueue) bookmarks = BookmarkDataManager(dbQueue: dbQueue) + ratings = RatingsDataManager() } convenience init(endOfYearManager: EndOfYearDataManager) { @@ -1133,5 +1135,10 @@ public extension DataManager { func episodesStartedAndCompleted(in year: Int) -> EpisodesStartedAndCompleted { endOfYearManager.episodesStartedAndCompleted(in: year, dbQueue: dbQueue) + + } + + func summarizedRatings(in year: Int) -> [UInt32: Int]? { + endOfYearManager.summarizedRatings(in: year) } } diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Public/Model/UserPodcastRating.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Public/Model/UserPodcastRating.swift new file mode 100644 index 000000000..4a43e335e --- /dev/null +++ b/Modules/DataModel/Sources/PocketCastsDataModel/Public/Model/UserPodcastRating.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct UserPodcastRating: Codable { + public let podcastRating: UInt32 + public let podcastUuid: String + public let modifiedAt: Date + + public init(podcastRating: UInt32, podcastUuid: String, modifiedAt: Date) { + self.podcastRating = podcastRating + self.podcastUuid = podcastUuid + self.modifiedAt = modifiedAt + } +} diff --git a/Modules/DataModel/Sources/PocketCastsDataModel/Public/RatingsDataManager.swift b/Modules/DataModel/Sources/PocketCastsDataModel/Public/RatingsDataManager.swift new file mode 100644 index 000000000..c8232ec1f --- /dev/null +++ b/Modules/DataModel/Sources/PocketCastsDataModel/Public/RatingsDataManager.swift @@ -0,0 +1,4 @@ +/// This is currently in memory only since we don't store these locally in the database +public class RatingsDataManager { + public var ratings: [UserPodcastRating]? +} diff --git a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift new file mode 100644 index 000000000..b0f9d47e1 --- /dev/null +++ b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/RetrieveRatingsTask.swift @@ -0,0 +1,52 @@ +import Foundation +import PocketCastsDataModel +import PocketCastsUtils +import SwiftProtobuf + +class RetrieveRatingsTask: ApiBaseTask, @unchecked Sendable { + var completion: (([UserPodcastRating]?) -> Void)? + + var success: Bool = false + + private var convertedRatings = [UserPodcastRating]() + + private lazy var addRatingGroup: DispatchGroup = { + let dispatchGroup = DispatchGroup() + + return dispatchGroup + }() + + override func apiTokenAcquired(token: String) { + let url = ServerConstants.Urls.api() + "user/podcast_rating/list" + + do { + let (response, httpStatus) = getToServer(url: url, token: token) + + guard let responseData = response, httpStatus?.statusCode == ServerConstants.HttpConstants.ok else { + completion?(nil) + return + } + + let serverRatings = try Api_PodcastRatingsResponse(serializedBytes: responseData).podcastRatings + if serverRatings.count == 0 { + success = true + completion?(convertedRatings) + + return + } + + convertedRatings = serverRatings.map { rating in + UserPodcastRating(podcastRating: rating.podcastRating, podcastUuid: rating.podcastUuid, modifiedAt: rating.modifiedAt.date) + } + + DataManager.sharedManager.ratings.ratings = convertedRatings + + success = true + + completion?(convertedRatings) + } catch { + FileLog.shared.addMessage("Decoding ratings failed \(error.localizedDescription)") + completion?(nil) + } + } +} diff --git a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/UserPodcastRatingTask.swift b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/UserPodcastRatingTask.swift index 30c3437bf..e39a8509c 100644 --- a/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/UserPodcastRatingTask.swift +++ b/Modules/Server/Sources/PocketCastsServer/Private/API Tasks/UserPodcastRatingTask.swift @@ -1,6 +1,7 @@ import Foundation import PocketCastsUtils import SwiftProtobuf +import PocketCastsDataModel class UserPodcastRatingAddTask: ApiBaseTask { var completion: ((Bool) -> Void)? @@ -88,9 +89,3 @@ class UserPodcastRatingGetTask: ApiBaseTask { } } } - -public struct UserPodcastRating: Codable { - public let podcastRating: UInt32 - public let podcastUuid: String - public let modifiedAt: Date -} diff --git a/Modules/Server/Sources/PocketCastsServer/Public/API/ApiServerHandler+UserPodcastRating.swift b/Modules/Server/Sources/PocketCastsServer/Public/API/ApiServerHandler+UserPodcastRating.swift index 946b9f093..42d0d1eb4 100644 --- a/Modules/Server/Sources/PocketCastsServer/Public/API/ApiServerHandler+UserPodcastRating.swift +++ b/Modules/Server/Sources/PocketCastsServer/Public/API/ApiServerHandler+UserPodcastRating.swift @@ -1,4 +1,5 @@ import Foundation +import PocketCastsDataModel public extension ApiServerHandler { func addRating(uuid: String, rating: Int) async -> Bool { diff --git a/Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncYearListeningHistoryTask.swift b/Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncYearListeningHistoryTask.swift index 36baa387b..98391265f 100644 --- a/Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncYearListeningHistoryTask.swift +++ b/Modules/Server/Sources/PocketCastsServer/Public/Sync/SyncYearListeningHistoryTask.swift @@ -192,7 +192,7 @@ class PodcastExistsHelper { public class YearListeningHistory { public static func sync() -> Bool { var syncResults: [Bool] = [] - let yearsToSync: [Int32] = SubscriptionHelper.hasActiveSubscription() ? [2023, 2022] : [2023] + let yearsToSync: [Int32] = SubscriptionHelper.hasActiveSubscription() ? [2024, 2023, 2022] : [2024, 2023] let dispatchGroup = DispatchGroup() yearsToSync.forEach { yearToSync in @@ -205,6 +205,12 @@ public class YearListeningHistory { syncResults.append(syncYearListeningHistory.success) + let syncRatings = RetrieveRatingsTask() + + syncRatings.start() + + syncResults.append(syncRatings.success) + dispatchGroup.leave() } } diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj index 89b507440..d64e3445b 100644 --- a/podcasts.xcodeproj/project.pbxproj +++ b/podcasts.xcodeproj/project.pbxproj @@ -1667,6 +1667,7 @@ F52B4F8E2BB4A9ED00E87BE4 /* CategoriesSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52B4F8D2BB4A9ED00E87BE4 /* CategoriesSelectorView.swift */; }; F533F19D2C24B8CB00EDE9AA /* ShareDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = F533F19C2C24B8CB00EDE9AA /* ShareDestination.swift */; }; F53C3E832CC73549004F3581 /* TopSpotStory2024.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53C3E822CC73538004F3581 /* TopSpotStory2024.swift */; }; + F53EFEEA2CD42AE400F4561B /* Ratings2024Story.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53EFEE92CD42ADF00F4561B /* Ratings2024Story.swift */; }; F53EFEE82CD405DA00F4561B /* YearOverYearCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53EFEE72CD405DA00F4561B /* YearOverYearCompareTests.swift */; }; F543F6A42C0804FA00FEC8B6 /* AnyPublisher+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F543F6A32C0804FA00FEC8B6 /* AnyPublisher+Async.swift */; }; F54E0F542CC764C0006D4DA2 /* PositionModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F54E0F532CC764C0006D4DA2 /* PositionModifier.swift */; }; @@ -1714,6 +1715,7 @@ F5D3A0D92B70950100EED067 /* MockURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D3A0D82B70950100EED067 /* MockURLHandler.swift */; }; F5D527382CC81AAD00682CD5 /* EpilogueStory2024.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D527372CC81AA700682CD5 /* EpilogueStory2024.swift */; }; F5DBA58A2B756A8700AED77F /* PodcastSettings+ImportUserDefaultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DBA5892B756A8700AED77F /* PodcastSettings+ImportUserDefaultsTests.swift */; }; + F5E16FE72CD47DE800C0372F /* SFSafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E16FE62CD47DE800C0372F /* SFSafariView.swift */; }; F5E3DAA72BDD5567002BD4E4 /* PCBundleDoc.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E3DAA62BDD5567002BD4E4 /* PCBundleDoc.swift */; }; F5E431D62B50888500A71DB3 /* PlusLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E431D52B50888500A71DB3 /* PlusLabel.swift */; }; F5E949DA2B61762E002DAFC3 /* TokenHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E949D92B61762E002DAFC3 /* TokenHelperTests.swift */; }; @@ -3616,6 +3618,7 @@ F52B4F8D2BB4A9ED00E87BE4 /* CategoriesSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesSelectorView.swift; sourceTree = ""; }; F533F19C2C24B8CB00EDE9AA /* ShareDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareDestination.swift; sourceTree = ""; }; F53C3E822CC73538004F3581 /* TopSpotStory2024.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSpotStory2024.swift; sourceTree = ""; }; + F53EFEE92CD42ADF00F4561B /* Ratings2024Story.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ratings2024Story.swift; sourceTree = ""; }; F53EFEE72CD405DA00F4561B /* YearOverYearCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YearOverYearCompareTests.swift; sourceTree = ""; }; F543F6A32C0804FA00FEC8B6 /* AnyPublisher+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPublisher+Async.swift"; sourceTree = ""; }; F54E0F532CC764C0006D4DA2 /* PositionModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionModifier.swift; sourceTree = ""; }; @@ -3660,6 +3663,7 @@ F5D3A0D82B70950100EED067 /* MockURLHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockURLHandler.swift; sourceTree = ""; }; F5D527372CC81AA700682CD5 /* EpilogueStory2024.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpilogueStory2024.swift; sourceTree = ""; }; F5DBA5892B756A8700AED77F /* PodcastSettings+ImportUserDefaultsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PodcastSettings+ImportUserDefaultsTests.swift"; sourceTree = ""; }; + F5E16FE62CD47DE800C0372F /* SFSafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSafariView.swift; sourceTree = ""; }; F5E3DAA62BDD5567002BD4E4 /* PCBundleDoc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PCBundleDoc.swift; sourceTree = ""; }; F5E431D52B50888500A71DB3 /* PlusLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlusLabel.swift; sourceTree = ""; }; F5E949D92B61762E002DAFC3 /* TokenHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenHelperTests.swift; sourceTree = ""; }; @@ -6973,6 +6977,7 @@ C74887782919DC7B00EBC926 /* SwiftUI */ = { isa = PBXGroup; children = ( + F5E16FE62CD47DE800C0372F /* SFSafariView.swift */, F54E0F532CC764C0006D4DA2 /* PositionModifier.swift */, C7B3C60B2919DCC800054145 /* ActionView.swift */, C7A110F8291F66C700887A90 /* Confetti.swift */, @@ -7896,6 +7901,7 @@ F581ED412CC6F19300A19860 /* ListeningTime2024Story.swift */, F5D527372CC81AA700682CD5 /* EpilogueStory2024.swift */, F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */, + F53EFEE92CD42ADF00F4561B /* Ratings2024Story.swift */, F581ED432CC6F3A400A19860 /* Top5Podcasts2024Story.swift */, F5F884662CCA8F1F002BED2C /* CompletionRate2024Story.swift */, F52987E82CD2ED7000628016 /* YearOverYearCompare2024Story.swift */, @@ -9561,6 +9567,7 @@ F54E721D2CA7359800CD5C86 /* DiscoverCellType.swift in Sources */, F51D90742C71A0A100F0A232 /* SharingFooterView.swift in Sources */, BD240C43231F46F400FB2CDD /* PCSearchBarController+TextFieldDelegate.swift in Sources */, + F5E16FE72CD47DE800C0372F /* SFSafariView.swift in Sources */, 4053CDF9214F3583001C92B1 /* PodcastFilterOverlayController.swift in Sources */, BDF5FD1E1FD8D38900F2A339 /* DownloadsViewController+Table.swift in Sources */, C72CED32289DA1900017883A /* TracksAdapter.swift in Sources */, @@ -9575,6 +9582,7 @@ C713D4FA2A04C90500A78468 /* AccountHeaderViewModel.swift in Sources */, 10756F882C59449C0089D34F /* Folder+Sortable.swift in Sources */, F55C4C762BCF064500A10352 /* DiscoverViewController+CategoryRedesign.swift in Sources */, + F53EFEEA2CD42AE400F4561B /* Ratings2024Story.swift in Sources */, F5954D602C3F2864004A8C04 /* TrimHandle.swift in Sources */, BDF09F041E669E16009E9845 /* BasePlayPauseButton.swift in Sources */, 407B21E72231F6A300B4E492 /* UploadedSettingsViewController.swift in Sources */, diff --git a/podcasts/Analytics/AnalyticsEvent.swift b/podcasts/Analytics/AnalyticsEvent.swift index 63247f21e..cda3e2e56 100644 --- a/podcasts/Analytics/AnalyticsEvent.swift +++ b/podcasts/Analytics/AnalyticsEvent.swift @@ -637,6 +637,7 @@ enum AnalyticsEvent: String { case endOfYearStoryShared case endOfYearProfileCardTapped case endOfYearUpsellShown + case endOfYearLearnRatingsShown // MARK: - Welcome View diff --git a/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift b/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift index 97b11696f..833a9985f 100644 --- a/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift +++ b/podcasts/End of Year/End of Year 2023/EndOfYear2023StoriesModel.swift @@ -112,6 +112,10 @@ class EndOfYear2023StoriesModel: StoryModel { } } + func shouldLoadData(in dataManager: DataManager) -> Bool { + false // Default data load of episodes is enough and we don't need to load additional data + } + func isReady() -> Bool { if !stories.isEmpty { stories.append(.intro) diff --git a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift index 210dde67f..eda574059 100644 --- a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift +++ b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift @@ -35,6 +35,12 @@ class EndOfYear2024StoriesModel: StoryModel { data.listeningTime = listeningTime } + // Ratings + if let ratings = dataManager.summarizedRatings(in: Self.year) { + data.ratings = ratings + } + stories.append(.ratings) // Gets added regardless of the count since we have a fallback empty screen + // Longest episode if let longestEpisode = dataManager.longestEpisode(in: Self.year), let podcast = longestEpisode.parentPodcast() { @@ -75,6 +81,8 @@ class EndOfYear2024StoriesModel: StoryModel { return TopSpotStory2024(topPodcast: data.topPodcasts.first!) case .top5Podcasts: return Top5Podcasts2024Story(top5Podcasts: data.topPodcasts) + case .ratings: + return Ratings2024Story(ratings: data.ratings) case .listeningTime: return ListeningTime2024Story(listeningTime: data.listeningTime) case .longestEpisode: @@ -94,11 +102,19 @@ class EndOfYear2024StoriesModel: StoryModel { return true case .top5Podcasts: return true + case .ratings: + return true default: return false } } + func shouldLoadData(in dataManager: DataManager) -> Bool { + // Load data if our `ratings` property is empty + // Other data is handled in `EndOfYearStoriesBuilder` + dataManager.ratings.ratings == nil + } + func isReady() -> Bool { if stories.isEmpty { return false @@ -156,4 +172,6 @@ class EndOfYear2024StoriesData { var episodesStartedAndCompleted: EpisodesStartedAndCompleted! var yearOverYearListeningTime: YearOverYearListeningTime! + + var ratings: [UInt32: Int] = [:] } diff --git a/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift b/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift index f88e4313b..2afec7947 100644 --- a/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift +++ b/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift @@ -3,6 +3,7 @@ enum EndOfYear2024Story: CaseIterable { case numberOfPodcastsAndEpisodesListened case topSpot case top5Podcasts + case ratings case listeningTime case longestEpisode case yearOverYearListeningTime diff --git a/podcasts/End of Year/EndOfYearStoriesBuilder.swift b/podcasts/End of Year/EndOfYearStoriesBuilder.swift index 1a338f8ef..9047186dc 100644 --- a/podcasts/End of Year/EndOfYearStoriesBuilder.swift +++ b/podcasts/End of Year/EndOfYearStoriesBuilder.swift @@ -27,7 +27,9 @@ class EndOfYearStoriesBuilder { let modelType = type(of: model) // Check if the user has the full listening history for this year - if SyncManager.isUserLoggedIn(), !Settings.hasSyncedEpisodesForPlayback(year: modelType.year) || (Settings.hasSyncedEpisodesForPlayback(year: modelType.year) && Settings.hasSyncedEpisodesForPlaybackAsPlusUser(year: modelType.year) != hasActiveSubscription()) { + if SyncManager.isUserLoggedIn(), + !Settings.hasSyncedEpisodesForPlayback(year: modelType.year) || + (Settings.hasSyncedEpisodesForPlayback(year: modelType.year) && Settings.hasSyncedEpisodesForPlaybackAsPlusUser(year: modelType.year) != hasActiveSubscription()) || model.shouldLoadData(in: dataManager) { let syncedWithSuccess = sync?() if syncedWithSuccess == true { @@ -53,6 +55,7 @@ protocol StoryModel { func populate(with dataManager: DataManager) func story(for storyNumber: Int) -> any StoryView func isInteractiveView(for storyNumber: Int) -> Bool + func shouldLoadData(in dataManager: DataManager) -> Bool func isReady() -> Bool func paywallView() -> AnyView /// Overlaid on top of the story diff --git a/podcasts/End of Year/Stories/2024/Ratings2024Story.swift b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift new file mode 100644 index 000000000..930382883 --- /dev/null +++ b/podcasts/End of Year/Stories/2024/Ratings2024Story.swift @@ -0,0 +1,162 @@ +import SwiftUI + +struct Ratings2024Story: ShareableStory { + + let ratingScale = 1...5 + let ratings: [UInt32: Int] + + let foregroundColor: Color = .black + let backgroundColor: Color = Color(hex: "#EFECAD") + private let ratingsBlogPostURL = URL(string: "https://blog.pocketcasts.com/2024/08/20/podcast-ratings/")! + + @ObservedObject private var animationViewModel = PlayPauseAnimationViewModel(duration: 0.8, animation: Animation.spring(_:)) + @Environment(\.animated) var animated: Bool + @Environment(\.pauseState) var pauseState + + @State var scale: Double = 1 + @State var openURL = false + + var body: some View { + Group { + if ratings.count == 0 { + emptyView() + } else { + VStack(alignment: .leading) { + Spacer() + chartView() + .modifier(animationViewModel.animate($scale, to: 1)) + .padding() + .padding(.vertical, 32) + Spacer() + footerView() + } + } + } + .padding(.top, 24) + .onAppear { + if animated { + setInitialCoverOffsetForAnimation() + animationViewModel.play() + } + } + .foregroundStyle(foregroundColor) + .background( + backgroundColor + .ignoresSafeArea() + .allowsHitTesting(false) + ) + } + + private func setInitialCoverOffsetForAnimation() { + scale = 0 + } + + @ViewBuilder func emptyView() -> some View { + let words = ["OOOOPSIES"] + let separator = Image("star") + VStack { + Spacer() + VStack { + MarqueeTextView(words: words, separator: separator, direction: .leading) + MarqueeTextView(words: words, separator: separator, direction: .trailing) + } + .frame(height: 400) + Spacer() + emptyFooterView() + } + } + + @ViewBuilder func emptyFooterView() -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.playback2024RatingsEmptyTitle) + .font(.system(size: 31, weight: .bold)) + Text(L10n.playback2024RatingsEmptyDescription) + .font(.system(size: 15, weight: .light)) + Button(L10n.learnAboutRatings) { + pauseState.togglePause() + openURL = true + Analytics.track(.endOfYearLearnRatingsShown) + } + .buttonStyle(BasicButtonStyle(textColor: .black, backgroundColor: Color.clear, borderColor: .black)) + .allowsHitTesting(true) + } + .minimumScaleFactor(0.5) + .sheet(isPresented: $openURL, onDismiss: { + pauseState.togglePause() + openURL = false + }, content: { + SFSafariView(url: ratingsBlogPostURL) + }) + .padding(.horizontal, 24) + .padding(.vertical, 6) + } + + @ViewBuilder func chartView() -> some View { + GeometryReader { geometry in + HStack(alignment: .bottom) { + let maxRating = ratings.values.max() ?? 0 + ForEach(ratingScale, id: \.self) { ratingGroup in + let count = ratings[UInt32(ratingGroup)] ?? 0 + VStack { + Text("\(ratingGroup)") + .font(.system(size: 22, weight: .semibold)) + .opacity(scale) + .offset(x: 0, y: 10 * (1 - scale)) + DashedRectangle() + .frame(height: max(geometry.size.height * (CGFloat(count) / CGFloat(maxRating)), 5)) + .scaleEffect(x: 1, y: scale, anchor: .bottom) + } + } + } + } + } + + private func descriptionText() -> String { + let maxRatingCategory = ratings.max(by: { $0.value < $1.value })?.key ?? 0 + switch maxRatingCategory { + case 1...3: + return L10n.playback2024RatingsDescription1To3 + case 4...5: + return L10n.playback2024RatingsDescription4To5(maxRatingCategory) + default: + return "" + } + + } + + @ViewBuilder func footerView() -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.playback2024RatingsTitle) + .font(.system(size: 31, weight: .bold)) + Text(descriptionText()) + .font(.system(size: 15, weight: .light)) + } + .padding(.horizontal, 24) + .padding(.bottom, 12) + } + + func sharingAssets() -> [Any] { + [ + StoryShareableProvider.new(AnyView(self)), + StoryShareableText(L10n.eoyYearCompletionRateShareText) + ] + } + + func hideShareButton() -> Bool { + ratings.count == 0 + } +} + +struct DashedRectangle: View { + var body: some View { + GeometryReader { geometry in + VStack(spacing: 4) { + ForEach(0.. + + diff --git a/podcasts/Strings+Generated.swift b/podcasts/Strings+Generated.swift index c0b7a3bd5..e15cabb23 100644 --- a/podcasts/Strings+Generated.swift +++ b/podcasts/Strings+Generated.swift @@ -1475,6 +1475,8 @@ internal enum L10n { internal static var kidsProfileThankyouText: String { return L10n.tr("Localizable", "kids_profile_thankyou_text") } /// Thank you for your interest internal static var kidsProfileThankyouTitle: String { return L10n.tr("Localizable", "kids_profile_thankyou_title") } + /// Learn about ratings + internal static var learnAboutRatings: String { return L10n.tr("Localizable", "learn_about_ratings") } /// Learn More internal static var learnMore: String { return L10n.tr("Localizable", "learn_more") } /// Listening History @@ -1790,6 +1792,18 @@ internal enum L10n { internal static var playback2024PlusUpsellDescription: String { return L10n.tr("Localizable", "playback_2024_plus_upsell_description") } /// There's more! internal static var playback2024PlusUpsellTitle: String { return L10n.tr("Localizable", "playback_2024_plus_upsell_title") } + /// Thanks for sharing your feedback with the creator community + internal static var playback2024RatingsDescription1To3: String { return L10n.tr("Localizable", "playback_2024_ratings_description_1_to_3") } + /// Wow, so many %1$@ star ratings! Thanks for sharing the love with your favorite creators. + internal static func playback2024RatingsDescription4To5(_ p1: Any) -> String { + return L10n.tr("Localizable", "playback_2024_ratings_description_4_to_5", String(describing: p1)) + } + /// Did you know that you can rate shows now? Share the love for your favorite creators and help them get noticed! + internal static var playback2024RatingsEmptyDescription: String { return L10n.tr("Localizable", "playback_2024_ratings_empty_description") } + /// Oh-oh! No podcast ratings to show you yet + internal static var playback2024RatingsEmptyTitle: String { return L10n.tr("Localizable", "playback_2024_ratings_empty_title") } + /// Let’s see your ratings! + internal static var playback2024RatingsTitle: String { return L10n.tr("Localizable", "playback_2024_ratings_title") } /// You listened to %1$@ episodes for a total of %2$@ of "%3$@" internal static func playback2024TopSpotDescription(_ p1: Any, _ p2: Any, _ p3: Any) -> String { return L10n.tr("Localizable", "playback_2024_top_spot_description", String(describing: p1), String(describing: p2), String(describing: p3)) diff --git a/podcasts/SwiftUI/SFSafariView.swift b/podcasts/SwiftUI/SFSafariView.swift new file mode 100644 index 000000000..a17a43dc4 --- /dev/null +++ b/podcasts/SwiftUI/SFSafariView.swift @@ -0,0 +1,14 @@ +import SafariServices +import SwiftUI + +struct SFSafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { + return SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) { + // No need to do anything here + } +} diff --git a/podcasts/en.lproj/Localizable.strings b/podcasts/en.lproj/Localizable.strings index f094c8c4e..ab872bd2b 100644 --- a/podcasts/en.lproj/Localizable.strings +++ b/podcasts/en.lproj/Localizable.strings @@ -3602,6 +3602,15 @@ /*A title used for the Playback 2024 Top Podcast screen */ "playback_2024_top_spot_title" = "This was your top podcast in 2024"; +/* A title shown on the Ratings Playback 2024 screen showing a bar chart of your ratings */ +"playback_2024_ratings_title" = "Let’s see your ratings!"; + +/* A description shown in Playback 2024 when the user has made ratings of 4-5/5 for Podcasts */ +"playback_2024_ratings_description_4_to_5" = "Wow, so many %1$@ star ratings! Thanks for sharing the love with your favorite creators."; + +/* A description shown in Playback 2024 when the user has only made ratings of 1-3/5 for Podcasts */ +"playback_2024_ratings_description_1_to_3" = "Thanks for sharing your feedback with the creator community"; + "playback_2024_top_spot_description" = "You listened to %1$@ episodes for a total of %2$@ of \"%3$@\""; /* A title shown on the Year over Year Comparison screen for a small decrease in listening time from 2023 in Playback 2024 */ @@ -3634,6 +3643,12 @@ /* A title shown on the Year over Year Comparison screen for listening time which stayed the same from 2023 in Playback 2024 */ "playback_2024_year_over_year_compare_description_up" = "Ready to top it in 2025?"; +/* A title shown in Playback 2024 when the user has not made any ratings. */ +"playback_2024_ratings_empty_title" = "Oh-oh! No podcast ratings to show you yet"; + +/* A description shown in Playback 2024 to describe the new Podcast Ratings feature */ +"playback_2024_ratings_empty_description" = "Did you know that you can rate shows now? Share the love for your favorite creators and help them get noticed!"; + /* Label of the End of Year dismiss button */ "eoy_not_now" = "Not Now"; @@ -4603,4 +4618,5 @@ /* Toast message when episode download is cancelled*/ "player_episode_download_cancelled" = "Episode download cancelled"; - +/* A title shown on a button to open information about the Podcast Ratings feature */ +"learn_about_ratings" = "Learn about ratings";