diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png index a28024f..0425845 100644 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/WatchApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/iPhoneApp/PaywallView.swift b/iPhoneApp/PaywallView.swift index b756155..5073844 100644 --- a/iPhoneApp/PaywallView.swift +++ b/iPhoneApp/PaywallView.swift @@ -5,17 +5,14 @@ import StoreKit private let termsOfUseURL = URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")! private let privacyPolicyURL = URL(string: "https://douinc.github.io/clicker/PRIVACY_POLICY.html")! -/// Custom paywall view with price-first hierarchy to comply with App Store guideline 3.1.2. -/// The billed amount is the most prominent pricing element; trial info is subordinate. +/// Paywall using Apple's SubscriptionStoreView for full guideline 3.1.2 compliance. +/// Automatically displays subscription title, price, length, and legal links. struct PaywallView: View { @Environment(SubscriptionManager.self) private var subscriptionManager @Environment(\.dismiss) private var dismiss - @Environment(\.scenePhase) private var scenePhase - @State private var isPurchasing = false - @State private var errorMessage: String? var body: some View { - ScrollView { + SubscriptionStoreView(groupID: subscriptionGroupID) { VStack(spacing: 24) { // Header VStack(spacing: 16) { @@ -29,7 +26,6 @@ struct PaywallView: View { Text("Control your presentations with ease") .foregroundStyle(.secondary) } - .padding(.top, 40) // Features VStack(alignment: .leading, spacing: 12) { @@ -39,146 +35,23 @@ struct PaywallView: View { } .padding(.horizontal, 8) - Spacer(minLength: 24) - - // Pricing section — billed amount is largest and most prominent - if let product = subscriptionManager.yearlyProduct { - PricingSection(product: product) - } else if subscriptionManager.productLoadFailed { - VStack(spacing: 12) { - Text("Unable to load pricing") - .font(.subheadline) - .foregroundStyle(.secondary) - Button("Retry") { - Task { - await subscriptionManager.loadProducts() - } - } - .font(.subheadline.weight(.medium)) - .foregroundStyle(.blue) - } - .padding() - } else { - ProgressView("Loading...") - .padding() - } - - // Subscribe button - if let product = subscriptionManager.yearlyProduct { - Button { - Task { await purchase(product) } - } label: { - Group { - if isPurchasing { - ProgressView() - .tint(.white) - } else { - Text("Subscribe") - .font(.title3.weight(.semibold)) - } - } - .frame(maxWidth: .infinity) - .frame(height: 54) - .background(.blue, in: RoundedRectangle(cornerRadius: 14)) - .foregroundStyle(.white) - } - .disabled(isPurchasing) - .padding(.horizontal, 4) - } - - // Error message - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundStyle(.red) - } - - // Restore purchases - Button { - Task { - await subscriptionManager.restorePurchases() - } - } label: { - Text("Restore Purchases") - .font(.subheadline) - .foregroundStyle(.blue) - } - - // Legal links + // Legal links — functional links required by guideline 3.1.2(c) HStack(spacing: 16) { - Link("Terms of Use", destination: termsOfUseURL) + Link("Terms of Use (EULA)", destination: termsOfUseURL) Text("·").foregroundStyle(.secondary) Link("Privacy Policy", destination: privacyPolicyURL) } - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom, 24) - } - .padding(.horizontal, 24) - } - .background(Color(uiColor: .systemBackground)) - .task { - await subscriptionManager.loadProducts() - await subscriptionManager.updateSubscriptionStatus() - } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - Task { - await subscriptionManager.updateSubscriptionStatus() - } + .font(.footnote) } + .padding(.top, 40) } - } - - private func purchase(_ product: Product) async { - isPurchasing = true - errorMessage = nil - do { - let success = try await subscriptionManager.purchase(product) - if success { + .subscriptionStoreButtonLabel(.multiline) + .storeButton(.visible, for: .restorePurchases) + .onInAppPurchaseCompletion { _, result in + if case .success(.success(_)) = result { + await subscriptionManager.updateSubscriptionStatus() dismiss() } - } catch { - errorMessage = error.localizedDescription - } - isPurchasing = false - } -} - -/// Pricing section with billed amount as the most prominent element. -/// Trial/introductory info is subordinate in size, color, and position. -struct PricingSection: View { - let product: Product - - var body: some View { - VStack(spacing: 8) { - // Primary: billed amount — largest and most prominent - Text(product.displayPrice + "/year") - .font(.system(size: 36, weight: .bold, design: .rounded)) - .foregroundStyle(.primary) - - // Subordinate: trial info — smaller font, secondary color, below price - if let subscription = product.subscription, - let introOffer = subscription.introductoryOffer { - Text(introOfferText(introOffer)) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 8) - } - - private func introOfferText(_ offer: Product.SubscriptionOffer) -> String { - switch offer.paymentMode { - case .freeTrial: - let days = offer.period.value * (offer.period.unit == .day ? 1 : offer.period.unit == .week ? 7 : 30) - return "\(days)-day free trial included" - case .payUpFront: - return "Introductory price: \(offer.displayPrice)" - case .payAsYouGo: - return "Introductory price: \(offer.displayPrice)/period" - default: - return "" } } }