diff --git a/.gitignore b/.gitignore index 1031af25b..3a9905da1 100644 --- a/.gitignore +++ b/.gitignore @@ -117,7 +117,7 @@ iOSInjectionProject/ # End of https://www.toptal.com/developers/gitignore/api/macos,swift -Config/secrets.xcconfig +Config/secrets*.xcconfig **/*.otf **/.swiftpm **/.tmp diff --git a/Config/Test_Subscriptions.storekit b/Config/Test_Subscriptions.storekit new file mode 100644 index 000000000..17fce3274 --- /dev/null +++ b/Config/Test_Subscriptions.storekit @@ -0,0 +1,77 @@ +{ + "identifier" : "C203F756", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + + ], + "settings" : { + + }, + "subscriptionGroups" : [ + { + "id" : "7BB42CF1", + "localizations" : [ + + ], + "name" : "Pocket Premium Alpha", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "4.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "EAB5EDEA", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "monthly.subscription.pocket", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Pocket Premium Monthly", + "subscriptionGroupID" : "7BB42CF1", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "44.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "B7F87368", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "annual.subscription.pocket", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Pocket Premium Annual", + "subscriptionGroupID" : "7BB42CF1", + "type" : "RecurringSubscription" + } + ] + } + ], + "version" : { + "major" : 2, + "minor" : 0 + } +} diff --git a/Info.plist b/Info.plist index 36564daa5..6d90659ee 100644 --- a/Info.plist +++ b/Info.plist @@ -56,6 +56,10 @@ $(POCKET_API_BASE_URL) PocketAPIConsumerKey $(POCKET_API_CONSUMER_KEY) + PocketPremiumMonthly + $(POCKET_PREMIUM_MONTHLY) + PocketPremiumAnnual + $(POCKET_PREMIUM_ANNUAL) SentryDSN $(SENTRY_DSN) SnowplowEndpoint diff --git a/Pocket.xcodeproj/project.pbxproj b/Pocket.xcodeproj/project.pbxproj index c7fddaaf7..48b9e194f 100644 --- a/Pocket.xcodeproj/project.pbxproj +++ b/Pocket.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 8A7765A2288EE80900127BB4 /* RecentSavesCellElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7765A1288EE80900127BB4 /* RecentSavesCellElement.swift */; }; 8A8F5EFE28CB740000124B6D /* EditTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A8F5EFD28CB740000124B6D /* EditTagsTests.swift */; }; 8ADAC6B028AD4E7500DE9A62 /* AddTagsViewElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADAC6AF28AD4E7500DE9A62 /* AddTagsViewElement.swift */; }; + AA7D6A2B2995F0A20094FD18 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA7D6A2A2995F0A20094FD18 /* StoreKit.framework */; }; 8ADD6A9F292C189C007F419D /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADD6A9E292C189C007F419D /* SearchTests.swift */; }; 8ADD6AA1292C1B2C007F419D /* SearchViewElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADD6AA0292C1B2C007F419D /* SearchViewElement.swift */; }; D26A5EF4297F1D8400FA5A88 /* ReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D26A5EF3297F1D8400FA5A88 /* ReaderTests.swift */; }; @@ -215,9 +216,12 @@ 8A7765A1288EE80900127BB4 /* RecentSavesCellElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSavesCellElement.swift; sourceTree = ""; }; 8A8F5EFD28CB740000124B6D /* EditTagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditTagsTests.swift; sourceTree = ""; }; 8ADAC6AF28AD4E7500DE9A62 /* AddTagsViewElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTagsViewElement.swift; sourceTree = ""; }; + AA7D6A2A2995F0A20094FD18 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.2.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; + D26A5EF3297F1D8400FA5A88 /* ReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTests.swift; sourceTree = ""; }; 8ADD6A9E292C189C007F419D /* SearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = ""; }; 8ADD6AA0292C1B2C007F419D /* SearchViewElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewElement.swift; sourceTree = ""; }; - D26A5EF3297F1D8400FA5A88 /* ReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTests.swift; sourceTree = ""; }; + AA909AC8299EFD3A00F90FA7 /* secrets_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = secrets_test.xcconfig; sourceTree = ""; }; + AA909ACC299F4D5400F90FA7 /* Test_Subscriptions.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Test_Subscriptions.storekit; sourceTree = ""; }; F204A76027E22EC50010E155 /* SaveToPocket.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SaveToPocket.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F204A76727E22EC50010E155 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; F20BB0382744542F00AE5E70 /* AlertElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertElement.swift; sourceTree = ""; }; @@ -238,6 +242,7 @@ buildActionMask = 2147483647; files = ( 16BA7D6626851579009A17C1 /* PocketKit in Frameworks */, + AA7D6A2B2995F0A20094FD18 /* StoreKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -410,7 +415,9 @@ 16973354263CBB290003DE2A /* Config */ = { isa = PBXGroup; children = ( + AA909AC8299EFD3A00F90FA7 /* secrets_test.xcconfig */, 16973355263CBB3F0003DE2A /* secrets.xcconfig */, + AA909ACC299F4D5400F90FA7 /* Test_Subscriptions.storekit */, ); path = Config; sourceTree = ""; @@ -449,6 +456,7 @@ F220F2B8264DC2750064D272 /* Frameworks */ = { isa = PBXGroup; children = ( + AA7D6A2A2995F0A20094FD18 /* StoreKit.framework */, F227F0C3265E96290031F985 /* CoreText.framework */, 65EC272628BA8AD50075E1DF /* UserNotifications.framework */, 65EC272828BA8AD50075E1DF /* UserNotificationsUI.framework */, @@ -1069,7 +1077,7 @@ }; 1676E5A727FBC89A00F9283A /* Debug_Test */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 16973355263CBB3F0003DE2A /* secrets.xcconfig */; + baseConfigurationReference = AA909AC8299EFD3A00F90FA7 /* secrets_test.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; diff --git a/PocketKit/Sources/PocketKit/Keys.swift b/PocketKit/Sources/PocketKit/Keys.swift index 39f064b76..e5e77cf8b 100644 --- a/PocketKit/Sources/PocketKit/Keys.swift +++ b/PocketKit/Sources/PocketKit/Keys.swift @@ -11,6 +11,8 @@ struct Keys { let sentryDSN: String let brazeAPIEndpoint: String let brazeAPIKey: String + let pocketPremiumMonthly: String + let pocketPremiumAnnual: String private init() { guard let info = Bundle.main.infoDictionary else { @@ -33,9 +35,19 @@ struct Keys { fatalError("Unable to extract BrazeAPIKey from main bundle") } + guard let pocketPremiumMonthly = info["PocketPremiumMonthly"] as? String else { + fatalError("Unable to extract PocketPremiumMonthlyAlpha from main bundle") + } + + guard let pocketPremiumAnnual = info["PocketPremiumAnnual"] as? String else { + fatalError("Unable to extract PocketPremiumAnnualAlpha from main bundle") + } + self.pocketApiConsumerKey = pocketApiConsumerKey self.sentryDSN = sentryDSN self.brazeAPIEndpoint = brazeAPIEndpoint self.brazeAPIKey = brazeAPIKey + self.pocketPremiumMonthly = pocketPremiumMonthly + self.pocketPremiumAnnual = pocketPremiumAnnual } } diff --git a/PocketKit/Sources/PocketKit/MyList/EmptyStates/EmptyStateView.swift b/PocketKit/Sources/PocketKit/MyList/EmptyStates/EmptyStateView.swift index dc09ccbc2..083389326 100644 --- a/PocketKit/Sources/PocketKit/MyList/EmptyStates/EmptyStateView.swift +++ b/PocketKit/Sources/PocketKit/MyList/EmptyStates/EmptyStateView.swift @@ -25,7 +25,7 @@ open class SwiftUICollectionViewCell: UICollectionViewCell where Conten } } -class EmptyStateCollectionViewCell: SwiftUICollectionViewCell { +class EmptyStateCollectionViewCell: SwiftUICollectionViewCell> { func configure(parent: UIViewController, _ viewModel: EmptyStateViewModel) { embed(in: parent, withView: EmptyStateView(viewModel: viewModel)) host?.view.frame = self.contentView.bounds @@ -34,14 +34,16 @@ class EmptyStateCollectionViewCell: SwiftUICollectionViewCell { } } -struct EmptyStateView: View { - private var viewModel: EmptyStateViewModel +struct EmptyStateView: View { + private let viewModel: EmptyStateViewModel + private var content: Content? @State private var showSafariView = false - init(viewModel: EmptyStateViewModel) { + init(viewModel: EmptyStateViewModel, content: (() -> Content)? = nil) { self.viewModel = viewModel + self.content = content?() } var body: some View { @@ -64,8 +66,9 @@ struct EmptyStateView: View { } } else { Text(subtitle).style(.detail) } } - - if let buttonText = viewModel.buttonText, let webURL = viewModel.webURL { + if let content { + content + } else if let buttonText = viewModel.buttonText, let webURL = viewModel.webURL { Button(action: { self.showSafariView = true }, label: { diff --git a/PocketKit/Sources/PocketKit/MyList/SearchItemsList/SearchView.swift b/PocketKit/Sources/PocketKit/MyList/SearchItemsList/SearchView.swift index dff28406e..0e40691b4 100644 --- a/PocketKit/Sources/PocketKit/MyList/SearchItemsList/SearchView.swift +++ b/PocketKit/Sources/PocketKit/MyList/SearchItemsList/SearchView.swift @@ -14,6 +14,7 @@ struct SearchView: View { switch viewModel.searchState { case .emptyState(let emptyStateViewModel): SearchEmptyView(viewModel: emptyStateViewModel) + .environmentObject(viewModel) case .recentSearches(let searches): RecentSearchView(viewModel: viewModel, recentSearches: searches) case .searchResults(let results): @@ -23,8 +24,6 @@ struct SearchView: View { default: EmptyView() } - }.onAppear { - viewModel.trackOpenSearch() } } } @@ -68,11 +67,45 @@ struct ResultsView: View { // MARK: - Search Empty States Component struct SearchEmptyView: View { - var viewModel: EmptyStateViewModel + private var viewModel: EmptyStateViewModel + + init(viewModel: EmptyStateViewModel) { + self.viewModel = viewModel + } var body: some View { - EmptyStateView(viewModel: viewModel) + if let text = viewModel.buttonText { + EmptyStateView(viewModel: viewModel) { + GetPocketPremiumButton(text: text) + } .padding(Margins.normal.rawValue) + } else { + EmptyStateView(viewModel: viewModel) + .padding(Margins.normal.rawValue) + } + } +} + +struct GetPocketPremiumButton: View { + @EnvironmentObject private var searchViewModel: SearchViewModel + private let text: String + + init(text: String) { + self.text = text + } + + var body: some View { + Button(action: { + searchViewModel.showPremiumUpgrade() + }, label: { + Text(text) + .style(.header.sansSerif.h7.with(color: .ui.white)) + .padding(EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0)) + .frame(maxWidth: 320) + }).buttonStyle(GetPocketPremiumButtonStyle()) + .sheet(isPresented: $searchViewModel.isPresentingPremiumUpgrade) { + PremiumUpgradeView(viewModel: searchViewModel.makePremiumUpgradeViewModel()) + } } } diff --git a/PocketKit/Sources/PocketKit/MyList/SearchItemsList/SearchViewModel.swift b/PocketKit/Sources/PocketKit/MyList/SearchItemsList/SearchViewModel.swift index 17afb9a25..9271c6e6d 100644 --- a/PocketKit/Sources/PocketKit/MyList/SearchItemsList/SearchViewModel.swift +++ b/PocketKit/Sources/PocketKit/MyList/SearchItemsList/SearchViewModel.swift @@ -14,6 +14,15 @@ enum SearchViewState { case emptyState(EmptyStateViewModel) case recentSearches([String]) case searchResults([PocketItem]) + + var isEmptyState: Bool { + switch self { + case .emptyState: + return true + default: + return false + } + } } class SearchViewModel: ObservableObject { @@ -24,11 +33,14 @@ class SearchViewModel: ObservableObject { private let user: User private let userDefaults: UserDefaults private let source: Source + private let premiumUpgradeViewModelFactory: () -> PremiumUpgradeViewModel private var savesLocalSearch: LocalSavesSearch private var savesOnlineSearch: OnlineSearch private var archiveOnlineSearch: OnlineSearch private var allOnlineSearch: OnlineSearch + // separated from the subscriptions array as that one gets cleared between searches + private var userStatusListener: AnyCancellable? private let tracker: Tracker @@ -44,8 +56,8 @@ class SearchViewModel: ObservableObject { var selectedScope: SearchScope = .saves - @Published - var showBanner: Bool = false + @Published var showBanner: Bool = false + @Published var isPresentingPremiumUpgrade = false var bannerData: BannerModifier.BannerData { let offlineView = BannerModifier.BannerData(image: .looking, title: L10n.Search.limitedResults, detail: L10n.Search.offlineMessage) @@ -85,12 +97,18 @@ class SearchViewModel: ObservableObject { } } - init(networkPathMonitor: NetworkPathMonitor, user: User, userDefaults: UserDefaults, source: Source, tracker: Tracker) { + init(networkPathMonitor: NetworkPathMonitor, + user: User, + userDefaults: UserDefaults, + source: Source, + tracker: Tracker, + premiumUpgradeViewModelFactory: @escaping () -> PremiumUpgradeViewModel) { self.networkPathMonitor = networkPathMonitor self.user = user self.userDefaults = userDefaults self.source = source self.tracker = tracker + self.premiumUpgradeViewModelFactory = premiumUpgradeViewModelFactory savesLocalSearch = LocalSavesSearch(source: source) savesOnlineSearch = OnlineSearch(source: source, scope: .saves) @@ -101,6 +119,16 @@ class SearchViewModel: ObservableObject { networkPathMonitor.start(queue: .global()) observeNetworkChanges() + + userStatusListener = user + .statusPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + guard self?.searchState?.isEmptyState == true else { + return + } + self?.searchState = self?.defaultState + } } func updateScope(with scope: SearchScope, searchTerm: String? = nil) { @@ -119,6 +147,7 @@ class SearchViewModel: ObservableObject { searchState = .emptyState(GetPremiumEmptyState()) return } + resetSearch(with: searchTerm) let term = searchTerm.trimmingCharacters(in: .whitespaces).lowercased() @@ -128,7 +157,6 @@ class SearchViewModel: ObservableObject { searchState = .emptyState(searchResultState()) return } - trackPerformSearch() searchState = .loading submitSearch(with: term, scope: selectedScope) recentSearches = updateRecentSearches(with: term) @@ -361,3 +389,17 @@ extension SearchViewModel { tracker.track(event: Events.Search.searchCardContentOpen(url: url, positionInList: index, scope: selectedScope)) } } + + +// MARK: Premium upgrades +extension SearchViewModel { + @MainActor + func makePremiumUpgradeViewModel() -> PremiumUpgradeViewModel { + premiumUpgradeViewModelFactory() + } + + /// Ttoggle the presentation of `PremiumUpgradeView` + func showPremiumUpgrade() { + self.isPresentingPremiumUpgrade = true + } +} diff --git a/PocketKit/Sources/PocketKit/PocketSceneDelegate.swift b/PocketKit/Sources/PocketKit/PocketSceneDelegate.swift index 77366e129..1de3dd32f 100644 --- a/PocketKit/Sources/PocketKit/PocketSceneDelegate.swift +++ b/PocketKit/Sources/PocketKit/PocketSceneDelegate.swift @@ -21,7 +21,9 @@ public class PocketSceneDelegate: UIResponder, UIWindowSceneDelegate { userDefaults: Services.shared.userDefaults, source: Services.shared.source, tracker: Services.shared.tracker.childTracker(hosting: .saves.search) - ), + ) { + PremiumUpgradeViewModel(store: Services.shared.subscriptionStore) + }, savedItemsList: SavedItemsListViewModel( source: Services.shared.source, tracker: Services.shared.tracker.childTracker(hosting: .saves.saves), @@ -45,7 +47,9 @@ public class PocketSceneDelegate: UIResponder, UIWindowSceneDelegate { user: Services.shared.user, userDefaults: Services.shared.userDefaults, notificationCenter: .default - ) + ) { + PremiumUpgradeViewModel(store: Services.shared.subscriptionStore) + } ), source: Services.shared.source, tracker: Services.shared.tracker diff --git a/PocketKit/Sources/PocketKit/Premium/Model/PremiumSubscription.swift b/PocketKit/Sources/PocketKit/Premium/Model/PremiumSubscription.swift new file mode 100644 index 000000000..d33e1ceb1 --- /dev/null +++ b/PocketKit/Sources/PocketKit/Premium/Model/PremiumSubscription.swift @@ -0,0 +1,88 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import StoreKit + +/// An enum that maps all available subscription IDs on the App Store +enum PremiumSubscriptionType: CaseIterable { + case monthly + case annual + + var id: String { + switch self { + case .monthly: + return Keys.shared.pocketPremiumMonthly + case .annual: + return Keys.shared.pocketPremiumAnnual + } + } + + static func type(from productId: String) -> Self? { + switch productId { + case Keys.shared.pocketPremiumMonthly: + return .monthly + case Keys.shared.pocketPremiumAnnual: + return .annual + default: + return .none + } + } +} + +/// A type that maps to a subscription product on the App Store +struct PremiumSubscription { + let product: Product + var isPurchased = false + + var name: String { + switch type { + case .monthly: + return L10n.monthly + case .annual: + return L10n.annual + case .none: + return "" + } + } + + var type: PremiumSubscriptionType? { + PremiumSubscriptionType.type(from: product.id) + } + + /// Localized subscription price + var price: String { + product.displayPrice + } + + /// Descriptive representation of the subscription type; format: `price_frequency`, where: + /// - `price` is the localized subscription price (e. g. $ 4.99) + /// - `frequency` is the renewal time, (e. g. /month) + /// In the above example, the returned value would be $4.99/month. + var priceDescription: String { + price + frequency + } + + /// Suffix of the price description + private var frequency: String { + switch type { + case .monthly: + return Self.separator + L10n.month + case .annual: + return Self.separator + L10n.year + case .none: + return "" + } + } + private static let separator = "/" +} + +extension Array where Element == PremiumSubscription { + init() async throws { + self = try await Product + .products(for: PremiumSubscriptionType.allCases.map { $0.id }) + .map { + PremiumSubscription(product: $0) + } + } +} diff --git a/PocketKit/Sources/PocketKit/Premium/Model/PremiumSubscriptionStore.swift b/PocketKit/Sources/PocketKit/Premium/Model/PremiumSubscriptionStore.swift new file mode 100644 index 000000000..468a132ef --- /dev/null +++ b/PocketKit/Sources/PocketKit/Premium/Model/PremiumSubscriptionStore.swift @@ -0,0 +1,122 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SharedPocketKit +import StoreKit +import Sync + +/// Subscription store error(s) +enum SubscriptionStoreError: Error { + case unverifiedPurchase +} + +/// A type that handles premium subscriptions purchases from the App Store +final class PremiumSubscriptionStore: ObservableObject { + @Published private(set) var subscriptions: [PremiumSubscription] = [] + private var user: User + /// Will listen for transaction updates while the app is running + private var transactionListener: Task? + + // TODO: determine what we actually need to store here + /// For premium users, this property contains details of the purchased subscription + @Published private(set) var purchasedSubscription: PremiumSubscription? + + init(user: User) { + self.user = user + transactionListener = makeTransactionListener() + + Task { + do { + try await requestSubscriptions() + } catch { + // TODO: use logger here + print(error) + } + } + } + + /// Fetch available subscriptions from the App Store + func requestSubscriptions() async throws { + subscriptions = try await .init() + } + + /// Return a detached Task to lListen for transaction updates from the App Store + /// that don't directly come from purchases on the active device. + func makeTransactionListener() -> Task { + return Task.detached { + for await transaction in Transaction.updates { + do { + let verifiedTransaction = try self.verify(transaction) + await self.updateSubscription() + // Always finish a transaction, otherwise it will return. + await verifiedTransaction.finish() + } catch { + // TODO: use logger here + print(error) + } + } + } + } + + /// Varify the passed transaction + /// - Parameter transaction: a new transaction to verify + /// - Returns: a verified trnasaction, if verification is successful. Throws an error otherwise. + func verify(_ transaction: VerificationResult) throws -> Transaction { + switch transaction { + case .unverified: + throw SubscriptionStoreError.unverifiedPurchase + case .verified(let verifiedTransaction): + return verifiedTransaction + } + } + + func purchase(_ subscription: PremiumSubscription) async { + do { + try await purchase(product: subscription.product) + } catch { + print(error) + } + } + + /// Process the purchase of a product + /// - Parameter product: the product to purchase + private func purchase(product: Product) async throws { + // TODO: we might want to add `appAccountToken` in the options + let result = try await product.purchase() + + switch result { + case .success(let transaction): + let verifiedTransaction = try verify(transaction) + await updateSubscription() + // Always finish a transaction, otherwise it will return. + await verifiedTransaction.finish() + default: + // TODO: we might want to handle states differently, e. g. when user cancels. + break + } + } + + /// Updates app status when a new subscription is found + private func updateSubscription() async { + // TODO: we need to handle the downgrade as well + for await transaction in Transaction.currentEntitlements { + do { + let verifiedTransaction = try verify(transaction) + switch verifiedTransaction.productType { + case .autoRenewable: + if let subscription = subscriptions.first(where: { $0.product.id == verifiedTransaction.productID }) { + purchasedSubscription = subscription + user.setPremiumStatus(true) + } + default: + // We do not have other product types as of now. + Log.capture(message: "Received invalid product type") + } + } catch { + // TODO: use logger here + print(error) + } + } + } +} diff --git a/PocketKit/Sources/PocketKit/Premium/View/PremiumUpgradeSuccessScreen.swift b/PocketKit/Sources/PocketKit/Premium/View/PremiumUpgradeSuccessScreen.swift new file mode 100644 index 000000000..ce9c9aff2 --- /dev/null +++ b/PocketKit/Sources/PocketKit/Premium/View/PremiumUpgradeSuccessScreen.swift @@ -0,0 +1,68 @@ +import SwiftUI +import Textile + +struct PremiumUpgradeSuccessView: View { + @Environment(\.dismiss) + private var dismiss + + var body: some View { + VStack(spacing: Constants.verticalPadding) { + dismissButton + Image(uiImage: UIImage(asset: .premiumHooray)) + .resizable() + .scaledToFit() + .frame(width: Constants.frameSize.width, height: Constants.frameSize.height) + Text(L10n.hooray) + .style(.title) + Text(L10n.Premium.Success.message) + .style(.paragraph) + Button(action: { + dismiss() + }) { + VStack { + Text(L10n.Back.To.pocket) + .style(.button) + } + } + .padding() + .frame(maxWidth: .infinity, minHeight: Constants.frameMinHeight) + .background( + RoundedRectangle(cornerRadius: Constants.cornerRadius, style: .continuous) + .fill(Color(.ui.coral2)) + ) + .padding([.leading, .trailing], Constants.trailingPadding) + Spacer() + } + } + + private var dismissButton: some View { + HStack(spacing: 0) { + Spacer() + Button { + dismiss() + } label: { + Image(asset: .close).renderingMode(.template).foregroundColor(Color(.ui.grey5)) + } + .padding(.top, Constants.verticalPadding) + .padding([.leading, .trailing], Constants.verticalPadding) + } + } +} + +private extension Style { + static let title = Style.header.serif.title + + static let paragraph = Style.header.serif.p2.with(alignment: .center) + + static let button = Style.header.sansSerif.p3.with(color: .ui.white).with(weight: .medium) +} + +private extension PremiumUpgradeSuccessView { + enum Constants { + static let frameSize = CGSize(width: 350, height: 350) + static let frameMinHeight: CGFloat = 53 + static let verticalPadding: CGFloat = 20 + static let cornerRadius: CGFloat = 8 + static let trailingPadding: CGFloat = 15 + } +} diff --git a/PocketKit/Sources/PocketKit/Premium/View/PremiumUpgradeView.swift b/PocketKit/Sources/PocketKit/Premium/View/PremiumUpgradeView.swift new file mode 100644 index 000000000..e3fc156d7 --- /dev/null +++ b/PocketKit/Sources/PocketKit/Premium/View/PremiumUpgradeView.swift @@ -0,0 +1,340 @@ +import SwiftUI +import Textile + +struct PremiumUpgradeView: View { + static let shouldAllowUpgrade = false + @State private var showingMonthlyAlert = false + @State private var showingAnnualAlert = false + @Environment(\.dismiss) private var dismiss + @StateObject var viewModel: PremiumUpgradeViewModel + + init(viewModel: PremiumUpgradeViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + VStack(spacing: 0) { + dismissButton + upgradeView + } + .padding([.top, .bottom], 20) + .background(PremiumBackgroundView()) + .task { + do { + try await viewModel.requestSubscriptions() + } catch { + // TODO: Here we will handle any error providing user feedback if/when needed + print(error) + } + } + .onChange(of: viewModel.shouldDismiss) { shouldDismiss in + if shouldDismiss { + dismiss() + } + } + } + + private var dismissButton: some View { + HStack(spacing: 0) { + Spacer() + Button { + dismiss() + } label: { + Image(asset: .close).renderingMode(.template).foregroundColor(Color(.ui.grey5)) + } + .padding(.top, 10) + .padding([.leading, .trailing], 32) + } + } + + struct OffsetConstant { + static var offsetX: CGFloat = 10 + static var offsetY: CGFloat = -30 + } + + private var upgradeView: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 40) { + PremiumUpgradeHeader() + Divider().background(Color(.ui.grey1)) + PremiumUpgradeFeaturesView() + HStack { + if viewModel.monthlyName.isEmpty { + PremiumUpgradeButton(isYearly: false) + .redacted(reason: .placeholder) + } else { + PremiumUpgradeButton( + text: viewModel.monthlyName, + pricing: viewModel.monthlyPriceDescription, + isYearly: false + ) { + Task { + if Self.shouldAllowUpgrade { + await viewModel.purchaseMonthlySubscription() + } else { + showingMonthlyAlert = true + } + } + } + .alert("Comiing Soon!", isPresented: $showingMonthlyAlert) { + Button("OK", role: .cancel) { } + } + } + Spacer().frame(width: 28) + ZStack(alignment: .topTrailing) { + if viewModel.annualName.isEmpty { + PremiumUpgradeButton(isYearly: true) + .redacted(reason: .placeholder) + } else { + PremiumUpgradeButton( + text: viewModel.annualName, + pricing: viewModel.annualPriceDescription, + isYearly: true + ) { + Task { + if Self.shouldAllowUpgrade { + await viewModel.purchaseAnnualSubscription() + } else { + showingAnnualAlert = true + } + } + } + .alert("Comiing Soon!", isPresented: $showingAnnualAlert) { + Button("OK", role: .cancel) { } + } + PremiumYearlyPercent() + .offset(x: OffsetConstant.offsetX, y: OffsetConstant.offsetY) + } + } + } + if viewModel.monthlyPrice.isEmpty, viewModel.annualPrice.isEmpty { + PremiumInfoView(monthlyPrice: viewModel.monthlyPrice, annualPrice: viewModel.annualPrice) + .redacted(reason: .placeholder) + } else { + PremiumInfoView(monthlyPrice: viewModel.monthlyPrice, annualPrice: viewModel.annualPrice) + } + PremiumTermsView() + } + .padding([.leading, .trailing], 32) + } + } +} + +private struct PremiumUpgradeHeader: View { + var body: some View { + VStack(spacing: 0) { + Text("Premium").style(.upgradeHeader) + Text("Membership").style(.upgradeHeader) + } + } +} + +private struct PremiumUpgradeFeaturesView: View { + private let features = [ + "Permanent library of everything you've saved", + "Ad-free", + "Suggested tags", + "Full-text search", + "Unlimited highlights", + "Premium fonts" + ] + + var body: some View { + VStack(spacing: 24) { + ForEach(features, id: \.self) { + PremiumUpgradeFeatureRow(text: $0) + } + .listStyle(.plain) + .disabled(true) + } + } +} + +private struct PremiumUpgradeFeatureRow: View { + private let text: String + + init(text: String) { + self.text = text + } + + var body: some View { + HStack(spacing: 16) { + Image(asset: .checkMini).renderingMode(.template).foregroundColor(Color(.ui.teal3)) + Text(text).style(.featureRow) + Spacer() + }.listRowSeparator(.hidden) + } +} + +private struct PremiumUpgradeButton: View { + private let text: String + private let pricing: String + private let isYearly: Bool + private var action: (() -> Void)? + + /// The default values are used as placeholders while the actual values are being loaded + /// Useful for redacting the Text views while loading + init(text: String = String(repeating: " ", count: 8), + pricing: String = String(repeating: " ", count: 10), + isYearly: Bool, + action: (() -> Void)? = nil) { + self.text = text + self.pricing = pricing + self.isYearly = isYearly + self.action = action + } + + var body: some View { + if isYearly { + Button(action: { action?() }) { + VStack(spacing: 8) { + Text(text) + .style(.yearlyPremiumRow) + Text(pricing) + .style(.yearlyPricing) + } + .padding() + .frame(maxWidth: .infinity, minHeight: 53) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(.ui.coral2)) + ) + } + } else { + Button(action: { action?() }) { + VStack(spacing: 8) { + Text(text) + .style(.monthlyPremiumRow) + Text(pricing) + .style(.pricing) + } + .padding() + .frame(maxWidth: .infinity, minHeight: 53) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(.ui.grey1), lineWidth: 2) + ) + } + } + } +} + +private struct PremiumYearlyPercent: View { + var body: some View { + VStack { + Text("Save 25%") + .style(.percentSaved) + .multilineTextAlignment(.center) + } + .frame(width: 60, height: 60, alignment: .center) + .background(Circle().fill(Color(.ui.teal3))) + } +} + +private struct PremiumInfoView: View { + let monthlyPrice: String + let annualPrice: String + private let text: String + + init(monthlyPrice: String, annualPrice: String) { + self.monthlyPrice = monthlyPrice + self.annualPrice = annualPrice + self.text = L10n.Premium.Upgradeview.description(monthlyPrice, annualPrice) + } + + var body: some View { + Text(text).style(.info) + } +} + +private struct PremiumTermsView: View { + @State var showPrivacyPolicy = false + @State var showTermsOfService = false + + var body: some View { + HStack(spacing: 16) { + Button(action: { + self.showPrivacyPolicy = true + }, label: { Text("Privacy Policy").style(.terms) }) + .sheet(isPresented: $showPrivacyPolicy) { + if let privacyUrl = getUrlFor(typeOf: .privacyPolicy) { + SFSafariView(url: privacyUrl) + } + } + .accessibilityIdentifier("privacy-policy") + Button(action: { + self.showTermsOfService = true + }, label: { Text("Terms of Service").style(.terms) }) + .sheet(isPresented: $showTermsOfService) { + if let toSUrl = getUrlFor(typeOf: .termsOfService) { + SFSafariView(url: toSUrl) + } + } + .accessibilityIdentifier("terms-of-service") + }.padding(.bottom) + } + + enum Link { + case privacyPolicy + case termsOfService + } + + private func getUrlFor(typeOf: Link) -> URL? { + switch typeOf { + case .privacyPolicy: + guard let privacyUrl = URL(string: "https://getpocket.com/privacy/") else { + return nil + } + return privacyUrl + case .termsOfService: + guard let toSUrl = URL(string: "https://getpocket.com/tos/") else { + return nil + } + return toSUrl + } + } +} + +private struct PremiumBackgroundView: View { + var body: some View { + VStack(spacing: 0) { + Image(asset: .premiumBorderTop).resizable().frame(maxHeight: borderWidth) + sidebars + Image(asset: .premiumBorderBottom).resizable().frame(maxHeight: borderWidth) + } + } + + private var sidebars: some View { + HStack(spacing: 0) { + Image(asset: .premiumBorderLeft).resizable().frame(maxWidth: borderWidth) + Spacer() + Image(asset: .premiumBorderRight).resizable().frame(maxWidth: borderWidth) + } + } + + private var borderWidth: CGFloat { + CGFloat(UIDevice.current.userInterfaceIdiom == .pad ? 18.0 : 13.0) + } +} + +private extension Style { + static let upgradeHeader = Style.header.display.medium.h1.with { style in + style.with(alignment: .center) + } + + static let featureRow = Style.settings.row.default.with(size: .p3) + + static let monthlyPremiumRow = Style.settings.row.default.with(size: .h4).with(weight: .medium) + + static let yearlyPremiumRow = Style.settings.row.darkBackground.default.with(size: .h4).with(weight: .medium) + + static let pricing = Style.featureRow.with(size: .p4) + + static let yearlyPricing = Style.yearlyPremiumRow.with(size: .p4) + + static let percentSaved = Style.yearlyPremiumRow.with(size: .p3).with(weight: .medium) + + static let info = Style.body.sansSerif.with(size: .p4).with(color: .ui.grey4) + + static let terms = Style.settings.button.default.with(color: .ui.grey4) +} diff --git a/PocketKit/Sources/PocketKit/Premium/ViewModel/PremiumUpgradeViewModel.swift b/PocketKit/Sources/PocketKit/Premium/ViewModel/PremiumUpgradeViewModel.swift new file mode 100644 index 000000000..cf2d582e4 --- /dev/null +++ b/PocketKit/Sources/PocketKit/Premium/ViewModel/PremiumUpgradeViewModel.swift @@ -0,0 +1,75 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Combine +import Foundation +import SharedPocketKit + +@MainActor +class PremiumUpgradeViewModel: ObservableObject { + let store: PremiumSubscriptionStore + + @Published private(set) var monthlyName = "" + @Published private(set) var monthlyPrice = "" + @Published private(set) var monthlyPriceDescription = "" + + @Published private(set) var annualName = "" + @Published private(set) var annualPrice = "" + @Published private(set) var annualPriceDescription = "" + @Published private(set) var shouldDismiss = false + + private var cancellables: Set = [] + + init(store: PremiumSubscriptionStore) { + self.store = store + + store.$subscriptions + .receive(on: DispatchQueue.main) + .sink { [weak self] subscriptions in + subscriptions.forEach { + switch $0.type { + case .monthly: + self?.monthlyName = $0.name + self?.monthlyPrice = $0.price + self?.monthlyPriceDescription = $0.priceDescription + case .annual: + self?.annualName = $0.name + self?.annualPrice = $0.price + self?.annualPriceDescription = $0.priceDescription + case .none: + break + } + } + } + .store(in: &cancellables) + // Dismiss premium upgrade view if user is successfully upgraded to premium + store.$purchasedSubscription + .receive(on: DispatchQueue.main) + .sink { [weak self] subscription in + if subscription != nil { + self?.shouldDismiss = true + } + } + .store(in: &cancellables) + } + + /// Request purchaseable subscriptions to the subscription store + func requestSubscriptions() async throws { + try await store.requestSubscriptions() + } + + func purchaseMonthlySubscription() async { + guard let monthlySubscription = store.subscriptions.first(where: { $0.type == .monthly }) else { + return + } + await store.purchase(monthlySubscription) + } + + func purchaseAnnualSubscription() async { + guard let annualSubscription = store.subscriptions.first(where: { $0.type == .annual }) else { + return + } + await store.purchase(annualSubscription) + } +} diff --git a/PocketKit/Sources/PocketKit/Resources/en.lproj/Localizable.strings b/PocketKit/Sources/PocketKit/Resources/en.lproj/Localizable.strings index 4ae80062c..906e59f35 100644 --- a/PocketKit/Sources/PocketKit/Resources/en.lproj/Localizable.strings +++ b/PocketKit/Sources/PocketKit/Resources/en.lproj/Localizable.strings @@ -94,6 +94,9 @@ "Premium Status:" = "Premium Status:"; "Your Account" = "Your Account"; +"Sign Out" = "Sign Out"; +"Are you sure?" = "Are you sure?"; +"You will be signed out of your account and any files that have been saved for offline viewing will be deleted." = "You will be signed out of your account and any files that have been saved for offline viewing will be deleted."; "App Customization" = "App Customization"; "Show App Badge Count" = "Show App Badge Count"; "About & Support" = "About & Support"; @@ -146,3 +149,18 @@ //List view "item.list.min" = "%@ min"; +// Short name of the monthly premium subscription +"Monthly" = "Monthly"; +// Suffix of the monthly premium subscription price description +"month" = "month"; +// Suffix of the annual premium subscription price description +"year" = "year"; +// Short name of the annual premium subscription +"Annual" = "Annual"; +// Description of the premium uprgade view +"premium.upgradeview.description" = "Subscriptions will be charged to your credit card through your iTunes account. Your account will be charged %1$@ (monthly) or %2$@ (yearly) for renewal within 24 hours prior to the end of the current period. Subscriptions will automatically renew unless canceled at least 24 hours before the end of the current period. It will not be possible to immediately cancel a subscription. You can manage subscriptions and turn off auto-renewal by going to your account settings after purchase. Refunds are not available for unused portions of a subscription."; +"settings.goPremiumRow" = "Go Premium"; +"settings.premiumSubscriptionRow" = "Premium Subscription"; +"Hooray!" = "Hooray!"; +"premium.success.message" = "You’re officially a Pocket Premium member. Welcome to the new ad-free, customizable, permanent version of your Pocket. We think you’ll like it here."; +"back.to.pocket" = "Back to Pocket"; diff --git a/PocketKit/Sources/PocketKit/Services.swift b/PocketKit/Sources/PocketKit/Services.swift index e4fd11584..8b5478ff8 100644 --- a/PocketKit/Sources/PocketKit/Services.swift +++ b/PocketKit/Sources/PocketKit/Services.swift @@ -30,6 +30,7 @@ struct Services { let instantSync: InstantSyncProtocol let braze: BrazeProtocol let appBadgeSetup: AppBadgeSetup + let subscriptionStore: PremiumSubscriptionStore private let persistentContainer: PersistentContainer @@ -111,6 +112,7 @@ struct Services { userDefaults: userDefaults, badgeProvider: UIApplication.shared ) + subscriptionStore = .init(user: user) } } diff --git a/PocketKit/Sources/PocketKit/Settings/AccountViewModel.swift b/PocketKit/Sources/PocketKit/Settings/AccountViewModel.swift index d70f42bfe..7ef691566 100644 --- a/PocketKit/Sources/PocketKit/Settings/AccountViewModel.swift +++ b/PocketKit/Sources/PocketKit/Settings/AccountViewModel.swift @@ -1,9 +1,9 @@ -import Sync import Analytics -import Textile -import Foundation +import Combine import SharedPocketKit import SwiftUI +import Sync +import Textile class AccountViewModel: ObservableObject { static let ToggleAppBadgeKey = "AccountViewModel.ToggleAppBadge" @@ -11,15 +11,40 @@ class AccountViewModel: ObservableObject { private let user: User private let userDefaults: UserDefaults private let notificationCenter: NotificationCenter + private let premiumUpgradeViewModelFactory: () -> PremiumUpgradeViewModel + + @Published var isPresentingHelp = false + @Published var isPresentingTerms = false + @Published var isPresentingPrivacy = false + @Published var isPresentingSignOutConfirm = false + @Published var isPresentingPremiumUpgrade = false + @Published var isPresentingLicenses = false @AppStorage("Settings.ToggleAppBadge") public var appBadgeToggle: Bool = false - init(appSession: AppSession, user: User, userDefaults: UserDefaults, notificationCenter: NotificationCenter) { + private var userStatusListener: AnyCancellable? + + @Published var isPremium: Bool + + init(appSession: AppSession, + user: User, + userDefaults: UserDefaults, + notificationCenter: NotificationCenter, + premiumUpgradeViewModelFactory: @escaping () -> PremiumUpgradeViewModel) { self.appSession = appSession self.user = user self.userDefaults = userDefaults self.notificationCenter = notificationCenter + self.premiumUpgradeViewModelFactory = premiumUpgradeViewModelFactory + self.isPremium = user.status == .premium + + userStatusListener = user + .statusPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + self?.isPremium = status == .premium + } } func signOut() { @@ -45,10 +70,17 @@ class AccountViewModel: ObservableObject { self.notificationCenter.post(name: .listUpdated, object: nil) } } +} - @Published var isPresentingHelp = false - @Published var isPresentingTerms = false - @Published var isPresentingPrivacy = false - @Published var isPresentingSignOutConfirm = false - @Published var isPresentingLicenses = false +// MARK: Premium upgrades +extension AccountViewModel { + @MainActor + func makePremiumUpgradeViewModel() -> PremiumUpgradeViewModel { + premiumUpgradeViewModelFactory() + } + + /// Ttoggle the presentation of `PremiumUpgradeView` + func showPremiumUpgrade() { + self.isPresentingPremiumUpgrade = true + } } diff --git a/PocketKit/Sources/PocketKit/Settings/Components/SettingsRowButton.swift b/PocketKit/Sources/PocketKit/Settings/Components/SettingsRowButton.swift index 7adc8fa76..cc1f89334 100644 --- a/PocketKit/Sources/PocketKit/Settings/Components/SettingsRowButton.swift +++ b/PocketKit/Sources/PocketKit/Settings/Components/SettingsRowButton.swift @@ -5,7 +5,10 @@ struct SettingsRowButton: View { var title: String var titleStyle: Style = .settings.row.default var icon: SFIconModel? - var imageColor: Color = Color(.ui.black1) + var leadingImageAsset: ImageAsset? + var trailingImageAsset: ImageAsset? + var leadingTintColor: Color = Color(.ui.black1) + var trailingTintColor: Color = Color(.ui.black1) let action: () -> Void @@ -14,6 +17,10 @@ struct SettingsRowButton: View { self.action() } label: { HStack(spacing: 0) { + if let leadingImageAsset { + SettingsButtonImage(color: leadingTintColor, asset: leadingImageAsset) + .padding(.trailing) + } Text(title) .style(titleStyle) Spacer() @@ -21,8 +28,22 @@ struct SettingsRowButton: View { if let icon = icon { SFIcon(icon) } + if let trailingImageAsset { + SettingsButtonImage(color: trailingTintColor, asset: trailingImageAsset) + } } .padding(.vertical, 5) } } } + +struct SettingsButtonImage: View { + let color: Color + let asset: ImageAsset + + var body: some View { + Image(uiImage: UIImage(asset: asset)) + .renderingMode(.template) + .foregroundColor(color) + } +} diff --git a/PocketKit/Sources/PocketKit/Settings/Components/SettingsRowLink.swift b/PocketKit/Sources/PocketKit/Settings/Components/SettingsRowLink.swift index 737a1d26c..144df8e8f 100644 --- a/PocketKit/Sources/PocketKit/Settings/Components/SettingsRowLink.swift +++ b/PocketKit/Sources/PocketKit/Settings/Components/SettingsRowLink.swift @@ -7,7 +7,7 @@ struct SettingsRowLink: View { var title: String var titleStyle: Style = .settings.row.default - var icon: SFIconModel = SFIconModel("chevron.right", color: Color(.ui.black1)) + var icon: SFIconModel? = SFIconModel("chevron.right", color: Color(.ui.black1)) var destination: Destination var body: some View { @@ -19,7 +19,9 @@ struct SettingsRowLink: View { Text(title) .style(titleStyle) Spacer() - SFIcon(icon) + if let icon = icon { + SFIcon(icon) + } } .padding(.vertical, 5) NavigationLink(destination: destination, isActive: $isActive) { EmptyView() }.hidden() diff --git a/PocketKit/Sources/PocketKit/Settings/SettingsView.swift b/PocketKit/Sources/PocketKit/Settings/SettingsView.swift index 298ab4907..3fe06e27d 100644 --- a/PocketKit/Sources/PocketKit/Settings/SettingsView.swift +++ b/PocketKit/Sources/PocketKit/Settings/SettingsView.swift @@ -41,25 +41,12 @@ struct SettingsView: View { struct SettingsForm: View { @ObservedObject var model: AccountViewModel + var body: some View { Form { Group { - Section(header: Text(L10n.yourAccount).style(.settings.header)) { - SettingsRowButton(title: L10n.Settings.logout, titleStyle: .settings.button.signOut, icon: SFIconModel("rectangle.portrait.and.arrow.right", weight: .semibold, color: Color(.ui.apricot1))) { model.isPresentingSignOutConfirm.toggle() } - .accessibilityIdentifier("log-out-button") - } - .alert( - L10n.Settings.Logout.areyousure, - isPresented: $model.isPresentingSignOutConfirm, - actions: { - Button(L10n.Settings.logout, role: .destructive) { - model.signOut() - } - }, message: { - Text(L10n.Settings.Logout.areYouSureMessage) - } - ) - .textCase(nil) + topSectionWithLeadingDivider() + .textCase(nil) Section(header: Text(L10n.appCustomization).style(.settings.header)) { SettingsRowToggle(title: L10n.showAppBadgeCount, model: model) { @@ -115,6 +102,83 @@ struct SettingsForm: View { } } +// MARK: Top Section +// These methods should be removed once we support iOS 16+ +extension SettingsForm { + /// Handles top section separator on different versions of iOS + @ViewBuilder + private func topSectionWithLeadingDivider() -> some View { + if #available(iOS 16.0, *) { + topSection() + .alignmentGuide(.listRowSeparatorLeading) { _ in + return 0 + } + } else { + topSection() + } + } + /// Provides the standard top section view + private func topSection() -> some View { + Section(header: Text(L10n.yourAccount).style(.settings.header)) { + if model.isPremium { + makePremiumSubscriptionRow() + } else { + makeGoPremiumRow() + } + SettingsRowButton( + title: L10n.Settings.logout, + titleStyle: .settings.button.signOut, + icon: SFIconModel( + "rectangle.portrait.and.arrow.right", + weight: .semibold, + color: Color(.ui.apricot1) + ) + ) { + model.isPresentingSignOutConfirm.toggle() + } + .accessibilityIdentifier("sign-out-button") + .alert( + L10n.Settings.Logout.areyousure, + isPresented: $model.isPresentingSignOutConfirm, + actions: { + Button(L10n.Settings.logout, role: .destructive) { + model.signOut() + } + }, message: { + Text(L10n.Settings.Logout.areYouSureMessage) + } + ) + } + } + + private func makePremiumRowContent(_ isPremium: Bool) -> some View { + let title = isPremium ? L10n.Settings.premiumSubscriptionRow : L10n.Settings.goPremiumRow + let titleStyle: Style = isPremium ? .settings.row.active : .settings.row.default + let leadingTintColor = isPremium ? Color(.ui.teal2) : Color(.ui.black1) + let action = isPremium ? { } : { model.showPremiumUpgrade() } + return SettingsRowButton( + title: title, + titleStyle: titleStyle, + leadingImageAsset: .premiumIcon, + trailingImageAsset: .chevronRight, + leadingTintColor: leadingTintColor, + action: action + ) + } + + private func makeGoPremiumRow() -> some View { + makePremiumRowContent(false) + .sheet(isPresented: $model.isPresentingPremiumUpgrade) { + PremiumUpgradeView(viewModel: model.makePremiumUpgradeViewModel()) + } + } + + private func makePremiumSubscriptionRow() -> some View { + makePremiumRowContent(true) + // TODO: add logic to present the premium subscription sheet here + } +} + private extension Style { static let credits = Style.header.sansSerif.p4.with(color: .ui.grey5) } diff --git a/PocketKit/Sources/PocketKit/Strings.swift b/PocketKit/Sources/PocketKit/Strings.swift index 28fa7396b..e31fdf0b6 100644 --- a/PocketKit/Sources/PocketKit/Strings.swift +++ b/PocketKit/Sources/PocketKit/Strings.swift @@ -24,6 +24,8 @@ internal enum L10n { internal static let all = L10n.tr("Localizable", "All", fallback: "All") /// An error occurred internal static let anErrorOccurred = L10n.tr("Localizable", "An error occurred", fallback: "An error occurred") + /// Annual + internal static let annual = L10n.tr("Localizable", "Annual", fallback: "Annual") /// App Customization internal static let appCustomization = L10n.tr("Localizable", "App Customization", fallback: "App Customization") /// Archive @@ -35,6 +37,8 @@ internal enum L10n { internal static let areYouSureYouWantToDeleteTheTagsAndRemoveItFromAllItems = L10n.tr("Localizable", "Are you sure you want to delete the tags and remove it from all items?", fallback: "Are you sure you want to delete the tags and remove it from all items?") /// Are you sure you want to delete this item? internal static let areYouSureYouWantToDeleteThisItem = L10n.tr("Localizable", "Are you sure you want to delete this item?", fallback: "Are you sure you want to delete this item?") + /// Are you sure? + internal static let areYouSure = L10n.tr("Localizable", "Are you sure?", fallback: "Are you sure?") /// AuthorizationClient is already authenticating internal static let authorizationClientIsAlreadyAuthenticating = L10n.tr("Localizable", "AuthorizationClient is already authenticating", fallback: "AuthorizationClient is already authenticating") /// Cancel @@ -71,6 +75,8 @@ internal enum L10n { internal static let hitTheStarIconToFavoriteAnArticleAndFindItFaster = L10n.tr("Localizable", "Hit the star icon to favorite an article and find it faster.", fallback: "Hit the star icon to favorite an article and find it faster.") /// Home internal static let home = L10n.tr("Localizable", "Home", fallback: "Home") + /// Hooray! + internal static let hooray = L10n.tr("Localizable", "Hooray!", fallback: "Hooray!") /// How to archive internal static let howToArchive = L10n.tr("Localizable", "How to archive", fallback: "How to archive") /// How to save @@ -93,6 +99,10 @@ internal enum L10n { internal static let longestToRead = L10n.tr("Localizable", "Longest to read", fallback: "Longest to read") /// Make the most of any moment internal static let makeTheMostOfAnyMoment = L10n.tr("Localizable", "Make the most of any moment", fallback: "Make the most of any moment") + /// month + internal static let month = L10n.tr("Localizable", "month", fallback: "month") + /// Monthly + internal static let monthly = L10n.tr("Localizable", "Monthly", fallback: "Monthly") /// Move to Saves internal static let moveToSaves = L10n.tr("Localizable", "Move to Saves", fallback: "Move to Saves") /// Newest saved @@ -157,6 +167,8 @@ internal enum L10n { internal static let shortestToRead = L10n.tr("Localizable", "Shortest to read", fallback: "Shortest to read") /// Show App Badge Count internal static let showAppBadgeCount = L10n.tr("Localizable", "Show App Badge Count", fallback: "Show App Badge Count") + /// Sign Out + internal static let signOut = L10n.tr("Localizable", "Sign Out", fallback: "Sign Out") /// Sign Up internal static let signUp = L10n.tr("Localizable", "Sign Up", fallback: "Sign Up") /// Sign up for Premium @@ -187,8 +199,12 @@ internal enum L10n { internal static let thisVideoCouldNotBeLoaded = L10n.tr("Localizable", "This video could not be loaded.", fallback: "This video could not be loaded.") /// Unfavorite internal static let unfavorite = L10n.tr("Localizable", "Unfavorite", fallback: "Unfavorite") + /// year + internal static let year = L10n.tr("Localizable", "year", fallback: "year") /// Yes internal static let yes = L10n.tr("Localizable", "Yes", fallback: "Yes") + /// You will be signed out of your account and any files that have been saved for offline viewing will be deleted. + internal static let youWillBeSignedOutOfYourAccountAndAnyFilesThatHaveBeenSavedForOfflineViewingWillBeDeleted = L10n.tr("Localizable", "You will be signed out of your account and any files that have been saved for offline viewing will be deleted.", fallback: "You will be signed out of your account and any files that have been saved for offline viewing will be deleted.") /// You're all caught up! /// Check back later for more. internal static let youReAllCaughtUpCheckBackLaterForMore = L10n.tr("Localizable", "You're all caught up!\nCheck back later for more.", fallback: "You're all caught up!\nCheck back later for more.") @@ -204,6 +220,12 @@ internal enum L10n { /// Save from Safari, Twitter, YouTube or your favorite news app (for starters). Your articles and videos will be ready for you in Pocket internal static let yourArticlesAndVideosWillBeReadyForYouInPocket = L10n.tr("Localizable", "Save from Safari, Twitter, YouTube or your favorite news app (for starters). Your articles and videos will be ready for you in Pocket", fallback: "Save from Safari, Twitter, YouTube or your favorite news app (for starters). Your articles and videos will be ready for you in Pocket") } + internal enum Back { + internal enum To { + /// Back to Pocket + internal static let pocket = L10n.tr("Localizable", "back.to.pocket", fallback: "Back to Pocket") + } + } internal enum Item { internal enum List { /// %@ min @@ -212,6 +234,18 @@ internal enum L10n { } } } + internal enum Premium { + internal enum Success { + /// You’re officially a Pocket Premium member. Welcome to the new ad-free, customizable, permanent version of your Pocket. We think you’ll like it here. + internal static let message = L10n.tr("Localizable", "premium.success.message", fallback: "You’re officially a Pocket Premium member. Welcome to the new ad-free, customizable, permanent version of your Pocket. We think you’ll like it here.") + } + internal enum Upgradeview { + /// Subscriptions will be charged to your credit card through your iTunes account. Your account will be charged %1$@ (monthly) or %2$@ (yearly) for renewal within 24 hours prior to the end of the current period. Subscriptions will automatically renew unless canceled at least 24 hours before the end of the current period. It will not be possible to immediately cancel a subscription. You can manage subscriptions and turn off auto-renewal by going to your account settings after purchase. Refunds are not available for unused portions of a subscription. + internal static func description(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "premium.upgradeview.description", String(describing: p1), String(describing: p2), fallback: "Subscriptions will be charged to your credit card through your iTunes account. Your account will be charged %1$@ (monthly) or %2$@ (yearly) for renewal within 24 hours prior to the end of the current period. Subscriptions will automatically renew unless canceled at least 24 hours before the end of the current period. It will not be possible to immediately cancel a subscription. You can manage subscriptions and turn off auto-renewal by going to your account settings after purchase. Refunds are not available for unused portions of a subscription.") + } + } + } internal enum Search { /// Oops! Try again? internal static let errorHeadline = L10n.tr("Localizable", "search.errorHeadline", fallback: "Oops! Try again?") @@ -235,6 +269,8 @@ internal enum L10n { } } internal enum Settings { + /// Go Premium + internal static let goPremiumRow = L10n.tr("Localizable", "settings.goPremiumRow", fallback: "Go Premium") /// Get Help and Support internal static let help = L10n.tr("Localizable", "settings.help", fallback: "Get Help and Support") /// Log Out @@ -245,6 +281,8 @@ internal enum L10n { internal static func pocketForiOS(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "settings.PocketForiOS %@ (%@)", String(describing: p1), String(describing: p2), fallback: "Pocket for iOS %@ (%@)") } + /// Premium Subscription + internal static let premiumSubscriptionRow = L10n.tr("Localizable", "settings.premiumSubscriptionRow", fallback: "Premium Subscription") internal enum Logout { /// Are you sure? internal static let areyousure = L10n.tr("Localizable", "settings.logout.areyousure", fallback: "Are you sure?") diff --git a/PocketKit/Sources/PocketKit/Style/GetPocketPremiumButtonStyle.swift b/PocketKit/Sources/PocketKit/Style/GetPocketPremiumButtonStyle.swift new file mode 100644 index 000000000..a0227daaf --- /dev/null +++ b/PocketKit/Sources/PocketKit/Style/GetPocketPremiumButtonStyle.swift @@ -0,0 +1,13 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI + +struct GetPocketPremiumButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background(configuration.isPressed ? Color(.ui.coral1) : Color(.ui.coral2)) + .cornerRadius(13) + } +} diff --git a/PocketKit/Sources/SharedPocketKit/User.swift b/PocketKit/Sources/SharedPocketKit/User.swift index 203caab35..31e5b4eda 100644 --- a/PocketKit/Sources/SharedPocketKit/User.swift +++ b/PocketKit/Sources/SharedPocketKit/User.swift @@ -8,26 +8,44 @@ public enum Status: String { } public protocol User { - var status: Status? { get } + var status: Status { get } + var statusPublisher: Published.Publisher { get } + var publishedStatus: Published { get } func setPremiumStatus(_ isPremium: Bool) func clear() } -public class PocketUser: User { - static let userStatusKey = "User.statusKey" +public class PocketUser: User, ObservableObject { + @Published public private(set) var status: Status = .unknown + public var statusPublisher: Published.Publisher { $status } + public var publishedStatus: Published { _status } + @AppStorage private var storedStatus: Status - @AppStorage - public var status: Status? + private static let userStatusKey = "User.statusKey" public init(status: Status = .unknown, userDefaults: UserDefaults) { - _status = AppStorage(Self.userStatusKey, store: userDefaults) + _storedStatus = AppStorage(wrappedValue: status, Self.userStatusKey, store: userDefaults) + publishStatus() } public func setPremiumStatus(_ isPremium: Bool) { - status = isPremium ? .premium : .free + let targetStatus: Status = isPremium ? .premium : .free + setStatus(targetStatus) } public func clear() { - status = .unknown + setStatus(.unknown) + } +} + +// MARK: Private helpers +extension PocketUser { + private func setStatus(_ status: Status) { + storedStatus = status + publishStatus() + } + + private func publishStatus() { + status = storedStatus } } diff --git a/PocketKit/Sources/Textile/Style/Images/ImageAsset.swift b/PocketKit/Sources/Textile/Style/Images/ImageAsset.swift index 753c24f63..cdeab9993 100644 --- a/PocketKit/Sources/Textile/Style/Images/ImageAsset.swift +++ b/PocketKit/Sources/Textile/Style/Images/ImageAsset.swift @@ -46,12 +46,20 @@ extension ImageAsset { public static let chevronRight = ImageAsset("chevronRight") public static let tag = ImageAsset("tag") public static let pocketWordmark = ImageAsset("pocket-wordmark") + public static let close = ImageAsset("close") + public static let checkMini = ImageAsset("checkMini") + public static let premiumBorderTop = ImageAsset("premium.border.top") + public static let premiumBorderBottom = ImageAsset("premium.border.bottom") + public static let premiumBorderLeft = ImageAsset("premium.border.left") + public static let premiumBorderRight = ImageAsset("premium.border.right") + public static let premiumIcon = ImageAsset("premium.icon") public static let magnifyingGlass = ImageAsset("magnifying-glass") public static let search = ImageAsset("search") public static let searchNoResults = ImageAsset("search.noresults") public static let searchRecent = ImageAsset("search.recent") public static let diamond = ImageAsset("diamond") public static let warning = ImageAsset("warning") + public static let premiumHooray = ImageAsset("premium.hooray") public static let readerSkeleton = ReaderSkeleton() } diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/checkMini.imageset/CheckMini.pdf b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/checkMini.imageset/CheckMini.pdf new file mode 100644 index 000000000..df31e799e Binary files /dev/null and b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/checkMini.imageset/CheckMini.pdf differ diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/checkMini.imageset/Contents.json b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/checkMini.imageset/Contents.json new file mode 100644 index 000000000..41b860a9a --- /dev/null +++ b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/checkMini.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "CheckMini.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/close.imageset/CloseXLine.pdf b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/close.imageset/CloseXLine.pdf new file mode 100644 index 000000000..521b19f6a Binary files /dev/null and b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/close.imageset/CloseXLine.pdf differ diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/close.imageset/Contents.json b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/close.imageset/Contents.json new file mode 100644 index 000000000..4b41297fa --- /dev/null +++ b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/close.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "CloseXLine.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.bottom.imageset/Contents.json b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.bottom.imageset/Contents.json new file mode 100644 index 000000000..3f3462087 --- /dev/null +++ b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.bottom.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "left.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.bottom.imageset/left.pdf b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.bottom.imageset/left.pdf new file mode 100644 index 000000000..25ba27e5d Binary files /dev/null and b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.bottom.imageset/left.pdf differ diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.left.imageset/Contents.json b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.left.imageset/Contents.json new file mode 100644 index 000000000..a85117da8 --- /dev/null +++ b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.left.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "right.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.left.imageset/right.pdf b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.left.imageset/right.pdf new file mode 100644 index 000000000..fe95f971b Binary files /dev/null and b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.left.imageset/right.pdf differ diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.right.imageset/Asset 6.pdf b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.right.imageset/Asset 6.pdf new file mode 100644 index 000000000..bac8b3d69 Binary files /dev/null and b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.right.imageset/Asset 6.pdf differ diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.right.imageset/Contents.json b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.right.imageset/Contents.json new file mode 100644 index 000000000..86f421244 --- /dev/null +++ b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.right.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Asset 6.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.top.imageset/Contents.json b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.top.imageset/Contents.json new file mode 100644 index 000000000..a2f36b60a --- /dev/null +++ b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.top.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "top.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.top.imageset/top.pdf b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.top.imageset/top.pdf new file mode 100644 index 000000000..71ceeb537 Binary files /dev/null and b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.border.top.imageset/top.pdf differ diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.hooray.imageset/Contents.json b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.hooray.imageset/Contents.json new file mode 100644 index 000000000..349f5ef6c --- /dev/null +++ b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.hooray.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hooray.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.hooray.imageset/hooray.pdf b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.hooray.imageset/hooray.pdf new file mode 100644 index 000000000..76313312f Binary files /dev/null and b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.hooray.imageset/hooray.pdf differ diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.icon.imageset/Contents.json b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.icon.imageset/Contents.json new file mode 100644 index 000000000..52f4c4fa4 --- /dev/null +++ b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "premium.icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.icon.imageset/premium.icon.pdf b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.icon.imageset/premium.icon.pdf new file mode 100644 index 000000000..ce173330c Binary files /dev/null and b/PocketKit/Sources/Textile/Style/Images/Images.xcassets/premium.icon.imageset/premium.icon.pdf differ diff --git a/PocketKit/Sources/Textile/Style/Style+Definitions.swift b/PocketKit/Sources/Textile/Style/Style+Definitions.swift index 9ac70635b..d6824ed0c 100644 --- a/PocketKit/Sources/Textile/Style/Style+Definitions.swift +++ b/PocketKit/Sources/Textile/Style/Style+Definitions.swift @@ -71,19 +71,21 @@ public extension Style { } struct Display { - let medium = Medium() - let regular = Regular() + public let medium = Medium() + public let regular = Regular() - struct Medium { + public struct Medium { public let h1 = Style(family: .doyle, size: .h1, weight: .medium) public let h2 = Style(family: .doyle, size: .h2, weight: .medium) public let h3 = Style(family: .doyle, size: .h3, weight: .medium) public let h4 = Style(family: .doyle, size: .h4, weight: .medium) public let h5 = Style(family: .doyle, size: .h5, weight: .medium) public let h6 = Style(family: .doyle, size: .h6, weight: .medium) + public let h7 = Style(family: .doyle, size: .h7, weight: .medium) + public let h8 = Style(family: .doyle, size: .h8, weight: .medium) } - struct Regular { + public struct Regular { public let h1 = Style(family: .doyle, size: .h1, weight: .regular) public let h2 = Style(family: .doyle, size: .h2, weight: .regular) public let h3 = Style(family: .doyle, size: .h3, weight: .regular) diff --git a/PocketKit/Sources/Textile/Style/Style.swift b/PocketKit/Sources/Textile/Style/Style.swift index 0f7a4e44d..f3fa945f2 100644 --- a/PocketKit/Sources/Textile/Style/Style.swift +++ b/PocketKit/Sources/Textile/Style/Style.swift @@ -176,4 +176,15 @@ public struct Style { backgroundColor: backgroundColorAsset ) } + + public func with(alignment: TextAlignment) -> Style { + Style( + fontDescriptor: fontDescriptor, + color: colorAsset, + underlineStyle: underlineStyle, + strike: strike, + paragraph: ParagraphStyle(alignment: alignment), + backgroundColor: backgroundColorAsset + ) + } } diff --git a/PocketKit/Tests/PocketKitTests/Search/SearchViewModelTests.swift b/PocketKit/Tests/PocketKitTests/Search/SearchViewModelTests.swift index 9d1d36ddb..0f6699902 100644 --- a/PocketKit/Tests/PocketKitTests/Search/SearchViewModelTests.swift +++ b/PocketKit/Tests/PocketKitTests/Search/SearchViewModelTests.swift @@ -25,6 +25,7 @@ class SearchViewModelTests: XCTestCase { override func setUpWithError() throws { networkPathMonitor = MockNetworkPathMonitor() user = MockUser() + user.stubStandardSetStatus() source = MockSource() tracker = MockTracker() userDefaults = UserDefaults(suiteName: "SearchViewModelTests") @@ -96,7 +97,7 @@ class SearchViewModelTests: XCTestCase { } func test_updateScope_forPremiumUser_withSaves_showsRecentSearchEmptyState() { - user.status = .premium + user.setPremiumStatus(true) source.stubSearchItems { _ in return [] } @@ -110,7 +111,7 @@ class SearchViewModelTests: XCTestCase { } func test_updateScope_forPremiumUser_withArchive_showsRecentSearchEmptyState() { - user.status = .premium + user.setPremiumStatus(true) source.stubSearchItems { _ in return [] } @@ -124,7 +125,7 @@ class SearchViewModelTests: XCTestCase { } func test_updateScope_forPremiumUser_withAll_showsRecentSearchEmptyState() { - user.status = .premium + user.setPremiumStatus(true) source.stubSearchItems { _ in return [] } @@ -153,7 +154,7 @@ class SearchViewModelTests: XCTestCase { func test_updateScope_forFreeUser_withArchiveAndTerm_showsResults() async { let term = "search-term" - user.status = .free + user.setPremiumStatus(false) await setupOnlineSearch(with: term) let viewModel = subject() @@ -190,7 +191,7 @@ class SearchViewModelTests: XCTestCase { func test_updateScope_forPremiumUser_withSavesAndTerm_showsResults() async { let term = "search-term" - user.status = .premium + user.setPremiumStatus(true) await setupOnlineSearch(with: term) let viewModel = subject() @@ -214,7 +215,7 @@ class SearchViewModelTests: XCTestCase { func test_updateScope_forPremiumUser_withArchiveAndTerm_showsResults() async { let term = "search-term" - user.status = .premium + user.setPremiumStatus(true) await setupOnlineSearch(with: term) let viewModel = subject() @@ -237,7 +238,7 @@ class SearchViewModelTests: XCTestCase { func test_updateScope_forPremiumUser_withAllAndTerm_showsResults() async { let term = "search-term" - user.status = .premium + user.setPremiumStatus(true) await setupOnlineSearch(with: term) let viewModel = subject() @@ -301,7 +302,7 @@ class SearchViewModelTests: XCTestCase { } func test_updateSearchResults_forPremiumUser_withNoItems_showsNoResultsEmptyState() { - user.status = .premium + user.setPremiumStatus(true) searchService.stubSearch { _, _ in } searchService._results = [] @@ -324,7 +325,7 @@ class SearchViewModelTests: XCTestCase { func test_updateSearchResults_forPremiumUser_withItems_showsResults() async { let term = "search-term" - user.status = .premium + user.setPremiumStatus(true) await setupOnlineSearch(with: term) let viewModel = subject() @@ -360,7 +361,7 @@ class SearchViewModelTests: XCTestCase { } func test_updateSearchResults_forPremiumUser_withOfflineArchive_showsOfflineEmptyState() async { - user.status = .premium + user.setPremiumStatus(true) networkPathMonitor.update(status: .unsatisfied) await setupOnlineSearch(with: "search-term") @@ -384,7 +385,7 @@ class SearchViewModelTests: XCTestCase { } func test_updateSearchResults_forPremiumUser_withOfflineAll_showsOfflineEmptyState() async { - user.status = .premium + user.setPremiumStatus(true) networkPathMonitor.update(status: .unsatisfied) await setupOnlineSearch(with: "search-term") @@ -398,7 +399,7 @@ class SearchViewModelTests: XCTestCase { } func test_selectingScope_whenOffline_showsOfflineEmptyState() async { - user.status = .premium + user.setPremiumStatus(true) await setupOnlineSearch(with: "search-term") let viewModel = subject() @@ -421,7 +422,7 @@ class SearchViewModelTests: XCTestCase { // MARK: - Recent Searches func test_recentSearches_withFreeUser_hasNoRecentSearches() { - user.status = .free + user.setPremiumStatus(false) source.stubSearchItems { _ in [] } let viewModel = subject() @@ -438,7 +439,7 @@ class SearchViewModelTests: XCTestCase { } func test_recentSearches_withNewTerm_showsRecentSearches() { - user.status = .premium + user.setPremiumStatus(true) searchService.stubSearch { _, _ in } let viewModel = subject() @@ -454,7 +455,7 @@ class SearchViewModelTests: XCTestCase { } func test_recentSearches_withDuplicateTerm_showsRecentSearches() { - user.status = .premium + user.setPremiumStatus(true) searchService.stubSearch { _, _ in } let viewModel = subject() @@ -471,7 +472,7 @@ class SearchViewModelTests: XCTestCase { } func test_recentSearches_withEmptyTerm_showsRecentSearchEmptyState() { - user.status = .premium + user.setPremiumStatus(true) searchService.stubSearch { _, _ in } let viewModel = subject() @@ -487,7 +488,7 @@ class SearchViewModelTests: XCTestCase { } func test_recentSearches_withNewTerms_showsMaxSearches() { - user.status = .premium + user.setPremiumStatus(true) searchService.stubSearch { _, _ in } let viewModel = subject() @@ -508,7 +509,7 @@ class SearchViewModelTests: XCTestCase { } func test_recentSearches_withPreviousSearch_updatesSearches() { - user.status = .premium + user.setPremiumStatus(true) searchService.stubSearch { _, _ in } let viewModel = subject() @@ -531,7 +532,7 @@ class SearchViewModelTests: XCTestCase { // MARK: - Clear func test_clear_resetsSearchResults() async { let term = "search-term" - user.status = .premium + user.setPremiumStatus(true) await setupOnlineSearch(with: term) let viewModel = subject() @@ -607,7 +608,7 @@ class SearchViewModelTests: XCTestCase { // MARK: - Error Handling func test_updateSearchResults_forPremiumUser_withOnlineSavesError_showsLocalSaves() async throws { - user.status = .premium + user.setPremiumStatus(true) networkPathMonitor.update(status: .unsatisfied) try setupLocalSavesSearch() @@ -636,7 +637,7 @@ class SearchViewModelTests: XCTestCase { } func test_updateSearchResults_withInternetConnectionError_showsOfflineView() async throws { - user.status = .premium + user.setPremiumStatus(true) let viewModel = subject() let errorExpectation = expectation(description: "handle apollo internet connection error") diff --git a/PocketKit/Tests/PocketKitTests/Support/MockUser.swift b/PocketKit/Tests/PocketKitTests/Support/MockUser.swift index 4ff5b061e..dcda0083f 100644 --- a/PocketKit/Tests/PocketKitTests/Support/MockUser.swift +++ b/PocketKit/Tests/PocketKitTests/Support/MockUser.swift @@ -1,7 +1,11 @@ import SharedPocketKit +import Combine class MockUser: User { - var status: SharedPocketKit.Status? + @Published public private(set) var status: Status = .unknown + public var statusPublisher: Published.Publisher { $status } + public var publishedStatus: Published { _status } + private var implementations: [String: Any] = [:] private var calls: [String: [Any]] = [:] } @@ -19,6 +23,12 @@ extension MockUser { implementations[Self.setStatus] = impl } + func stubStandardSetStatus() { + implementations[Self.setStatus] = { isPremium in + self.status = isPremium ? .premium : .free + } + } + func setPremiumStatus(_ isPremium: Bool) { guard let impl = implementations[Self.setStatus] as? SetStatusImpl else { fatalError("\(Self.self)#\(#function) has not been stubbed") diff --git a/PocketKit/Tests/SyncTests/PocketSourceTests.swift b/PocketKit/Tests/SyncTests/PocketSourceTests.swift index 8d363a4b5..049e6c7ff 100644 --- a/PocketKit/Tests/SyncTests/PocketSourceTests.swift +++ b/PocketKit/Tests/SyncTests/PocketSourceTests.swift @@ -27,6 +27,7 @@ class PocketSourceTests: XCTestCase { override func setUpWithError() throws { space = .testSpace() user = MockUser() + user.stubStandardSetStatus() apollo = MockApolloClient() operations = MockOperationFactory() lastRefresh = MockLastRefresh() @@ -629,7 +630,7 @@ extension PocketSourceTests { // MARK: - Search Term extension PocketSourceTests { func test_savesSearches_withFreeUser_showSearchResults_searchTitle() throws { - user.status = .free + user.setPremiumStatus(false) try setupLocalSavesSearch() let source = subject() @@ -641,7 +642,7 @@ extension PocketSourceTests { } func test_savesSearches_withPremiumUser_showSearchResults_searchTitle() throws { - user.status = .premium + user.setPremiumStatus(true) try setupLocalSavesSearch() let source = subject() let results = source.searchSaves(search: "saved") @@ -652,7 +653,7 @@ extension PocketSourceTests { } func test_savesSearches_withFreeUser_showSearchResults_searchUrl() throws { - user.status = .free + user.setPremiumStatus(false) let url = URL(string: "testUrl.saved") try setupLocalSavesSearch(with: url) @@ -665,7 +666,7 @@ extension PocketSourceTests { } func test_savesSearches_withPremiumUser_showSearchResults_searchUrl() throws { - user.status = .premium + user.setPremiumStatus(true) let url = URL(string: "testUrl.saved") try setupLocalSavesSearch(with: url) @@ -679,7 +680,7 @@ extension PocketSourceTests { } func test_savesSearches_withFreeUser_showSearchResults_doesNotSearchTag() throws { - user.status = .free + user.setPremiumStatus(false) _ = createItemsWithTags(2) @@ -694,7 +695,7 @@ extension PocketSourceTests { } func test_savesSearches_withPremiumUser_showSearchResults_searchTag() throws { - user.status = .premium + user.setPremiumStatus(true) _ = createItemsWithTags(2) diff --git a/PocketKit/Tests/SyncTests/Support/MockUser.swift b/PocketKit/Tests/SyncTests/Support/MockUser.swift index 4ff5b061e..5022a8670 100644 --- a/PocketKit/Tests/SyncTests/Support/MockUser.swift +++ b/PocketKit/Tests/SyncTests/Support/MockUser.swift @@ -1,7 +1,10 @@ import SharedPocketKit +import Combine class MockUser: User { - var status: SharedPocketKit.Status? + @Published public private(set) var status: Status = .unknown + public var statusPublisher: Published.Publisher { $status } + public var publishedStatus: Published { _status } private var implementations: [String: Any] = [:] private var calls: [String: [Any]] = [:] } @@ -19,6 +22,12 @@ extension MockUser { implementations[Self.setStatus] = impl } + func stubStandardSetStatus() { + implementations[Self.setStatus] = { isPremium in + self.status = isPremium ? .premium : .free + } + } + func setPremiumStatus(_ isPremium: Bool) { guard let impl = implementations[Self.setStatus] as? SetStatusImpl else { fatalError("\(Self.self)#\(#function) has not been stubbed")