From efdee1668917e08149f355f5e5a2b87f71714e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Wallner-G=C3=A9hri?= <68284225+mpwg@users.noreply.github.com> Date: Sat, 2 May 2026 17:09:27 +0200 Subject: [PATCH] Wire Symi Plus paywall to StoreKit 2 --- Symi/Configs/Debug.xcconfig | 1 + Symi/Configs/Release.xcconfig | 1 + Symi/Info.plist | 2 + Symi/Sources/App/AppContainer.swift | 4 + .../Core/Monetization/SymiPlusStore.swift | 254 ++++++++++++++++++ .../Features/Settings/SettingsView.swift | 2 +- .../Features/Settings/SymiPlusView.swift | 77 +++++- SymiTests/FeatureFlagsTests.swift | 18 ++ 8 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 Symi/Sources/Core/Monetization/SymiPlusStore.swift diff --git a/Symi/Configs/Debug.xcconfig b/Symi/Configs/Debug.xcconfig index 741e81d..424b332 100644 --- a/Symi/Configs/Debug.xcconfig +++ b/Symi/Configs/Debug.xcconfig @@ -5,4 +5,5 @@ APS_ENVIRONMENT = development APPLE_DEVELOPER_TEAM_ID = SENTRY_DSN = TELEMETRY_APP_ID = +SYMI_PLUS_PRODUCT_IDS = eu.mpwg.MigraineTracker.symiPlus.yearly #include? "LocalSecrets.xcconfig" diff --git a/Symi/Configs/Release.xcconfig b/Symi/Configs/Release.xcconfig index c1fce44..373a83f 100644 --- a/Symi/Configs/Release.xcconfig +++ b/Symi/Configs/Release.xcconfig @@ -5,4 +5,5 @@ APS_ENVIRONMENT = production APPLE_DEVELOPER_TEAM_ID = SENTRY_DSN = TELEMETRY_APP_ID = +SYMI_PLUS_PRODUCT_IDS = eu.mpwg.MigraineTracker.symiPlus.yearly #include? "LocalSecrets.xcconfig" diff --git a/Symi/Info.plist b/Symi/Info.plist index 339559e..85e869d 100644 --- a/Symi/Info.plist +++ b/Symi/Info.plist @@ -4,6 +4,8 @@ TELEMETRY_APP_ID $(TELEMETRY_APP_ID) + SYMI_PLUS_PRODUCT_IDS + $(SYMI_PLUS_PRODUCT_IDS) ITSAppUsesNonExemptEncryption SENTRY_DSN diff --git a/Symi/Sources/App/AppContainer.swift b/Symi/Sources/App/AppContainer.swift index 2bf8600..ca77392 100644 --- a/Symi/Sources/App/AppContainer.swift +++ b/Symi/Sources/App/AppContainer.swift @@ -102,6 +102,9 @@ final class AppContainer { usageDataConsentService: usageDataConsentService ) }, + makeSymiPlusStore: { + SymiPlusStore() + }, dataExport: dataExport ) let therapy = TherapyFeatureDependencies( @@ -191,6 +194,7 @@ struct TherapyFeatureDependencies { @MainActor struct SettingsFeatureDependencies { let makeSettingsController: () -> SettingsController + let makeSymiPlusStore: () -> SymiPlusStore let dataExport: DataExportFeatureDependencies } diff --git a/Symi/Sources/Core/Monetization/SymiPlusStore.swift b/Symi/Sources/Core/Monetization/SymiPlusStore.swift new file mode 100644 index 0000000..22d9235 --- /dev/null +++ b/Symi/Sources/Core/Monetization/SymiPlusStore.swift @@ -0,0 +1,254 @@ +import Foundation +import StoreKit + +nonisolated enum SymiPlusProductConfiguration { + static let defaultProductID = "eu.mpwg.MigraineTracker.symiPlus.yearly" + + static var productIDs: [String] { + productIDs(from: Bundle.main.object(forInfoDictionaryKey: "SYMI_PLUS_PRODUCT_IDS") as? String) + } + + static func productIDs(from rawValue: String?) -> [String] { + guard + let rawValue, + !rawValue.isEmpty, + !rawValue.contains("$(") + else { + return [defaultProductID] + } + + let productIDs = rawValue + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + return productIDs.isEmpty ? [defaultProductID] : productIDs + } +} + +enum SymiPlusStoreLoadState: Equatable { + case idle + case loading + case loaded + case unavailable + case failed(String) +} + +enum SymiPlusEntitlementState: Equatable { + case unknown + case inactive + case active +} + +enum SymiPlusPurchaseState: Equatable { + case idle + case purchasing + case restoring + case pending + case succeeded + case failed(String) +} + +@MainActor +@Observable +final class SymiPlusStore { + private(set) var loadState: SymiPlusStoreLoadState = .idle + private(set) var entitlementState: SymiPlusEntitlementState = .unknown + private(set) var purchaseState: SymiPlusPurchaseState = .idle + private(set) var products: [Product] = [] + private(set) var selectedProduct: Product? + + private let productIDs: [String] + @ObservationIgnored private var transactionUpdatesTask: Task? + + init(productIDs: [String] = SymiPlusProductConfiguration.productIDs) { + self.productIDs = productIDs + self.transactionUpdatesTask = listenForTransactionUpdates() + } + + deinit { + transactionUpdatesTask?.cancel() + } + + var primaryButtonTitle: String { + if entitlementState == .active { + return "Symi Plus aktiv" + } + + switch loadState { + case .idle, .loading: + return "App Store laden..." + case .loaded: + if let price = selectedProduct?.displayPrice { + return "Für \(price) aktivieren" + } + return "Symi Plus aktivieren" + case .unavailable, .failed: + return "Nicht verfügbar" + } + } + + var footerText: String { + switch loadState { + case .loaded: + if let displayName = selectedProduct?.displayName, let price = selectedProduct?.displayPrice { + return "\(displayName) wird direkt aus dem App Store geladen: \(price).\nDu kannst Käufe jederzeit wiederherstellen." + } + return "Der aktuelle Preis wird direkt aus dem App Store geladen.\nDu kannst Käufe jederzeit wiederherstellen." + case .unavailable: + return "Symi Plus ist im App Store noch nicht verfügbar.\nPrüfe die Product-ID in App Store Connect." + case .failed(let message): + return "\(message)\nDu kannst Käufe jederzeit wiederherstellen." + case .idle, .loading: + return "Der aktuelle Preis wird direkt aus dem App Store geladen.\nDu kannst Käufe jederzeit wiederherstellen." + } + } + + var statusMessage: String? { + switch (entitlementState, purchaseState) { + case (.active, _): + return "Symi Plus ist auf diesem Apple Account aktiv." + case (_, .pending): + return "Der Kauf wartet auf Bestätigung." + case (_, .succeeded): + return "Kauf erfolgreich wiederhergestellt." + case (_, .failed(let message)): + return message + default: + return nil + } + } + + var canStartPurchase: Bool { + selectedProduct != nil && + entitlementState != .active && + loadState == .loaded && + !isWorking + } + + var canRestorePurchases: Bool { + !isWorking + } + + private var isWorking: Bool { + switch purchaseState { + case .purchasing, .restoring: + return true + case .idle, .pending, .succeeded, .failed: + return loadState == .loading + } + } + + func loadProducts() async { + guard loadState != .loading else { + return + } + + loadState = .loading + + do { + let products = try await Product.products(for: productIDs) + self.products = products.sorted { $0.displayName < $1.displayName } + self.selectedProduct = self.products.first + await refreshEntitlements() + loadState = self.products.isEmpty ? .unavailable : .loaded + } catch { + loadState = .failed("Der App Store konnte nicht geladen werden.") + } + } + + func purchaseSelectedProduct() async { + if selectedProduct == nil { + await loadProducts() + } + + guard let selectedProduct else { + purchaseState = .failed("Symi Plus ist im App Store noch nicht verfügbar.") + return + } + + purchaseState = .purchasing + + do { + let result = try await selectedProduct.purchase() + + switch result { + case .success(let verificationResult): + let transaction = try Self.verified(verificationResult) + await transaction.finish() + await refreshEntitlements() + purchaseState = .succeeded + case .pending: + purchaseState = .pending + case .userCancelled: + purchaseState = .idle + @unknown default: + purchaseState = .failed("Der Kauf konnte nicht abgeschlossen werden.") + } + } catch { + purchaseState = .failed("Der Kauf konnte nicht abgeschlossen werden.") + } + } + + func restorePurchases() async { + purchaseState = .restoring + + do { + try await AppStore.sync() + await refreshEntitlements() + purchaseState = entitlementState == .active ? .succeeded : .failed("Es wurden keine aktiven Symi-Plus-Käufe gefunden.") + } catch { + purchaseState = .failed("Käufe konnten nicht wiederhergestellt werden.") + } + } + + func refreshEntitlements() async { + var hasActiveEntitlement = false + + for await verificationResult in Transaction.currentEntitlements { + guard let transaction = try? Self.verified(verificationResult) else { + continue + } + + if productIDs.contains(transaction.productID), transaction.revocationDate == nil { + hasActiveEntitlement = true + break + } + } + + entitlementState = hasActiveEntitlement ? .active : .inactive + } + + private func listenForTransactionUpdates() -> Task { + Task { [weak self] in + for await verificationResult in Transaction.updates { + guard let self else { + return + } + + guard let transaction = try? Self.verified(verificationResult) else { + await MainActor.run { + self.purchaseState = .failed("Die App-Store-Transaktion konnte nicht verifiziert werden.") + } + continue + } + + await transaction.finish() + await self.refreshEntitlements() + } + } + } + + private static func verified(_ result: VerificationResult) throws -> T { + switch result { + case .verified(let value): + return value + case .unverified: + throw StoreKitVerificationError.unverifiedTransaction + } + } +} + +private enum StoreKitVerificationError: Error { + case unverifiedTransaction +} diff --git a/Symi/Sources/Features/Settings/SettingsView.swift b/Symi/Sources/Features/Settings/SettingsView.swift index 39cd698..059d771 100644 --- a/Symi/Sources/Features/Settings/SettingsView.swift +++ b/Symi/Sources/Features/Settings/SettingsView.swift @@ -54,7 +54,7 @@ struct SettingsView: View { Section("App") { if featureFlags.isEnabled(.monetization) { NavigationLink { - SymiPlusView() + SymiPlusView(store: dependencies.makeSymiPlusStore()) } label: { Label("Symi Plus", systemImage: "sparkles") } diff --git a/Symi/Sources/Features/Settings/SymiPlusView.swift b/Symi/Sources/Features/Settings/SymiPlusView.swift index cd784f1..2df08f5 100644 --- a/Symi/Sources/Features/Settings/SymiPlusView.swift +++ b/Symi/Sources/Features/Settings/SymiPlusView.swift @@ -1,9 +1,7 @@ import SwiftUI struct SymiPlusView: View { - var primaryButtonTitle = "Kostenlos aktivieren" - var activate: () -> Void = {} - var restorePurchases: () -> Void = {} + @State private var store: SymiPlusStore @Environment(\.colorScheme) private var colorScheme @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -11,12 +9,16 @@ struct SymiPlusView: View { private let privacyURL = URL(string: "https://symiapp.com/privacy")! private let termsURL = URL(string: "https://symiapp.com/terms")! + init(store: SymiPlusStore = SymiPlusStore()) { + _store = State(initialValue: store) + } + var body: some View { ScrollView { VStack(spacing: SymiSpacing.symiPlusContentSpacing) { - SymiPlusHeroCard() + SymiPlusHeroCard(entitlementState: store.entitlementState) - featureList + featureList(footerText: store.footerText, statusMessage: store.statusMessage) } .padding(.horizontal, horizontalPadding) .padding(.top, SymiSpacing.symiPlusContentTopPadding) @@ -28,11 +30,21 @@ struct SymiPlusView: View { .background(screenBackground.ignoresSafeArea()) .safeAreaInset(edge: .bottom) { SymiPlusBottomCTAView( - primaryButtonTitle: primaryButtonTitle, + primaryButtonTitle: store.primaryButtonTitle, privacyURL: privacyURL, termsURL: termsURL, - activate: activate, - restorePurchases: restorePurchases + canStartPurchase: store.canStartPurchase, + canRestorePurchases: store.canRestorePurchases, + activate: { + Task { + await store.purchaseSelectedProduct() + } + }, + restorePurchases: { + Task { + await store.restorePurchases() + } + } ) } .navigationTitle("Symi Plus") @@ -40,15 +52,22 @@ struct SymiPlusView: View { .toolbarBackground(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) .tint(AppTheme.petrol(for: colorScheme)) + .task { + await store.loadProducts() + } } - private var featureList: some View { + private func featureList(footerText: String, statusMessage: String?) -> some View { VStack(spacing: SymiSpacing.symiPlusFeatureSpacing) { ForEach(Self.featureRows) { row in SymiPlusFeatureCard(row: row) } - SymiPlusFooterInfoView() + if let statusMessage { + SymiPlusStatusView(message: statusMessage) + } + + SymiPlusFooterInfoView(text: footerText) } } @@ -86,6 +105,8 @@ struct SymiPlusView: View { } private struct SymiPlusHeroCard: View { + let entitlementState: SymiPlusEntitlementState + @Environment(\.colorScheme) private var colorScheme @State private var heroVisible = false @@ -107,7 +128,7 @@ private struct SymiPlusHeroCard: View { .lineLimit(1) .minimumScaleFactor(SymiTypography.symiPlusTitleScaleFactor) - Text("✦ Für kurze Zeit kostenlos") + Text(badgeText) .font(.callout.weight(.semibold)) .foregroundStyle(AppTheme.petrol(for: colorScheme)) .lineLimit(1) @@ -116,7 +137,7 @@ private struct SymiPlusHeroCard: View { .padding(.vertical, SymiSpacing.symiPlusBadgeVerticalPadding) .background(SymiColors.symiPlusBadgeFill.color, in: Capsule(style: .continuous)) - Text("Aktiviere Symi Plus jetzt kostenlos und sichere dir den Einführungsvorteil.") + Text(descriptionText) .font(.body) .foregroundStyle(AppTheme.textPrimary(for: colorScheme)) .fixedSize(horizontal: false, vertical: true) @@ -148,6 +169,16 @@ private struct SymiPlusHeroCard: View { } } } + + private var badgeText: String { + entitlementState == .active ? "✦ Aktiv" : "✦ Direkt über den App Store" + } + + private var descriptionText: String { + entitlementState == .active + ? "Symi Plus ist für deinen Apple Account freigeschaltet." + : "Aktiviere Symi Plus sicher über den App Store und unterstütze die Weiterentwicklung." + } } private struct SymiPlusFeatureRow: Identifiable { @@ -203,8 +234,10 @@ private struct SymiPlusFeatureCard: View { } private struct SymiPlusFooterInfoView: View { + let text: String + var body: some View { - Text("Der aktuelle Preis wird direkt aus dem App Store geladen.\nDu kannst Käufe jederzeit wiederherstellen.") + Text(text) .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -215,10 +248,26 @@ private struct SymiPlusFooterInfoView: View { } } +private struct SymiPlusStatusView: View { + let message: String + + var body: some View { + Text(message) + .font(.footnote.weight(.semibold)) + .foregroundStyle(AppTheme.symiPetrol) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: SymiSize.symiPlusFooterMaxWidth) + .padding(.horizontal, SymiSpacing.symiPlusFooterHorizontalPadding) + } +} + private struct SymiPlusBottomCTAView: View { let primaryButtonTitle: String let privacyURL: URL let termsURL: URL + let canStartPurchase: Bool + let canRestorePurchases: Bool let activate: () -> Void let restorePurchases: () -> Void @@ -238,6 +287,7 @@ private struct SymiPlusBottomCTAView: View { .frame(maxWidth: .infinity, minHeight: SymiSize.symiPlusButtonHeight) } .buttonStyle(SymiPlusPrimaryButtonStyle()) + .disabled(!canStartPurchase) Button(action: restorePurchases) { Text("Käufe wiederherstellen") @@ -245,6 +295,7 @@ private struct SymiPlusBottomCTAView: View { .frame(maxWidth: .infinity, minHeight: SymiSize.symiPlusButtonHeight) } .buttonStyle(SymiPlusSecondaryButtonStyle()) + .disabled(!canRestorePurchases) HStack(spacing: SymiSpacing.symiPlusFooterLinkSpacing) { Link(destination: privacyURL) { diff --git a/SymiTests/FeatureFlagsTests.swift b/SymiTests/FeatureFlagsTests.swift index 4557585..3b211e7 100644 --- a/SymiTests/FeatureFlagsTests.swift +++ b/SymiTests/FeatureFlagsTests.swift @@ -61,4 +61,22 @@ struct FeatureFlagsTests { #expect(configuration.featureFlags.isEnabled(.monetization)) } + + @Test + func symiPlusProductConfigurationParsesCommaSeparatedProductIDs() { + let productIDs = SymiPlusProductConfiguration.productIDs( + from: "eu.mpwg.MigraineTracker.symiPlus.monthly, eu.mpwg.MigraineTracker.symiPlus.yearly" + ) + + #expect(productIDs == [ + "eu.mpwg.MigraineTracker.symiPlus.monthly", + "eu.mpwg.MigraineTracker.symiPlus.yearly" + ]) + } + + @Test + func symiPlusProductConfigurationFallsBackForMissingBuildSetting() { + #expect(SymiPlusProductConfiguration.productIDs(from: nil) == [SymiPlusProductConfiguration.defaultProductID]) + #expect(SymiPlusProductConfiguration.productIDs(from: "$(SYMI_PLUS_PRODUCT_IDS)") == [SymiPlusProductConfiguration.defaultProductID]) + } }