diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj index f71b12d93a..426459af67 100644 --- a/podcasts.xcodeproj/project.pbxproj +++ b/podcasts.xcodeproj/project.pbxproj @@ -1721,6 +1721,7 @@ F5F6DA702BBE1109009B1934 /* CategoryButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F6DA6F2BBE1109009B1934 /* CategoryButtonStyle.swift */; }; F5F6DA822BC0B512009B1934 /* CategoriesModalPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F6DA812BC0B512009B1934 /* CategoriesModalPicker.swift */; }; F5F884632CC9EAA6002BED2C /* Humane-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = F5F884622CC9EAA6002BED2C /* Humane-Bold.otf */; }; + F5F884652CCA86AE002BED2C /* LongestEpisode2024Story.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */; }; F5F89B1E2C88B40A00013118 /* ShareButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F89B1D2C88B40A00013118 /* ShareButton.swift */; }; F5F8F3182CC314250071DD0E /* IntroStory2024.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F8F3172CC314250071DD0E /* IntroStory2024.swift */; }; F5FE747B2C223A6100DF2EAA /* ShareImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5FF61222BD07E3A00190711 /* ShareImageView.swift */; }; @@ -3659,6 +3660,7 @@ F5F6DA6F2BBE1109009B1934 /* CategoryButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryButtonStyle.swift; sourceTree = ""; }; F5F6DA812BC0B512009B1934 /* CategoriesModalPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesModalPicker.swift; sourceTree = ""; }; F5F884622CC9EAA6002BED2C /* Humane-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Humane-Bold.otf"; sourceTree = ""; }; + F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongestEpisode2024Story.swift; sourceTree = ""; }; F5F89B1D2C88B40A00013118 /* ShareButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareButton.swift; sourceTree = ""; }; F5F8F3172CC314250071DD0E /* IntroStory2024.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroStory2024.swift; sourceTree = ""; }; F5FF611F2BD076BA00190711 /* Sharing.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Sharing.xcassets; sourceTree = ""; }; @@ -7874,6 +7876,7 @@ F53C3E822CC73538004F3581 /* TopSpotStory2024.swift */, F581ED412CC6F19300A19860 /* ListeningTime2024Story.swift */, F5D527372CC81AA700682CD5 /* EpilogueStory2024.swift */, + F5F884642CCA86AA002BED2C /* LongestEpisode2024Story.swift */, F581ED432CC6F3A400A19860 /* Top5Podcasts2024Story.swift */, ); path = 2024; @@ -9491,6 +9494,7 @@ C7FAFF5D2941844C00329B40 /* CancelConfirmationViewModel.swift in Sources */, BD14CCDF1D7D3CB800DB4547 /* SelectedPodcastCell.swift in Sources */, BD93FDA120157B2000F6EF55 /* PodcastImageView.swift in Sources */, + F5F884652CCA86AE002BED2C /* LongestEpisode2024Story.swift in Sources */, BDD5253A20477E4400AAD211 /* NSObject+AppDelegate.swift in Sources */, 8B14E3B029B9159B0069B6F2 /* SearchHistoryModel.swift in Sources */, C7080C5D2923070200D7A432 /* PlusAccountUpgradePrompt.swift in Sources */, 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 e8d04d3891..144bda43a5 100644 --- a/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift +++ b/podcasts/End of Year/End of Year 2024/EndOfYear2024StoriesModel.swift @@ -24,6 +24,13 @@ class EndOfYear2024StoriesModel: StoryModel { data.listeningTime = listeningTime } + // Longest episode + if let longestEpisode = dataManager.longestEpisode(in: Self.year), + let podcast = longestEpisode.parentPodcast() { + data.longestEpisode = longestEpisode + data.longestEpisodePodcast = podcast + stories.append(.longestEpisode) + } } func story(for storyNumber: Int) -> any StoryView { @@ -34,6 +41,8 @@ class EndOfYear2024StoriesModel: StoryModel { return Top5Podcasts2024Story(top5Podcasts: data.topPodcasts) case .listeningTime: return ListeningTime2024Story(listeningTime: data.listeningTime) + case .longestEpisode: + return LongestEpisode2024Story(episode: data.longestEpisode, podcast: data.longestEpisodePodcast) case .epilogue: return EpilogueStory2024() } @@ -91,4 +100,9 @@ class EndOfYear2024StoriesData { var topPodcasts: [TopPodcast] = [] var listeningTime: Double = 0 + + var longestEpisode: Episode! + + var longestEpisodePodcast: Podcast! + } 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 e226b896b7..2af5afa66c 100644 --- a/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift +++ b/podcasts/End of Year/End of Year 2024/EndOfYear2024Story.swift @@ -2,5 +2,6 @@ enum EndOfYear2024Story: CaseIterable { case intro case top5Podcasts case listeningTime + case longestEpisode case epilogue } diff --git a/podcasts/End of Year/Stories/2024/LongestEpisode2024Story.swift b/podcasts/End of Year/Stories/2024/LongestEpisode2024Story.swift new file mode 100644 index 0000000000..967b4fb792 --- /dev/null +++ b/podcasts/End of Year/Stories/2024/LongestEpisode2024Story.swift @@ -0,0 +1,132 @@ +import SwiftUI +import PocketCastsDataModel + +struct LongestEpisode2024Story: ShareableStory { + @Environment(\.renderForSharing) var renderForSharing: Bool + @Environment(\.animated) var animated: Bool + + @ObservedObject private var animationViewModel = PlayPauseAnimationViewModel(duration: 0.8, animation: Animation.spring(_:)) + + var identifier: String = "longest_episode" + + let episode: Episode + + let podcast: Podcast + + @State var firstCover: Double = 0.4 + @State var secondCover: Double = 0.32 + @State var thirdCover: Double = 0.24 + @State var fourthCover: Double = 0.16 + @State var fifthCover: Double = 0.08 + @State var sixthCover: Double = 0 + + private let backgroundColor = Color(hex: "#E0EFAD") + private let foregroundColor = Color.black + + var body: some View { + GeometryReader { geometry in + let isSmallScreen = geometry.size.height <= 600 + VStack(alignment: .leading) { + Spacer() + ZStack { + covers() + let stickerSize = CGSize(width: 194, height: 135) + Image("playback-sticker-phew") + .resizable() + .frame(width: stickerSize.width, height: stickerSize.height) + .position(x: -6, y: 0, for: stickerSize, in: geometry.frame(in: .global), corner: .topTrailing) + } + .frame(width: geometry.size.width * 0.9) + .padding(.top, isSmallScreen ? 0 : 20) + VStack(alignment: .leading, spacing: isSmallScreen ? 4 : 16) { + let timeString = episode.playedUpTo.storyTimeDescriptionForSharing + Text(L10n.playback2024LongestEpisodeTitle(timeString)) + .font(.system(size: 31, weight: .bold)) + Text(L10n.playback2024LongestEpisodeDescription(episode.title ?? "unknown", podcast.title ?? "unknown")) + .font(.system(size: 15, weight: .light)) + } + .padding(.horizontal, 24) + .padding(.bottom, isSmallScreen ? 4 : 16) + } + } + .background(backgroundColor) + .foregroundStyle(foregroundColor) + .onAppear { + if animated { + setInitialCoverOffsetForAnimation() + animationViewModel.play() + } + } + } + + @ViewBuilder func covers() -> some View { + GeometryReader { geometry in + PodcastCoverContainer(geometry: geometry) { + ZStack { + PodcastCover(podcastUuid: podcast.uuid) + .frame(width: geometry.size.width * 0.5, height: geometry.size.width * 0.5) + .offset(x: -geometry.size.width * firstCover, y: geometry.size.width * firstCover) + .modifier(animationViewModel.animate($firstCover, to: 0.4)) + + PodcastCover(podcastUuid: podcast.uuid) + .frame(width: geometry.size.width * 0.55, height: geometry.size.width * 0.55) + .offset(x: -geometry.size.width * secondCover, y: geometry.size.width * secondCover) + .modifier(animationViewModel.animate($secondCover, to: 0.32)) + + PodcastCover(podcastUuid: podcast.uuid) + .frame(width: geometry.size.width * 0.6, height: geometry.size.width * 0.6) + .offset(x: -geometry.size.width * thirdCover, y: geometry.size.width * thirdCover) + .modifier(animationViewModel.animate($thirdCover, to: 0.24)) + + PodcastCover(podcastUuid: podcast.uuid) + .frame(width: geometry.size.width * 0.65, height: geometry.size.width * 0.65) + .offset(x: -geometry.size.width * fourthCover, y: geometry.size.width * fourthCover) + .modifier(animationViewModel.animate($fourthCover, to: 0.16)) + + PodcastCover(podcastUuid: podcast.uuid) + .frame(width: geometry.size.width * 0.7, height: geometry.size.width * 0.7) + .offset(x: -geometry.size.width * fifthCover, y: geometry.size.width * fifthCover) + .modifier(animationViewModel.animate($fifthCover, to: 0.08)) + + PodcastCover(podcastUuid: podcast.uuid, higherQuality: true) + .frame(width: geometry.size.width * 0.75, height: geometry.size.width * 0.75) + .offset(x: -geometry.size.width * sixthCover, y: geometry.size.width * sixthCover) + .modifier(animationViewModel.animate($sixthCover, to: 0)) + } + .offset(x: geometry.size.width * 0.04, y: geometry.size.height * 0.09) + } + } + } + + func onAppear() { + Analytics.track(.endOfYearStoryShown, story: identifier) + } + + func onPause() { + animationViewModel.pause() + } + + func onResume() { + animationViewModel.play() + } + + func willShare() { + Analytics.track(.endOfYearStoryShare, story: identifier) + } + + func sharingAssets() -> [Any] { + [ + StoryShareableProvider.new(AnyView(self)), + StoryShareableText(L10n.eoyStoryLongestEpisodeShareText("%1$@"), episode: episode) + ] + } + + private func setInitialCoverOffsetForAnimation() { + firstCover = 0.8 + secondCover = 0.8 + thirdCover = 0.8 + fourthCover = 0.8 + fifthCover = 0.8 + sixthCover = 0.8 + } +} diff --git a/podcasts/End of Year/Stories/Views/PodcastCover.swift b/podcasts/End of Year/Stories/Views/PodcastCover.swift index c32aa401bd..7cd020e3cc 100644 --- a/podcasts/End of Year/Stories/Views/PodcastCover.swift +++ b/podcasts/End of Year/Stories/Views/PodcastCover.swift @@ -16,7 +16,6 @@ struct PodcastCover: View { /// If the artwork needs a bigger image with higher quality var higherQuality: Bool = false - @State private var image: UIImage? @Environment(\.renderForSharing) var renderForSharing: Bool private var rectangleColor: Color? { @@ -40,36 +39,10 @@ struct PodcastCover: View { .modifier(NormalCoverShadow()) } } - .opacity(image != nil ? 1 : 0.2) .blendMode(.multiply) - ImageView(image: image) + PodcastImage(uuid: podcastUuid, size: .page) .cornerRadius(big ? 8 : 4) - - .onAppear { - if renderForSharing { - loadImage() - } - } - - Action { - if !renderForSharing { - loadImage() - } - } - } - } - - private func loadImage() { - image = nil - let size = higherQuality ? 680 : 280 - KingfisherManager.shared.retrieveImage(with: ServerHelper.imageUrl(podcastUuid: podcastUuid, size: size)) { result in - switch result { - case .success(let result): - image = result.image - default: - break - } } } } diff --git a/podcasts/End of Year/Views/StoriesView.swift b/podcasts/End of Year/Views/StoriesView.swift index a74ec8c00e..792547e260 100644 --- a/podcasts/End of Year/Views/StoriesView.swift +++ b/podcasts/End of Year/Views/StoriesView.swift @@ -41,7 +41,11 @@ struct StoriesView: View { // Manually set the zIndex order to ensure we can change the order when needed model.story(index: model.currentStoryIndex) .zIndex(3) - .ignoresSafeArea(edges: .bottom) + .modify { + if model.overlaidShareView() != nil { + $0.ignoresSafeArea(edges: .bottom) + } + } .environment(\.animated, true) if model.shouldShowUpsell() { diff --git a/podcasts/EndOfYear.xcassets/playback-sticker-phew.imageset/Contents.json b/podcasts/EndOfYear.xcassets/playback-sticker-phew.imageset/Contents.json new file mode 100644 index 0000000000..3373da3593 --- /dev/null +++ b/podcasts/EndOfYear.xcassets/playback-sticker-phew.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "playback-sticker-phew.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/podcasts/EndOfYear.xcassets/playback-sticker-phew.imageset/playback-sticker-phew.svg b/podcasts/EndOfYear.xcassets/playback-sticker-phew.imageset/playback-sticker-phew.svg new file mode 100644 index 0000000000..91855e6115 --- /dev/null +++ b/podcasts/EndOfYear.xcassets/playback-sticker-phew.imageset/playback-sticker-phew.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/podcasts/Strings+Generated.swift b/podcasts/Strings+Generated.swift index df1b7d265e..b00e827dbb 100644 --- a/podcasts/Strings+Generated.swift +++ b/podcasts/Strings+Generated.swift @@ -1754,6 +1754,14 @@ internal enum L10n { internal static func playback2024ListeningTimeDescription(_ p1: Any) -> String { return L10n.tr("Localizable", "playback_2024_listening_time_description", String(describing: p1)) } + /// It was "%1$@" from "%2$@" + internal static func playback2024LongestEpisodeDescription(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "playback_2024_longest_episode_description", String(describing: p1), String(describing: p2)) + } + /// The longest episode you listened to was %1$@ + internal static func playback2024LongestEpisodeTitle(_ p1: Any) -> String { + return L10n.tr("Localizable", "playback_2024_longest_episode_title", String(describing: p1)) + } /// View My Playback 2024 internal static var playback2024ViewYear: String { return L10n.tr("Localizable", "playback_2024_view_year") } /// All podcasts diff --git a/podcasts/en.lproj/Localizable.strings b/podcasts/en.lproj/Localizable.strings index 56ac896369..d751029df8 100644 --- a/podcasts/en.lproj/Localizable.strings +++ b/podcasts/en.lproj/Localizable.strings @@ -3557,6 +3557,12 @@ /* A description used to indicate the number of days and hours spent listening to podcasts in the last year */ "playback_2024_listening_time_description" = "%1$@ total listening to podcasts"; +/* A title shown with the amount of time listened on the Longest Episode screen of Playback 2024 */ +"playback_2024_longest_episode_title" = "The longest episode you listened to was %1$@"; + +/* A description shown on the Longest Episode screen of Playback 2024 with the episode title and podcast title */ +"playback_2024_longest_episode_description" = "It was \"%1$@\" from \"%2$@\""; + /* Label of the End of Year dismiss button */ "eoy_not_now" = "Not Now";