Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

End of Year: generate image and share it through Share Sheet #404

Merged
merged 11 commits into from
Oct 19, 2022
14 changes: 9 additions & 5 deletions PocketCastsTests/Tests/End of Year/StoriesModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@ class MockStoriesDataSource: StoriesDataSource {

var didCallStoryForWithStoryNumber: Int?

func story(for storyNumber: Int) -> any View {
func story(for storyNumber: Int) -> any StoryView {
didCallStoryForWithStoryNumber = storyNumber

switch storyNumber {
case 0:
return FakeStory()
return MockedStory()
default:
return FakeStoryTwo()
return MockedStoryTwo()
}
}

Expand All @@ -82,15 +82,19 @@ class MockStoriesDataSource: StoriesDataSource {
}
}

struct MockedStory: View {
struct MockedStory: StoryView {
var duration: TimeInterval = 5 * 60

var body: some View {
ZStack {
Color.purple
}
}
}

struct MockedStoryTwo: View {
struct MockedStoryTwo: StoryView {
var duration: TimeInterval = 5 * 60

var body: some View {
ZStack {
Color.yellow
Expand Down
12 changes: 12 additions & 0 deletions podcasts.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@
8B10E78828D9094500702C54 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8B10E78528D908A900702C54 /* GoogleService-Info.plist */; };
8B10E78928D9094900702C54 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8B10E78528D908A900702C54 /* GoogleService-Info.plist */; };
8B10E78A28D9094A00702C54 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8B10E78528D908A900702C54 /* GoogleService-Info.plist */; };
8B1C974628FE1C7E00BD5EB9 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1C974528FE1C7E00BD5EB9 /* ImageView.swift */; };
8B1C974828FE234B00BD5EB9 /* View+Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1C974728FE234B00BD5EB9 /* View+Snapshot.swift */; };
8B2E055028F8579700C2DBDE /* StoriesModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B2E054F28F8579700C2DBDE /* StoriesModelTests.swift */; };
8B2E055228F88D7300C2DBDE /* EndOfYearPromptCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B2E055128F88D7300C2DBDE /* EndOfYearPromptCell.swift */; };
8B2E055428F891F900C2DBDE /* EndOfYearCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B2E055328F891F900C2DBDE /* EndOfYearCard.swift */; };
Expand Down Expand Up @@ -480,6 +482,7 @@
8BA55A1328CA7425002BECC5 /* XCTestCase+eventually.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA55A1228CA7425002BECC5 /* XCTestCase+eventually.swift */; };
8BA55A1528CA8FEB002BECC5 /* PrivacySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA55A1428CA8FEB002BECC5 /* PrivacySettingsViewController.swift */; };
8BA55A1728CA92A7002BECC5 /* PrivacySettingsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BA55A1628CA92A7002BECC5 /* PrivacySettingsViewController.xib */; };
8BB55E3A28FEEE99001D1766 /* StoryShareableProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB55E3928FEEE99001D1766 /* StoryShareableProvider.swift */; };
8BCB22B228F47F44001A0315 /* EndOfYearModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCB22B128F47F44001A0315 /* EndOfYearModal.swift */; };
8BCB22B428F48282001A0315 /* EndOfYear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCB22B328F48282001A0315 /* EndOfYear.swift */; };
8BDEBC0F28F6F82A00A1B03C /* [email protected] in Resources */ = {isa = PBXBuildFile; fileRef = 8BDEBC0E28F6F82A00A1B03C /* [email protected] */; };
Expand Down Expand Up @@ -1974,6 +1977,8 @@
7E84A6B7056C4797BAD62A1F /* Pods-Pocket Casts Watch App Extension.staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Pocket Casts Watch App Extension.staging.xcconfig"; path = "Pods/Target Support Files/Pods-Pocket Casts Watch App Extension/Pods-Pocket Casts Watch App Extension.staging.xcconfig"; sourceTree = "<group>"; };
8229174E612BC7BAC6F63274 /* Pods-Pocket Casts Watch App Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Pocket Casts Watch App Extension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Pocket Casts Watch App Extension/Pods-Pocket Casts Watch App Extension.debug.xcconfig"; sourceTree = "<group>"; };
8B10E78528D908A900702C54 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
8B1C974528FE1C7E00BD5EB9 /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
8B1C974728FE234B00BD5EB9 /* View+Snapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Snapshot.swift"; sourceTree = "<group>"; };
8B2E054F28F8579700C2DBDE /* StoriesModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoriesModelTests.swift; sourceTree = "<group>"; };
8B2E055128F88D7300C2DBDE /* EndOfYearPromptCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndOfYearPromptCell.swift; sourceTree = "<group>"; };
8B2E055328F891F900C2DBDE /* EndOfYearCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndOfYearCard.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2007,6 +2012,7 @@
8BA55A1228CA7425002BECC5 /* XCTestCase+eventually.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+eventually.swift"; sourceTree = "<group>"; };
8BA55A1428CA8FEB002BECC5 /* PrivacySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewController.swift; sourceTree = "<group>"; };
8BA55A1628CA92A7002BECC5 /* PrivacySettingsViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = PrivacySettingsViewController.xib; sourceTree = "<group>"; };
8BB55E3928FEEE99001D1766 /* StoryShareableProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryShareableProvider.swift; sourceTree = "<group>"; };
8BCB22B128F47F44001A0315 /* EndOfYearModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndOfYearModal.swift; sourceTree = "<group>"; };
8BCB22B328F48282001A0315 /* EndOfYear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndOfYear.swift; sourceTree = "<group>"; };
8BDEBC0E28F6F82A00A1B03C /* [email protected] */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "[email protected]"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3686,6 +3692,7 @@
8BCB22B328F48282001A0315 /* EndOfYear.swift */,
8B6B68E428F744520032BFFF /* StoriesDataSource.swift */,
8B6B68E628F74A010032BFFF /* StoriesModel.swift */,
8BB55E3928FEEE99001D1766 /* StoryShareableProvider.swift */,
);
path = "End of Year";
sourceTree = "<group>";
Expand All @@ -3696,8 +3703,10 @@
8BCB22B128F47F44001A0315 /* EndOfYearModal.swift */,
8B2E055128F88D7300C2DBDE /* EndOfYearPromptCell.swift */,
8B2E055328F891F900C2DBDE /* EndOfYearCard.swift */,
8B1C974528FE1C7E00BD5EB9 /* ImageView.swift */,
8B738F3028F5CBE0004E7526 /* StoriesView.swift */,
8B6B68E828F7527C0032BFFF /* StoryIndicator.swift */,
8B1C974728FE234B00BD5EB9 /* View+Snapshot.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -7154,6 +7163,7 @@
BDA0E29F22DDB2DC0029EBEB /* ThemeableLabel.swift in Sources */,
BD3FD738202A85E900A7F609 /* ListeningHistoryViewController+Table.swift in Sources */,
BD2B50AD201076310099B79D /* PodcastSettingsViewController.swift in Sources */,
8BB55E3A28FEEE99001D1766 /* StoryShareableProvider.swift in Sources */,
408426322134CBE60076D82E /* SmallPagedListSummaryViewController.swift in Sources */,
BD4098801B9EFE3C007F36BD /* AudioPlayTask.swift in Sources */,
40AA12732488AE1F006B9D48 /* MultiSelectFooterView.swift in Sources */,
Expand Down Expand Up @@ -7466,6 +7476,7 @@
BDED92FD251DC43C000BF622 /* CarPlaySceneDelegate+Tabs.swift in Sources */,
408F0B30227927A20019584D /* NewSubscription.swift in Sources */,
BDDC1C652394B7F800F2D723 /* UpNextViewController+Swipe.swift in Sources */,
8B1C974828FE234B00BD5EB9 /* View+Snapshot.swift in Sources */,
BD01397F2022DBF8001EAF84 /* PodcastViewController+Search.swift in Sources */,
BD14BD53217074D800998296 /* UnplayedBadge.swift in Sources */,
C7C4CAEE28AB0BF200CFC8CF /* TracksSubscriptionData.swift in Sources */,
Expand Down Expand Up @@ -7654,6 +7665,7 @@
BDA0E2A322DDB5550029EBEB /* ThemeColor.swift in Sources */,
8BCB22B428F48282001A0315 /* EndOfYear.swift in Sources */,
BDCC55141CD0A448006BE239 /* AEXML.swift in Sources */,
8B1C974628FE1C7E00BD5EB9 /* ImageView.swift in Sources */,
40484F0122F1507F007DBD55 /* ThemeableRoundedButton.swift in Sources */,
BDEBD3A91BA0108800AD038F /* BufferedAudio.swift in Sources */,
402772D721B6105D00769811 /* PodcastManager+Delete.swift in Sources */,
Expand Down
21 changes: 21 additions & 0 deletions podcasts/End of Year/EndOfYear.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import SwiftUI
import MaterialComponents.MaterialBottomSheet

struct EndOfYear {
static var finishedImage: UIImage?

func showPrompt(in viewController: UIViewController) {
guard FeatureFlag.endOfYear else {
return
Expand All @@ -22,6 +24,25 @@ struct EndOfYear {
storiesViewController.modalPresentationStyle = .fullScreen
viewController.present(storiesViewController, animated: true, completion: nil)
}

func share(asset: @escaping () -> Any, onDismiss: (() -> Void)? = nil) {
let presenter = SceneHelper.rootViewController()?.presentedViewController

let imageToShare = [StoryShareableProvider()]
let activityViewController = UIActivityViewController(activityItems: imageToShare, applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = presenter?.view

activityViewController.completionWithItemsHandler = { _, _, _, _ in
onDismiss?()
}

presenter?.present(activityViewController, animated: true) {
// After the share sheet is presented we take the snapshot
// This action needs to happen on the main thread because
// the view needs to be rendered.
StoryShareableProvider.generatedItem = asset() as? UIImage
}
}
}

class StoriesHostingController<ContentView: View>: UIHostingController<ContentView> {
Expand Down
8 changes: 4 additions & 4 deletions podcasts/End of Year/Stories/DummyStory.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import SwiftUI
import PocketCastsServer
import PocketCastsDataModel
import Kingfisher

struct DummyStory: View {
struct DummyStory: StoryView {
let podcasts: [Podcast]

let duration: TimeInterval = 5.seconds

var backgroundColor: Color {
Color(podcasts.first?.bgColor() ?? UIColor.black)
}
Expand All @@ -29,8 +30,7 @@ struct DummyStory: View {
Text("\(x + 1).")
.font(.system(size: 32, weight: .bold))
.foregroundColor(tintColor)
KFImage(ServerHelper.imageUrl(podcastUuid: podcasts[x].uuid, size: 280))
.resizable()
ImageView(ServerHelper.imageUrl(podcastUuid: podcasts[x].uuid, size: 280))
.frame(width: 76, height: 76)
.aspectRatio(1, contentMode: .fit)
.cornerRadius(4)
Expand Down
17 changes: 5 additions & 12 deletions podcasts/End of Year/Stories/EndOfYearStoriesDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ struct EndOfYearStoriesDataSource: StoriesDataSource {

let randomPodcasts = DataManager.sharedManager.randomPodcasts()

@ViewBuilder
func story(for storyNumber: Int) -> any View {
func story(for storyNumber: Int) -> any StoryView {
switch storyNumber {
case 0:
DummyStory(podcasts: randomPodcasts)
return DummyStory(podcasts: randomPodcasts)
default:
FakeStoryTwo()
return FakeStory()
}
}

Expand All @@ -21,15 +20,9 @@ struct EndOfYearStoriesDataSource: StoriesDataSource {
}
}

struct FakeStory: View {
var body: some View {
ZStack {
Color.purple
}
}
}
struct FakeStory: StoryView {
var duration: TimeInterval = 5.seconds

struct FakeStoryTwo: View {
var body: some View {
ZStack {
Color.yellow
Expand Down
17 changes: 16 additions & 1 deletion podcasts/End of Year/StoriesDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import SwiftUI
protocol StoriesDataSource {
var numberOfStories: Int { get }

func story(for: Int) -> any View
func story(for: Int) -> any StoryView
func storyView(for: Int) -> AnyView

/// Whether the data source is ready to be used.
Expand All @@ -18,4 +18,19 @@ extension StoriesDataSource {
func storyView(for storyNumber: Int) -> AnyView {
return AnyView(story(for: storyNumber))
}

func shareableAsset(for storyNumber: Int) -> Any {
VStack {
storyView(for: storyNumber)
}
.frame(width: 540, height: 960)
.snapshot()
}
}

typealias StoryView = Story & View

protocol Story {
/// The amount of time this story should be show
var duration: TimeInterval { get }
}
8 changes: 7 additions & 1 deletion podcasts/End of Year/StoriesModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ class StoriesModel: ObservableObject {
private let dataSource: StoriesDataSource
private let publisher: Timer.TimerPublisher
private var cancellable: Cancellable?
private var interval: TimeInterval = 5.seconds
private var interval: TimeInterval {
dataSource.story(for: currentStory).duration
}

var numberOfStories: Int {
dataSource.numberOfStories
Expand Down Expand Up @@ -48,6 +50,10 @@ class StoriesModel: ObservableObject {
dataSource.storyView(for: index)
}

func shareableAsset(index: Int) -> Any {
dataSource.shareableAsset(for: index)
}

func next() {
progress = Double(Int(progress) + 1)
}
Expand Down
21 changes: 21 additions & 0 deletions podcasts/End of Year/StoryShareableProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import UIKit

/// An Activity Provider used for the share sheet
///
/// Given stories assets are generated in the main thread
/// and when the user taps "Share" we use this provider to
/// avoid blocking the main thread and the share sheet
/// having a delay when appearing.
class StoryShareableProvider: UIActivityItemProvider {
static var generatedItem: Any?

init() {
super.init(placeholderItem: UIImage())
}

override var item: Any {
get {
Self.generatedItem ?? UIImage()
}
}
}
30 changes: 30 additions & 0 deletions podcasts/End of Year/Views/ImageView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import SwiftUI
import Kingfisher

/// A SwiftUI representation of UIImageView
///
/// This is necessary because `Image` does not render correctly
/// when taking screenshots of it — the image doesn't appear.
struct ImageView: UIViewRepresentable {
let url: URL

init(_ url: URL) {
self.url = url
}

func makeUIView(context: Context) -> UIImageView {
let v = ImageViewWithFixedIntrinsicContentSize()

return v
}

func updateUIView(_ uiImage: UIImageView, context: Context) {
uiImage.kf.setImage(with: url)
}
}

private class ImageViewWithFixedIntrinsicContentSize: UIImageView {
override var intrinsicContentSize: CGSize {
return .init(width: 76, height: 76)
}
}
5 changes: 4 additions & 1 deletion podcasts/End of Year/Views/StoriesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ struct StoriesView: View {

var shareButton: some View {
Button(action: {

model.pause()
EndOfYear().share(asset: { model.shareableAsset(index: model.currentStory) }, onDismiss: {
model.start()
})
}) {
HStack {
Spacer()
Expand Down
19 changes: 19 additions & 0 deletions podcasts/End of Year/Views/View+Snapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import SwiftUI

extension View {
/// Returns a `UIImage` from a SwiftUI View
public func snapshot() -> UIImage {
let controller = UIHostingController(rootView: self.edgesIgnoringSafeArea(.top))
let view = controller.view

let targetSize = controller.view.intrinsicContentSize
view?.bounds = CGRect(origin: .zero, size: targetSize)
view?.backgroundColor = .clear

let renderer = UIGraphicsImageRenderer(size: targetSize)

return renderer.image { _ in
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
}
}