diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 24072788c..451bb0682 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; }; 50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; }; 50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; }; + 50A485DE2E840A8000F220CE /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50A485DD2E840A8000F220CE /* StoreKit.framework */; }; 50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; }; 50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */; }; 9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; }; @@ -57,6 +58,8 @@ 50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = ""; }; 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 50A485DD2E840A8000F220CE /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; + 50BBC54E2E840DBC0067C5E2 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Products.storekit; sourceTree = ""; }; 50CECF1E2E7C1084004487AA /* AffineResources */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineResources; sourceTree = ""; }; 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBridgedWindowScript.swift; sourceTree = ""; }; 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AffineViewController+AIButton.swift"; sourceTree = ""; }; @@ -96,6 +99,7 @@ buildActionMask = 2147483647; files = ( 5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */, + 50A485DE2E840A8000F220CE /* StoreKit.framework in Frameworks */, 5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */, 9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */, 5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */, @@ -110,6 +114,7 @@ 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { isa = PBXGroup; children = ( + 50A485DD2E840A8000F220CE /* StoreKit.framework */, C4C97C6B2D03027900BC2AD1 /* libaffine_mobile_native.a */, 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */, BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */, @@ -174,6 +179,7 @@ 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */, 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */, 5027D47F2E7C611900ADD25A /* Tools.swift */, + 50BBC54E2E840DBC0067C5E2 /* Products.storekit */, 9D90BE1D2CCB9876006677DB /* Assets.xcassets */, 9D90BE1E2CCB9876006677DB /* capacitor.config.json */, 9D90BE1F2CCB9876006677DB /* config.xml */, @@ -232,7 +238,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 0920; + LastUpgradeCheck = 2600; TargetAttributes = { 504EC3031FED79650016851F = { CreatedOnToolsVersion = 9.2; @@ -371,6 +377,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -381,6 +388,7 @@ CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; @@ -388,8 +396,10 @@ CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -422,6 +432,7 @@ ONLY_ACTIVE_ARCH = YES; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -432,6 +443,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -442,6 +454,7 @@ CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; @@ -449,8 +462,10 @@ CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -476,6 +491,7 @@ MTL_ENABLE_DEBUG_INFO = NO; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -491,7 +507,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 12; - DEVELOPMENT_TEAM = 964G86XT2P; + DEVELOPMENT_TEAM = 73YMMDVT2M; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 16.5; @@ -527,7 +543,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 12; - DEVELOPMENT_TEAM = 964G86XT2P; + DEVELOPMENT_TEAM = 73YMMDVT2M; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 16.5; diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNE.xcscheme b/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNE.xcscheme index dec463b30..eb1fc1a2a 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNE.xcscheme +++ b/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNE.xcscheme @@ -1,6 +1,6 @@ + + : View { + var axes: Axis.Set = [.vertical] + var showsIndicators = true + @Binding var offset: CGPoint + @ViewBuilder var content: () -> Content + + private let coordinateSpaceName = UUID() + + var body: some View { + ScrollView(axes, showsIndicators: showsIndicators) { + PositionObservingView( + coordinateSpace: .named(coordinateSpaceName), + position: Binding( + get: { offset }, + set: { newOffset in + offset = CGPoint( + x: -newOffset.x, + y: -newOffset.y + ) + } + ), + content: content + ) + } + .coordinateSpace(name: coordinateSpaceName) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/PositionObservingView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/PositionObservingView.swift new file mode 100644 index 000000000..dd1ebed92 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/PositionObservingView.swift @@ -0,0 +1,37 @@ +// +// PositionObservingView.swift +// AffinePaywall +// +// Created by qaq on 9/23/25. +// + +import SwiftUI + +struct PositionObservingView: View { + var coordinateSpace: CoordinateSpace + @Binding var position: CGPoint + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .background(GeometryReader { geometry in + Color.clear.preference( + key: PreferenceKey.self, + value: geometry.frame(in: coordinateSpace).origin + ) + }) + .onPreferenceChange(PreferenceKey.self) { position in + self.position = position + } + } +} + +private extension PositionObservingView { + struct PreferenceKey: SwiftUI.PreferenceKey { + static var defaultValue: CGPoint { .zero } + + static func reduce(value _: inout CGPoint, nextValue _: () -> CGPoint) { + // No-op + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView.swift index b6f078468..82e0aa976 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView.swift @@ -10,11 +10,14 @@ import SwiftUI struct SKUnitIntelligentDetailView: View { @StateObject var viewModel: ViewModel + + @State var detailIndexInSwitching: UUID? = nil @State var detailIndex: Int = 0 { didSet { lastInteractionDate = Date() } } @State var lastInteractionDate: Date = .init() + @State var scrollOffset: CGPoint = .zero let timer = Timer .publish(every: 5, on: .main, in: .common) @@ -28,15 +31,11 @@ struct SKUnitIntelligentDetailView: View { let height = r.size.height let width = r.size.width ScrollViewReader { scrollView in - ScrollView(.horizontal, showsIndicators: false) { - GeometryReader { geometry in - Color.clear - .preference( - key: ViewOffsetKey.self, - value: geometry.frame(in: .named("scrollView")).origin - ) - } - .frame(width: 0, height: 0) + OffsetObservingScrollView( + axes: .horizontal, + showsIndicators: false, + offset: $scrollOffset + ) { HStack(spacing: 0) { ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in let feature = Self.features[featureIndex] @@ -47,13 +46,6 @@ struct SKUnitIntelligentDetailView: View { } } } - .coordinateSpace(name: "scrollView") - .onPreferenceChange(ViewOffsetKey.self) { newValue in - let page = Int(round(-newValue.x / width)) - guard page != detailIndex else { return } - guard page >= 0, page < Self.features.count else { return } - detailIndex = page - } .frame(height: height) .onChange(of: detailIndex) { newValue in withAnimation(.spring) { @@ -61,6 +53,15 @@ struct SKUnitIntelligentDetailView: View { } } } + .onChange(of: scrollOffset) { _ in + guard detailIndexInSwitching == nil else { return } + guard width > 0 else { return } + let offset = scrollOffset.x + let newIndex = Int((offset + width / 2) / width) + if newIndex != detailIndex, + (0 ..< Self.features.count).contains(newIndex) + { detailIndex = newIndex } + } } PageDotsView( @@ -68,6 +69,12 @@ struct SKUnitIntelligentDetailView: View { total: Self.features.count ) { index in detailIndex = index + let token = UUID() + detailIndexInSwitching = token + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + guard detailIndexInSwitching == token else { return } + detailIndexInSwitching = nil + } } } .onReceive(timer) { _ in diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Believer/SKUnitBelieverDetailView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Believer/SKUnitBelieverDetailView.swift deleted file mode 100644 index a16a41774..000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Believer/SKUnitBelieverDetailView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// SKUnitBelieverDetailView.swift -// AffinePaywall -// -// Created by qaq on 9/18/25. -// - -import SwiftUI - -struct SKUnitBelieverDetailView: View { - @StateObject var viewModel: ViewModel - - let features: [Feature] = [ - .init("Everything in AFFiNE Pro"), - .init("Life-time Personal usage"), - .init("1TB Cloud Storage"), - ] - - var body: some View { - VStack(spacing: 24) { - HeadlineView(viewModel: viewModel) - Image("BELIVER_ICON", bundle: .module) - .resizable() - .aspectRatio(contentMode: .fit) - ForEach(features.indices, id: \.self) { index in - let feature = features[index] - ProFeatureRowView(feature: feature, index: index) - } - } - } -} - -#Preview { - SKUnitBelieverDetailView(viewModel: .vmPreviewForBeliever) - .padding() -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/CategorySelectionView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/CategorySelectionView.swift index 957b6078b..761028425 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/CategorySelectionView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/CategorySelectionView.swift @@ -61,7 +61,6 @@ struct CategorySelectionView: View { return VStack(alignment: .leading, spacing: 12) { CategorySelectionView(selectedTab: .pro, onSelect: { _ in }) CategorySelectionView(selectedTab: .ai, onSelect: { _ in }) - CategorySelectionView(selectedTab: .believer, onSelect: { _ in }) Divider() PreviewWrapper() } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/HeadlineView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/HeadlineView.swift index a8939712a..fbf9e6f13 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/HeadlineView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/HeadlineView.swift @@ -13,7 +13,7 @@ struct HeadlineView: View { var body: some View { VStack(spacing: 8) { Text(viewModel.selectedUnit.primaryText) - .font(.system(size: 24, weight: .heavy)) + .font(.system(size: 24, weight: .semibold)) .contentTransition(.numericText()) .animation(.spring.speed(2), value: viewModel.category) .padding(.top, 8) diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PricingOptionView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PackageOptionView.swift similarity index 95% rename from packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PricingOptionView.swift rename to packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PackageOptionView.swift index a00058e9b..d3a89c7e7 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PricingOptionView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PackageOptionView.swift @@ -1,5 +1,5 @@ // -// PricingOptionView.swift +// PackageOptionView.swift // AffinePaywall // // Created by qaq on 9/18/25. @@ -8,7 +8,7 @@ import AffineResources import SwiftUI -struct PricingOptionView: View { +struct PackageOptionView: View { let price: String let description: String var badge: String @@ -102,12 +102,12 @@ struct PricingOptionView: View { #Preview { VStack(spacing: 16) { HStack(spacing: 16) { - PricingOptionView( + PackageOptionView( price: "$7.99", description: "Monthly", isSelected: false ) {} - PricingOptionView( + PackageOptionView( price: "$6.75", description: "Annually", badge: "Save 15%", @@ -115,13 +115,13 @@ struct PricingOptionView: View { ) {} } HStack(spacing: 16) { - PricingOptionView( + PackageOptionView( price: "$114514", description: "Monthly", badge: "Most Popular", isSelected: true ) {} - PricingOptionView( + PackageOptionView( price: "$6.75", description: "Annually", badge: "Save 15%", diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift index b6e010f9d..6ce814039 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift @@ -11,36 +11,67 @@ import SwiftUI struct PurchaseFooterView: View { @StateObject var viewModel: ViewModel + var isPurchased: Bool { + let package = viewModel.selectePackageOption + return viewModel.purchasedItems.contains(package.productIdentifier) + } + var body: some View { VStack(spacing: 16) { - if viewModel.availablePricingOptions.count > 1 { + if viewModel.availablePackageOptions.count > 1 { HStack(spacing: 8) { - ForEach(viewModel.availablePricingOptions) { option in - PricingOptionView( + ForEach(viewModel.availablePackageOptions) { option in + PackageOptionView( price: option.price, description: option.description, badge: option.badge ?? "", - isSelected: option.id == viewModel.selectedPricingIdentifier + isSelected: option.id == viewModel.selectedPackageIdentifier ) { - viewModel.select(pricingOption: option) + viewModel.select(packageOption: option) } } } + .disabled(isPurchased) } - TheGiveMeMoneyButtonView( - primaryTitle: viewModel.selectedPricingOption.primaryTitle, - secondaryTitle: viewModel.selectedPricingOption.secondaryTitle, - callback: viewModel.purchase - ) + if viewModel.updating { + TheGiveMeMoneyButtonView( + primaryTitle: "Height Placeholder", + secondaryTitle: "", + isPurchased: false + ) {} + .hidden() + .background(AffineColors.buttonPrimary.color) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + ProgressView() + .progressViewStyle(.circular) + } + .transition(.opacity) + } else { + TheGiveMeMoneyButtonView( + primaryTitle: viewModel.selectePackageOption.primaryTitle, + secondaryTitle: viewModel.selectePackageOption.secondaryTitle, + isPurchased: isPurchased, + callback: viewModel.purchase + ) + .transition(.opacity) + } Button(action: viewModel.restore) { - Text("Restore Purchase") + if isPurchased { + Text("Already Purchased") + } else { + Text("Restore Purchase") + } } .font(.system(size: 12)) .buttonStyle(.plain) .foregroundStyle(AffineColors.textSecondary.color) + .opacity(viewModel.products.isEmpty ? 0 : 1) + .disabled(isPurchased) } + .animation(.spring, value: viewModel.updating) } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/TheGiveMeMoneyButtonView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/TheGiveMeMoneyButtonView.swift index b92b78cfb..b56fa459a 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/TheGiveMeMoneyButtonView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/TheGiveMeMoneyButtonView.swift @@ -11,43 +11,55 @@ import SwiftUI struct TheGiveMeMoneyButtonView: View { let primaryTitle: String let secondaryTitle: String + let isPurchased: Bool let callback: () -> Void init( primaryTitle: String = "", secondaryTitle: String = "", + isPurchased: Bool, callback: @escaping () -> Void = {} ) { self.primaryTitle = primaryTitle self.secondaryTitle = secondaryTitle + self.isPurchased = isPurchased self.callback = callback } var body: some View { Button { callback() } label: { - HStack(spacing: 4) { - if !primaryTitle.isEmpty { - Text(primaryTitle) - .bold() - .font(.system(size: 16)) - .contentTransition(.numericText()) - } - if !secondaryTitle.isEmpty { - Text("(\(secondaryTitle))") - .font(.system(size: 12)) - .opacity(0.8) - .contentTransition(.numericText()) + if isPurchased { + Image(systemName: "checkmark") + .foregroundColor(AffineColors.layerPureWhite.color) + .font(.system(size: 16, weight: .bold)) + .padding(12) + } else { + HStack(spacing: 4) { + if !primaryTitle.isEmpty { + Text(primaryTitle) + .bold() + .font(.system(size: 16)) + .contentTransition(.numericText()) + } + if !secondaryTitle.isEmpty { + Text("(\(secondaryTitle))") + .font(.system(size: 12)) + .opacity(0.8) + .contentTransition(.numericText()) + } } + .foregroundColor(AffineColors.layerPureWhite.color) + .padding(12) } - .foregroundColor(AffineColors.layerPureWhite.color) - .padding(12) } .animation(.spring, value: primaryTitle) .animation(.spring, value: secondaryTitle) .buttonStyle(.plain) .frame(maxWidth: .infinity) + .frame(minHeight: 32) .background(AffineColors.buttonPrimary.color) .clipShape(RoundedRectangle(cornerRadius: 8)) + .disabled(isPurchased) } } @@ -57,19 +69,28 @@ struct TheGiveMeMoneyButtonView: View { VStack(spacing: 16) { TheGiveMeMoneyButtonView( primaryTitle: "Upgrade for $6.75 per month", - secondaryTitle: "" + secondaryTitle: "", + isPurchased: false ) TheGiveMeMoneyButtonView( primaryTitle: "Upgrade for $10 per month", - secondaryTitle: "" + secondaryTitle: "", + isPurchased: false ) TheGiveMeMoneyButtonView( primaryTitle: "$8.9 per month", - secondaryTitle: "billed annually" + secondaryTitle: "billed annually", + isPurchased: false ) TheGiveMeMoneyButtonView( primaryTitle: "Upgrade for $499", - secondaryTitle: "" + secondaryTitle: "", + isPurchased: false + ) + TheGiveMeMoneyButtonView( + primaryTitle: "Upgrade for $499", + secondaryTitle: "", + isPurchased: true ) } .padding(32) diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeaturesCardView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeaturesCardView.swift deleted file mode 100644 index bf82bf3b7..000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeaturesCardView.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// ProFeaturesCardView.swift -// AffinePaywall -// -// Created by qaq on 9/18/25. -// - -import AffineResources -import SwiftUI - -struct ProFeaturesCardView: View { - let features: [Feature] - let headerText: String - - let timer = Timer - .publish(every: 0.08, on: .main, in: .common) - .autoconnect() - @State var animationIndex: Int64 = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - if !headerText.isEmpty { - Text(headerText) - .font(.system(size: 13)) - .foregroundColor(AffineColors.textSecondary.color) - .padding(.horizontal, 4) - } - - ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in - ProFeatureRowView(feature: feature, index: index) - .opacity(index < animationIndex ? 1 : 0) - } - } - .animation(.spring.speed(2), value: animationIndex) - .onChange(of: features) { _ in animationIndex = 0 } - .onReceive(timer) { _ in animationIndex += 1 } - .clipped() - .padding(16) - .background(AffineColors.layerBackgroundPrimary.color) - .cornerRadius(16) - .shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2) - .animation(.spring.speed(2), value: features) - } -} - -#Preview("Pro") { - ProFeaturesCardView(features: SKUnitSubcategoryProPlan.default.features, headerText: SKUnitSubcategoryProPlan.default.headerText) - .padding() - .background(Color.gray.ignoresSafeArea()) -} - -#Preview("Pro team") { - ProFeaturesCardView( - features: SKUnitSubcategoryProPlan.team.features, - headerText: SKUnitSubcategoryProPlan.team.headerText - ) - .padding() - .background(Color.gray.ignoresSafeArea()) -} - -#Preview("Self Hosted") { - ProFeaturesCardView(features: SKUnitSubcategoryProPlan.selfHost.features, headerText: SKUnitSubcategoryProPlan.selfHost.headerText) - .padding() - .background(Color.gray.ignoresSafeArea()) -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/SKUnitProDetailView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/SKUnitProDetailView.swift index 7a9e949ca..5b976fc57 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/SKUnitProDetailView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/SKUnitProDetailView.swift @@ -11,35 +11,82 @@ import SwiftUI struct SKUnitProDetailView: View { @StateObject var viewModel: ViewModel - @State var selection: SKUnitSubcategoryProPlan = .default + @State var selection: SKUnitSubcategoryProPlan + @State var headerText: String + @State var features: [Feature] + @State var animationIndex: Int64 = 0 + + let timer = Timer + .publish(every: 0.075, on: .main, in: .common) + .autoconnect() + + init(viewModel: ViewModel) { + _viewModel = .init(wrappedValue: viewModel) + let item = SKUnitSubcategoryProPlan.default + _selection = .init(initialValue: item) + _headerText = .init(initialValue: item.headerText) + _features = .init(initialValue: item.features) + } var body: some View { VStack(spacing: 24) { - Picker("Plan", selection: $selection) { - ForEach(SKUnitSubcategoryProPlan.allCases) { plan in - Text(plan.title).tag(plan) + if SKUnitSubcategoryProPlan.allCases.count > 1 { + Picker("Plan", selection: $selection) { + ForEach(SKUnitSubcategoryProPlan.allCases) { plan in + Text(plan.title).tag(plan) + } + } + .pickerStyle(.segmented) + .onChange(of: selection) { _ in + viewModel.select(subcategory: selection) } - } - .pickerStyle(.segmented) - .onChange(of: selection) { _ in - viewModel.select(subcategory: selection) } HeadlineView(viewModel: viewModel) ScrollView { - ProFeaturesCardView( - features: selection.features, - headerText: selection.headerText - ) + VStack(alignment: .leading, spacing: 16) { + if !headerText.isEmpty { + Text(headerText) + .font(.system(size: 13)) + .foregroundColor(AffineColors.textSecondary.color) + .contentTransition(.numericText()) + .transition(.opacity) + .padding(.horizontal, 4) + } + + ForEach(Array(features.enumerated()), id: \.element.id) { index, feature in + ProFeatureRowView(feature: feature, index: index) + .opacity(index < animationIndex ? 1 : 0) + } + } + .clipped() + .padding(16) + .background(AffineColors.layerBackgroundPrimary.color) + .cornerRadius(16) + .shadow(color: AffineColors.layerBorder.color.opacity(0.08), radius: 8, y: 2) .padding(16) } .padding(-16) + .animation(.spring.speed(2), value: animationIndex) + .onReceive(timer) { _ in animationIndex += 1 } .frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .top ) + .onChange(of: selection) { _ in updateSelectionContents() } + } + } + + func updateSelectionContents() { + let headerText = selection.headerText + if self.headerText != headerText { + self.headerText = headerText + } + let features = selection.features + if self.features != features { + self.features = features } } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Feature.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Feature.swift index a020af78f..307eb0e2d 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Feature.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Feature.swift @@ -7,7 +7,7 @@ import Foundation -struct Feature: Identifiable, Equatable { +struct Feature: Identifiable, Equatable, Hashable { var id = UUID() var text: String var isHighlighted: Bool // For text like "Everything in AFFINE Pro" diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift index b812bbf83..382523494 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift @@ -13,13 +13,15 @@ extension SKUnit { category: SKUnitCategory.ai, primaryText: "AFFINE AI", secondaryText: "A true multimodal AI copilot.", - pricing: [ - SKUnitPricingOption( + package: [ + SKUnitPackageOption( price: "$8.9 per month", description: "", isDefaultSelected: true, primaryTitle: "$8.9 per month", - secondaryTitle: "billed annually" + secondaryTitle: "billed annually", + productIdentifier: "app.affine.pro.ai.Annual", + revenueCatIdentifier: "app.affine.pro.ai.Annual" ), ] ), diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Believer.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Believer.swift deleted file mode 100644 index 92472b3bf..000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Believer.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// SKUnit+Believer.swift -// AffinePaywall -// -// Created by qaq on 9/18/25. -// - -import Foundation - -extension SKUnit { - static let believerUnits: [SKUnit] = [ - SKUnit( - category: SKUnitCategory.believer, - primaryText: "Believer Plan", - secondaryText: "AFFINE's Everything", - pricing: [ - SKUnitPricingOption( - price: "$499", - description: "", - isDefaultSelected: true, - primaryTitle: "Upgrade for $499", - secondaryTitle: "" - ), - ] - ), - ] -} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift index f2355972f..3488882cf 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift @@ -14,67 +14,25 @@ extension SKUnit { subcategory: SKUnitSubcategoryProPlan.default, primaryText: "Pro", secondaryText: "For family and small teams.", - pricing: [ - SKUnitPricingOption( + package: [ + SKUnitPackageOption( price: "$7.99", description: "Monthly", isDefaultSelected: false, primaryTitle: "Upgrade for $7.99/month", - secondaryTitle: "" + secondaryTitle: "", + productIdentifier: "app.affine.pro.Monthly", + revenueCatIdentifier: "app.affine.pro.Monthly" ), - SKUnitPricingOption( + SKUnitPackageOption( price: "$6.75", description: "Annual", badge: "Save 15%", isDefaultSelected: true, primaryTitle: "Upgrade for $6.75/month", - secondaryTitle: "" - ), - ] - ), - SKUnit( - category: SKUnitCategory.pro, - subcategory: SKUnitSubcategoryProPlan.team, - primaryText: "Pro team", - secondaryText: "Best for scalable teams.", - pricing: [ - SKUnitPricingOption( - price: "$12", - description: "Per seat monthly", - isDefaultSelected: false, - primaryTitle: "Upgrade for $12/month", - secondaryTitle: "" - ), - SKUnitPricingOption( - price: "$10", - description: "Annual", - badge: "Save 15%", - isDefaultSelected: true, - primaryTitle: "Upgrade for $10/month", - secondaryTitle: "" - ), - ] - ), - SKUnit( - category: SKUnitCategory.pro, - subcategory: SKUnitSubcategoryProPlan.selfHost, - primaryText: "Self Hosted team", - secondaryText: "Best for scalable teams.", - pricing: [ - SKUnitPricingOption( - price: "$12", - description: "Per seat monthly", - isDefaultSelected: false, - primaryTitle: "Upgrade for $12/month", - secondaryTitle: "" - ), - SKUnitPricingOption( - price: "$10", - description: "Annual", - badge: "Save 15%", - isDefaultSelected: true, - primaryTitle: "Upgrade for $10/month", - secondaryTitle: "" + secondaryTitle: "", + productIdentifier: "app.affine.pro.Annual", + revenueCatIdentifier: "app.affine.pro.Annual" ), ] ), diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit.swift index 2ef8177ae..9020c7b3c 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit.swift @@ -13,20 +13,20 @@ struct SKUnit: Identifiable, Sendable { let subcategory: any SKUnitSubcategorizable let primaryText: String let secondaryText: String - let pricing: [SKUnitPricingOption] + let package: [SKUnitPackageOption] init( category: SKUnitCategory, subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single, primaryText: String, secondaryText: String, - pricing: [SKUnitPricingOption] + package: [SKUnitPackageOption] ) { self.category = category self.subcategory = subcategory self.primaryText = primaryText self.secondaryText = secondaryText - self.pricing = pricing + self.package = package } } @@ -34,7 +34,6 @@ extension SKUnit { static let allUnits: [SKUnit] = [ proUnits, aiUnits, - believerUnits, ].flatMap(\.self) static func units(for category: SKUnitCategory) -> [SKUnit] { diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitCategory.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitCategory.swift index 40ea3d4ff..091c16da1 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitCategory.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitCategory.swift @@ -12,7 +12,6 @@ enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable { case pro case ai - case believer } extension SKUnitCategory { @@ -20,7 +19,6 @@ extension SKUnitCategory { switch self { case .pro: "AFFINE.Pro" case .ai: "AI" - case .believer: "Believer" } } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPricingOption.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPackageOption.swift similarity index 62% rename from packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPricingOption.swift rename to packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPackageOption.swift index 8a790911e..b3dbfc272 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPricingOption.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPackageOption.swift @@ -1,5 +1,5 @@ // -// SKUnitPricingOption.swift +// SKUnitPackageOption.swift // AffinePaywall // // Created by qaq on 9/18/25. @@ -7,10 +7,10 @@ import Foundation -struct SKUnitPricingOption: Identifiable, Equatable { +struct SKUnitPackageOption: Identifiable, Equatable { var id: UUID - // pricing selection button + // package selection button var price: String var description: String var badge: String? @@ -20,6 +20,10 @@ struct SKUnitPricingOption: Identifiable, Equatable { var primaryTitle: String var secondaryTitle: String + // product identifiers + var productIdentifier: String + var revenueCatIdentifier: String + init( id: UUID = UUID(), price: String, @@ -27,7 +31,9 @@ struct SKUnitPricingOption: Identifiable, Equatable { badge: String? = nil, isDefaultSelected: Bool = false, primaryTitle: String, - secondaryTitle: String + secondaryTitle: String, + productIdentifier: String, + revenueCatIdentifier: String ) { self.id = id self.price = price @@ -36,5 +42,7 @@ struct SKUnitPricingOption: Identifiable, Equatable { self.isDefaultSelected = isDefaultSelected self.primaryTitle = primaryTitle self.secondaryTitle = secondaryTitle + self.productIdentifier = productIdentifier + self.revenueCatIdentifier = revenueCatIdentifier } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategoryProPlan.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategoryProPlan.swift index 78ae59e36..082ae14d9 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategoryProPlan.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategoryProPlan.swift @@ -9,14 +9,10 @@ import Foundation enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable { case `default` - case team - case selfHost var title: String { switch self { case .default: "Pro" - case .team: "Pro team" - case .selfHost: "Self Hosted" } } @@ -24,10 +20,6 @@ enum SKUnitSubcategoryProPlan: String, SKUnitSubcategorizable { switch self { case .default: "For family and small teams." - case .team: - "Best for scalable teams." - case .selfHost: - "Best for scalable teams." } } } @@ -37,10 +29,6 @@ extension SKUnitSubcategoryProPlan { switch self { case .default: "Include in Pro" - case .team: - "Include in Team Workspace" - case .selfHost: - "Both in Teams & Enterprise" } } @@ -56,24 +44,6 @@ extension SKUnitSubcategoryProPlan { Feature("Community Support"), Feature("Real-time Syncing & Collaboration for more people"), ] - case .team: - [ - Feature("Everything in AFFINE Pro", isHighlighted: true), - Feature("100 GB initial storage + 20 GB per seat"), - Feature("500 MB of maximum file size"), - Feature("Unlimited team members (10+ seats)"), - Feature("Multiple admin roles"), - Feature("Priority customer support"), - ] - case .selfHost: - [ - Feature("Everything in Self Hosted FOSS"), - Feature("100 GB initial storage + 20 GB per seat"), - Feature("500 MB of maximum file size"), - Feature("Unlimited team members (10+ seats)"), - Feature("Multiple admin roles"), - Feature("Priority customer support"), - ] } } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift new file mode 100644 index 000000000..b9b89f26e --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift @@ -0,0 +1,59 @@ +// +// Store.swift +// AffinePaywall +// +// Created by qaq on 9/24/25. +// + +import StoreKit + +let store = Store.shared + +final nonisolated class Store: ObservableObject, Sendable { + static let shared = Store() + + private init() {} + + func fetchAppStoreContents() async throws { + try await AppStore.sync() + } + + func fetchProducts() async throws -> [Product] { + let identifiers = SKUnit.allUnits + .flatMap(\.package) + .map(\.productIdentifier) + print("fetching products for identifiers: \(identifiers)") + let products = try await Product.products( + for: identifiers.map { .init($0) } + ) + if products.count != identifiers.count { + throw NSError(domain: "AffinePaywall", code: -1, userInfo: [ + NSLocalizedDescriptionKey: String(localized: "Failed to fetch all products from App Store."), + ]) + } + return products + } + + func fetchEntitlements() async throws -> Set { + var purchasedItems: Set = [] + + for await result in Transaction.currentEntitlements { + if case let .verified(transaction) = result { + guard transaction.revocationDate == nil else { continue } + + switch transaction.productType { + case .nonConsumable, .consumable: + purchasedItems.insert(transaction.productID) + case .autoRenewable, .nonRenewable: + if let status = await transaction.subscriptionStatus, + status.state == .subscribed + { purchasedItems.insert(transaction.productID) } + default: + break + } + } + } + + return purchasedItems + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift index 377ae6808..ff2f84330 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift @@ -6,26 +6,122 @@ // import Foundation +import UIKit extension ViewModel { func purchase() { let unit = selectedUnit - let option = selectedPricingOption + let option = selectePackageOption + assert(!updating) + guard !updating else { return } print(#function, unit, option) + + Task.detached { + await MainActor.run { self.updating = true } + var shouldDismiss = false + + let product = await self.products.first { + $0.id == option.productIdentifier + } + + if let product { + let result = try await product.purchase() + switch result { + case .pending: + break + case let .success(transaction): + print("purchase success", transaction) + shouldDismiss = true + case .userCancelled: + break + @unknown default: + assertionFailure() + } + } else { assertionFailure() } // should never happen + + await MainActor.run { + self.updating = false + if shouldDismiss { self.dismiss() } + } + } } func restore() { let unit = selectedUnit - let option = selectedPricingOption + let option = selectePackageOption + assert(!updating) + guard !updating else { return } print(#function, unit, option) + + updateAppStoreStatus(initial: false) } func dismiss() { - let unit = selectedUnit - let option = selectedPricingOption - - print(#function, unit, option) + print(#function) + associatedController?.dismiss(animated: true) + } +} + +nonisolated extension ViewModel { + func updateAppStoreStatusExecute(initial: Bool) async { + guard await !updating else { return } + guard let controller = await associatedController else { return } + await MainActor.run { self.updating = true } + + do { + // before we continue, sync any changes from App Store + // this will ask user to sign in if needed + do { + try await store.fetchAppStoreContents() + } catch { + // ignore user's cancellation on restore, not a huge deal + print("updateAppStoreItems error:", error) + } + + // now we fetch records from app store + let products = try await store.fetchProducts() + await MainActor.run { self.products = products } + + // fetch purchased items if signed in + do { + let purchase = try await store.fetchEntitlements() + await MainActor.run { self.purchasedItems = purchase } + } catch { + print("fetchEntitlements error:", error) + if !initial { throw error } + } + + // select the package under purchased items if any + let availablePackages = await availablePackageOptions + let purchase = await purchasedItems + let purchasedPackages = availablePackages.filter { + purchase.contains($0.productIdentifier) + } + assert(purchasedPackages.count <= 1) + if let firstPurchased = purchasedPackages.first { + await MainActor.run { + self.select(packageOption: firstPurchased) + } + } + } catch { + await MainActor.run { + let alert = UIAlertController( + title: String(localized: "Error"), + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction( + UIAlertAction( + title: String(localized: "OK"), + style: .default + ) { [self] _ in dismiss() } + ) + controller.present(alert, animated: true) + } + } + + await MainActor.run { self.updating = false } } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Preview.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Preview.swift index 9b0a730c9..79286fefa 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Preview.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Preview.swift @@ -20,10 +20,4 @@ extension ViewModel { vm.select(category: .ai) return vm }() - - static let vmPreviewForBeliever: ViewModel = { - let vm = ViewModel() - vm.select(category: .believer) - return vm - }() } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift index aa2a52e67..8dd52c2fd 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift @@ -5,6 +5,7 @@ // Created by qaq on 9/18/25. // +import StoreKit import SwiftUI @MainActor @@ -15,12 +16,30 @@ class ViewModel: ObservableObject { @Published private(set) var category: SKUnitCategory = .pro @Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default - @Published private(set) var selectedPricingIdentifier: UUID = SKUnit.unit( + @Published private(set) var selectedPackageIdentifier: UUID = SKUnit.unit( for: .pro, subcategory: SKUnitSubcategoryProPlan.default - )!.pricing.first { $0.isDefaultSelected }!.id + )!.package.first { $0.isDefaultSelected }!.id - init() {} + @Published var updating = false + @Published var products: [Product] = [] + @Published var purchasedItems: Set = [] + + private(set) weak var associatedController: UIViewController? + + init() { + updateAppStoreStatus(initial: true) + } + + func updateAppStoreStatus(initial: Bool) { + Task.detached { + await self.updateAppStoreStatusExecute(initial: initial) + } + } + + func bind(controller: UIViewController) { + associatedController = controller + } func select(category: SKUnitCategory) { self.category = category @@ -30,7 +49,7 @@ class ViewModel: ObservableObject { if !subcategoryExists { subcategory = units.first!.subcategory } - _ = selectedPricingOption // ensure selectedPricingIdentifier is valid + _ = selectePackageOption // ensure selectePackageOption is valid } func select(subcategory: any SKUnitSubcategorizable) { @@ -45,20 +64,20 @@ class ViewModel: ObservableObject { } else { self.subcategory = subcategory } - _ = selectedPricingOption // ensure selectedPricingIdentifier is valid + _ = selectePackageOption // ensure selectePackageOption is valid } - func select(pricingOption option: SKUnitPricingOption) { - selectedPricingIdentifier = option.id + func select(packageOption option: SKUnitPackageOption) { + selectedPackageIdentifier = option.id let unit = availableUnits .first { unit in - unit.pricing.contains { $0.id == option.id } + unit.package.contains { $0.id == option.id } }! category = unit.category subcategory = unit.subcategory - _ = selectedPricingOption // ensure selectedPricingIdentifier is valid + _ = selectePackageOption // ensure selectePackageOption is valid } } @@ -79,21 +98,21 @@ extension ViewModel { return item } - var selectedPricingOption: SKUnitPricingOption { - let item = selectedUnit.pricing - .first { $0.id == selectedPricingIdentifier } + var selectePackageOption: SKUnitPackageOption { + let item = selectedUnit.package + .first { $0.id == selectedPackageIdentifier } if let item { return item } - let defaultItem = selectedUnit.pricing.first { $0.isDefaultSelected } + let defaultItem = selectedUnit.package.first { $0.isDefaultSelected } if let defaultItem { - selectedPricingIdentifier = defaultItem.id + selectedPackageIdentifier = defaultItem.id return defaultItem } - let lastItem = selectedUnit.pricing.last! - selectedPricingIdentifier = lastItem.id + let lastItem = selectedUnit.package.last! + selectedPackageIdentifier = lastItem.id return lastItem } - var availablePricingOptions: [SKUnitPricingOption] { - selectedUnit.pricing + var availablePackageOptions: [SKUnitPackageOption] { + selectedUnit.package } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Page/AffinePaywallPageView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Page/AffinePaywallPageView.swift index 9ee871a65..f9a57431f 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Page/AffinePaywallPageView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Page/AffinePaywallPageView.swift @@ -13,6 +13,9 @@ struct AffinePaywallPageView: View { @Environment(\.dismiss) var dismiss + @State private var presentAnimation = false + @State private var showCloseButton = false + var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -24,10 +27,17 @@ struct AffinePaywallPageView: View { Button { viewModel.dismiss() } label: { - Image(AffineIcons.close.rawValue) + AffineIcons.close.image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(.primary) } .buttonStyle(.plain) .foregroundColor(AffineColors.textSecondary.color) + .opacity(showCloseButton ? 1 : 0) + .disabled(!showCloseButton) + .animation(.spring, value: showCloseButton) } ZStack(alignment: .topLeading) { Spacer() @@ -45,11 +55,22 @@ struct AffinePaywallPageView: View { .animation(.spring.speed(2), value: viewModel.category) PurchaseFooterView(viewModel: viewModel) - .animation(.spring.speed(2), value: viewModel.selectedPricingIdentifier) + .animation(.spring.speed(2), value: viewModel.selectedPackageIdentifier) } .padding() + .opacity(presentAnimation ? 1 : 0) + .scaleEffect(presentAnimation ? 1 : 0.95, anchor: .top) + .animation(.spring, value: presentAnimation) + .onAppear { + presentAnimation = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + showCloseButton = true + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) .background( AffineColors.layerBackgroundSecondary.color + .ignoresSafeArea() ) } @@ -60,8 +81,6 @@ struct AffinePaywallPageView: View { SKUnitProDetailView(viewModel: viewModel) case .ai: SKUnitIntelligentDetailView(viewModel: viewModel) - case .believer: - SKUnitBelieverDetailView(viewModel: viewModel) } } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift index cd4b5b28a..b0869aac4 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift @@ -1,12 +1,12 @@ // -// File.swift +// Paywall.swift // AffinePaywall // // Created by qaq on 9/18/25. // -import UIKit import SwiftUI +import UIKit public enum Paywall { @MainActor @@ -15,13 +15,19 @@ public enum Paywall { type: String ) { let viewModel = ViewModel() - switch type { - // TODO: FIGURE OUT PAYWALL TYPES + switch type.lowercased() { + case "pro": + viewModel.select(category: .pro) + viewModel.select(subcategory: SKUnitSubcategoryProPlan.default) + case "ai": + viewModel.select(category: .ai) + viewModel.select(subcategory: SKUnitSingleSubcategory.single) default: break } let view = AffinePaywallPageView(viewModel: viewModel) let hostingController = UIHostingController(rootView: view) + viewModel.bind(controller: hostingController) hostingController.modalPresentationStyle = .overFullScreen hostingController.modalTransitionStyle = .coverVertical hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Utils/ViewOffsetKey.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Utils/ViewOffsetKey.swift deleted file mode 100644 index 9e803e303..000000000 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Utils/ViewOffsetKey.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ViewOffsetKey.swift -// AffinePaywall -// -// Created by qaq on 9/18/25. -// - -import SwiftUI - -@MainActor -struct ViewOffsetKey: @MainActor PreferenceKey { - static var defaultValue: CGPoint = .zero - static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { - value = nextValue() - } -}