diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 7a95ddaa7..04e88c07e 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */; }; + 5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D4772E7C5FBD00ADD25A /* AffinePaywall */; }; + 5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D4792E7C5FC100ADD25A /* AffineResources */; }; + 5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */ = {isa = PBXBuildFile; productRef = 5027D47B2E7C5FC400ADD25A /* AffineGraphQL */; }; + 5027D4802E7C611900ADD25A /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5027D47F2E7C611900ADD25A /* Tools.swift */; }; 5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; }; 50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; settings = {ATTRIBUTES = (Required, ); }; }; 50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; }; @@ -47,6 +51,8 @@ /* Begin PBXFileReference section */ 3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.debug.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.debug.xcconfig"; sourceTree = ""; }; + 5027D4762E7C5FB700ADD25A /* AffinePaywall */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffinePaywall; sourceTree = ""; }; + 5027D47F2E7C611900ADD25A /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.swift; sourceTree = ""; }; 5039CC962D1D42C700874F32 /* AffineGraphQL */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineGraphQL; sourceTree = ""; }; 504EC3041FED79650016851F /* AFFiNE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AFFiNE.app; sourceTree = BUILT_PRODUCTS_DIR; }; 507513692D1924C600AD60C0 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; }; @@ -91,7 +97,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5027D47A2E7C5FC100ADD25A /* AffineResources in Frameworks */, + 5027D4782E7C5FBD00ADD25A /* AffinePaywall in Frameworks */, 9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */, + 5027D47C2E7C5FC400ADD25A /* AffineGraphQL in Frameworks */, 50802D612D112F8700694021 /* Intelligents in Frameworks */, 2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */, ); @@ -136,6 +145,7 @@ isa = PBXGroup; children = ( 5039CC962D1D42C700874F32 /* AffineGraphQL */, + 5027D4762E7C5FB700ADD25A /* AffinePaywall */, 50CECF1E2E7C1084004487AA /* AffineResources */, 50802D5E2D112F7D00694021 /* Intelligents */, ); @@ -165,6 +175,7 @@ 9D90BE1B2CCB9876006677DB /* AffineViewController.swift */, 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */, 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */, + 5027D47F2E7C611900ADD25A /* Tools.swift */, 9D90BE1D2CCB9876006677DB /* Assets.xcassets */, 9D90BE1E2CCB9876006677DB /* capacitor.config.json */, 9D90BE1F2CCB9876006677DB /* config.xml */, @@ -340,6 +351,7 @@ 50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */, 9D90BE272CCB9876006677DB /* AffineViewController.swift in Sources */, 9D90BE282CCB9876006677DB /* AppDelegate.swift in Sources */, + 5027D4802E7C611900ADD25A /* Tools.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -484,7 +496,7 @@ DEVELOPMENT_TEAM = 964G86XT2P; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -520,7 +532,7 @@ DEVELOPMENT_TEAM = 964G86XT2P; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -569,6 +581,18 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 5027D4772E7C5FBD00ADD25A /* AffinePaywall */ = { + isa = XCSwiftPackageProductDependency; + productName = AffinePaywall; + }; + 5027D4792E7C5FC100ADD25A /* AffineResources */ = { + isa = XCSwiftPackageProductDependency; + productName = AffineResources; + }; + 5027D47B2E7C5FC400ADD25A /* AffineGraphQL */ = { + isa = XCSwiftPackageProductDependency; + productName = AffineGraphQL; + }; 50802D602D112F8700694021 /* Intelligents */ = { isa = XCSwiftPackageProductDependency; productName = Intelligents; diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index d9adbc658..c5a430c92 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -32,8 +32,8 @@ class AFFiNEViewController: CAPBridgeViewController { CookiePlugin(), HashcashPlugin(), NavigationGesturePlugin(), - // IntelligentsPlugin(representController: self), // no longer put in use NbStorePlugin(), + PayWallPlugin(associatedController: self), ] plugins.forEach { bridge?.registerPluginInstance($0) } } diff --git a/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift index 9106c49ff..ace254ca7 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift @@ -1,8 +1,18 @@ +import AffinePaywall import Capacitor import Foundation +import SwiftUI +import UIKit @objc(PayWallPlugin) public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin { + init(associatedController: UIViewController? = nil) { + controller = associatedController + super.init() + } + + weak var controller: UIViewController? + public let identifier = "PayWallPlugin" public let jsName = "PayWall" public let pluginMethods: [CAPPluginMethod] = [ @@ -12,14 +22,17 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin { @objc func showPayWall(_ call: CAPPluginCall) { do { let type = try call.getStringEnsure("type") + let controller = try controller.get() - // TODO: Implement actual paywall logic here - // For now, just log the type and resolve - print("PayWall: Showing paywall of type: \(type)") + // TODO: GET TO KNOW THE PAYWALL TYPE + print("[*] showing paywall of type: \(type)") + DispatchQueue.main.async { + Paywall.presentWall(toController: controller, type: type) + } call.resolve(["success": true, "type": type]) } catch { - call.reject("Failed to show paywall", nil, error) + call.reject("failed to show paywall", nil, error) } } -} \ No newline at end of file +} diff --git a/packages/frontend/apps/ios/App/App/Tools.swift b/packages/frontend/apps/ios/App/App/Tools.swift new file mode 100644 index 000000000..f5f8bb4f2 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Tools.swift @@ -0,0 +1,21 @@ +// +// Tools.swift +// AFFiNE +// +// Created by qaq on 9/18/25. +// + +import Foundation + +extension Optional { + func get(_ failure: String? = nil) throws -> Wrapped { + guard let self else { + if let failure { + throw NSError(domain: #function, code: -1, userInfo: [NSLocalizedDescriptionKey: failure]) + } else { + throw NSError(domain: #function, code: -1) + } + } + return self + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/.gitignore b/packages/frontend/apps/ios/App/Packages/AffinePaywall/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift new file mode 100644 index 000000000..3ecc50fef --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AffinePaywall", + platforms: [ + .iOS(.v16), + .macOS(.v14), // just for build so LLM can verify their code + ], + products: [ + .library( + name: "AffinePaywall", + targets: ["AffinePaywall"] + ), + ], + dependencies: [ + .package(path: "../AffineResources"), + ], + targets: [ + .target( + name: "AffinePaywall", + dependencies: ["AffineResources"], + ), + ] +) diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/IntelligentFeatureView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/IntelligentFeatureView.swift new file mode 100644 index 000000000..bd64326db --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/IntelligentFeatureView.swift @@ -0,0 +1,64 @@ +// +// IntelligentFeatureView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct IntelligentFeatureView: View { + let feature: Feature + + struct Feature: Identifiable { + let id: UUID = .init() + let preview: String + let icon: String + let title: String + let features: [String] + } + + var body: some View { + VStack(spacing: 24) { + Image(feature.preview, bundle: .module) + .resizable() + .aspectRatio(contentMode: .fit) + HStack(spacing: 8) { + Image(feature.icon, bundle: .module) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + Text(feature.title) + .font(.system(size: 24, weight: .semibold, design: .default)) + } + VStack(alignment: .leading, spacing: 12) { + ForEach(feature.features, id: \.self) { item in + HStack(alignment: .firstTextBaseline, spacing: 12) { + Rectangle() + .frame(width: 4, height: 10) + .foregroundStyle(.clear) + .overlay { + Image(systemName: "circle.fill") + .font(.system(size: 4)) + .foregroundColor(AffineColors.textSecondary.color) + } + Text(item) + .font(.system(size: 16)) + .foregroundColor(AffineColors.textSecondary.color) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +#Preview { + IntelligentFeatureView( + feature: SKUnitIntelligentDetailView.features.first! + ) + .padding() +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView+Feature.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView+Feature.swift new file mode 100644 index 000000000..a5d65be00 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView+Feature.swift @@ -0,0 +1,44 @@ +// +// SKUnitIntelligentDetailView+Feature.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +extension SKUnitIntelligentDetailView { + static let features: [IntelligentFeatureView.Feature] = [ + .init( + preview: "AI_PREVIEW_WRITE", + icon: "AI_TEXT", + title: "Write with you", + features: [ + "Create quality content from sentences to articles on topics you need", + "Rewrite like the professionals", + "Change the tones / fix spelling & grammar", + ] + ), + .init( + preview: "AI_PREVIEW_DRAW", + icon: "AI_PEN", + title: "Draw with you", + features: [ + "Visualize your mind, magically", + "Turn your outline into beautiful, engaging presentations(Beta)", + "Summarize your content into structured mind-maps", + ] + ), + .init( + preview: "AI_PREVIEW_PLAN", + icon: "AI_CHECK", + title: "Plan with you", + features: [ + "Memorize and tidy up your knowledge", + "Auto-sorting and auto-tagging (Coming soon)", + "Privacy ensured", + ] + ), + ] +} 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 new file mode 100644 index 000000000..b6f078468 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/AI/SKUnitIntelligentDetailView.swift @@ -0,0 +1,84 @@ +// +// SKUnitIntelligentDetailView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct SKUnitIntelligentDetailView: View { + @StateObject var viewModel: ViewModel + @State var detailIndex: Int = 0 { + didSet { lastInteractionDate = Date() } + } + + @State var lastInteractionDate: Date = .init() + + let timer = Timer + .publish(every: 5, on: .main, in: .common) + .autoconnect() + + var body: some View { + VStack(spacing: 24) { + HeadlineView(viewModel: viewModel) + + GeometryReader { r in + 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) + HStack(spacing: 0) { + ForEach(0 ..< Self.features.count, id: \.self) { featureIndex in + let feature = Self.features[featureIndex] + IntelligentFeatureView(feature: feature) + .padding() + .frame(width: width, height: height) + .id(featureIndex) + } + } + } + .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) { + scrollView.scrollTo(newValue) + } + } + } + } + + PageDotsView( + current: detailIndex, + total: Self.features.count + ) { index in + detailIndex = index + } + } + .onReceive(timer) { _ in + if Date().timeIntervalSince(lastInteractionDate) > 5 { + detailIndex = (detailIndex + 1) % Self.features.count + } + } + } +} + +#Preview { + SKUnitIntelligentDetailView(viewModel: .vmPreviewForAI) + .padding() +} 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 new file mode 100644 index 000000000..a16a41774 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Believer/SKUnitBelieverDetailView.swift @@ -0,0 +1,36 @@ +// +// 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 new file mode 100644 index 000000000..957b6078b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/CategorySelectionView.swift @@ -0,0 +1,70 @@ +// +// CategorySelectionView.swift +// AffinePaywall +// +// Created by qaq on 9/17/25. +// + +import AffineResources +import SwiftUI + +struct CategorySelectionView: View { + let selectedTab: SKUnitCategory + let onSelect: (SKUnitCategory) -> Void + + var body: some View { + HStack(spacing: 16) { + ForEach(SKUnitCategory.allCases) { tab in + TabItem(type: tab, isSelected: tab == selectedTab) + .onTapGesture { onSelect(tab) } + } + } + .animation(.spring.speed(2), value: selectedTab) + } + + struct TabItem: View { + let type: SKUnitCategory + let isSelected: Bool + + var font: Font { + if isSelected { + .system(size: 24, weight: .bold) + } else { + .system(size: 24, weight: .regular) + } + } + + var color: Color { + if isSelected { + AffineColors.textPrimary.color + } else { + AffineColors.textSecondary.color + } + } + + var body: some View { + Text(type.title) + .lineLimit(1) + .font(font) + .foregroundStyle(color) + } + } +} + +#Preview { + struct PreviewWrapper: View { + @State var selectedTab: SKUnitCategory = .pro + var body: some View { + CategorySelectionView(selectedTab: selectedTab, onSelect: { selectedTab = $0 }) + } + } + return VStack(alignment: .leading, spacing: 12) { + CategorySelectionView(selectedTab: .pro, onSelect: { _ in }) + CategorySelectionView(selectedTab: .ai, onSelect: { _ in }) + CategorySelectionView(selectedTab: .believer, onSelect: { _ in }) + Divider() + PreviewWrapper() + } + .padding() + .background(Color.gray.opacity(0.25).ignoresSafeArea()) +} 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 new file mode 100644 index 000000000..a8939712a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/HeadlineView.swift @@ -0,0 +1,28 @@ +// +// HeadlineView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct HeadlineView: View { + @StateObject var viewModel: ViewModel + var body: some View { + VStack(spacing: 8) { + Text(viewModel.selectedUnit.primaryText) + .font(.system(size: 24, weight: .heavy)) + .contentTransition(.numericText()) + .animation(.spring.speed(2), value: viewModel.category) + .padding(.top, 8) + + Text(viewModel.selectedUnit.secondaryText) + .font(.system(size: 16)) + .foregroundStyle(AffineColors.textSecondary.color) + .contentTransition(.numericText()) + .animation(.spring.speed(2), value: viewModel.category) + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PageDotsView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PageDotsView.swift new file mode 100644 index 000000000..375afb22c --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PageDotsView.swift @@ -0,0 +1,50 @@ +// +// PageDotsView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct PageDotsView: View { + let current: Int + let total: Int + + let onSelection: (Int) -> Void + + var body: some View { + HStack(spacing: 8) { + ForEach(0 ..< total, id: \.self) { index in + Circle() + .foregroundStyle( + index == current + ? AffineColors.buttonPrimary.color + : AffineColors.textSecondary.color.opacity(0.5) + ) + .frame(width: 6, height: 6) + .padding(4) + .contentShape(Rectangle()) + .onTapGesture { + onSelection(index) + } + } + } + } +} + +#Preview { + VStack(spacing: 32) { + PageDotsView(current: 0, total: 8) { _ in } + PageDotsView(current: 1, total: 8) { _ in } + PageDotsView(current: 2, total: 8) { _ in } + PageDotsView(current: 3, total: 8) { _ in } + PageDotsView(current: 4, total: 8) { _ in } + PageDotsView(current: 5, total: 8) { _ in } + PageDotsView(current: 6, total: 8) { _ in } + PageDotsView(current: 7, total: 8) { _ in } + PageDotsView(current: 8, total: 8) { _ in } + } + .padding() +} 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/PricingOptionView.swift new file mode 100644 index 000000000..a00058e9b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PricingOptionView.swift @@ -0,0 +1,134 @@ +// +// PricingOptionView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct PricingOptionView: View { + let price: String + let description: String + var badge: String + let isSelected: Bool + let action: () -> Void + + init( + price: String, + description: String, + badge: String = "", + isSelected: Bool, + action: @escaping () -> Void = {} + ) { + self.price = price + self.description = description + self.badge = badge + self.isSelected = isSelected + self.action = action + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(price) + .contentTransition(.numericText()) + .font(.system(size: 20, weight: .bold)) + .lineLimit(1) + .foregroundColor(isSelected ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color) + } + .layoutPriority(.infinity) + Spacer(minLength: 0) + if !badge.isEmpty { + Text(badge) + .contentTransition(.numericText()) + .font(.system(size: 12)) + .bold() + .lineLimit(1) + .foregroundColor(AffineColors.layerPureWhite.color) + .padding(2) + .padding(.horizontal, 2) + .background(AffineColors.buttonPrimary.color) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + + if !description.isEmpty { + Text(description) + .contentTransition(.numericText()) + .foregroundColor(isSelected ? AffineColors.buttonPrimary.color : AffineColors.textSecondary.color) + .font(.system(size: 14)) + } + } + .animation(.interactiveSpring, value: price) + .animation(.interactiveSpring, value: description) + .animation(.interactiveSpring, value: badge) + .padding(12) + .frame(maxWidth: .infinity) + .background { + ZStack { + Rectangle() + .foregroundColor(AffineColors.layerBackgroundPrimary.color) + if isSelected { + Rectangle() + .foregroundColor(AffineColors.buttonPrimary.color) + .opacity(0.05) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .stroke(AffineColors.buttonPrimary.color, lineWidth: 1.5) + .foregroundColor(.clear) + } else { + RoundedRectangle(cornerRadius: 8) + .stroke(AffineColors.layerBorder.color.opacity(0.15), lineWidth: 1.5) + .foregroundColor(.clear) + } + } + .shadow(color: AffineColors.layerBorder.color.opacity(0.05), radius: 4, x: 0, y: 0) + .animation(.interactiveSpring, value: isSelected) + .contentShape(.rect) + .onTapGesture { + action() + } + } +} + +#Preview { + VStack(spacing: 16) { + HStack(spacing: 16) { + PricingOptionView( + price: "$7.99", + description: "Monthly", + isSelected: false + ) {} + PricingOptionView( + price: "$6.75", + description: "Annually", + badge: "Save 15%", + isSelected: true + ) {} + } + HStack(spacing: 16) { + PricingOptionView( + price: "$114514", + description: "Monthly", + badge: "Most Popular", + isSelected: true + ) {} + PricingOptionView( + price: "$6.75", + description: "Annually", + badge: "Save 15%", + isSelected: false + ) {} + } + } + .padding(16) + .background(Color.gray.opacity(0.25).ignoresSafeArea()) +} 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 new file mode 100644 index 000000000..b6e010f9d --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift @@ -0,0 +1,51 @@ +// +// PurchaseFooterView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct PurchaseFooterView: View { + @StateObject var viewModel: ViewModel + + var body: some View { + VStack(spacing: 16) { + if viewModel.availablePricingOptions.count > 1 { + HStack(spacing: 8) { + ForEach(viewModel.availablePricingOptions) { option in + PricingOptionView( + price: option.price, + description: option.description, + badge: option.badge ?? "", + isSelected: option.id == viewModel.selectedPricingIdentifier + ) { + viewModel.select(pricingOption: option) + } + } + } + } + + TheGiveMeMoneyButtonView( + primaryTitle: viewModel.selectedPricingOption.primaryTitle, + secondaryTitle: viewModel.selectedPricingOption.secondaryTitle, + callback: viewModel.purchase + ) + + Button(action: viewModel.restore) { + Text("Restore Purchase") + } + .font(.system(size: 12)) + .buttonStyle(.plain) + .foregroundStyle(AffineColors.textSecondary.color) + } + } +} + +#Preview { + PurchaseFooterView(viewModel: .init()) + .padding() + .background(Color.gray.opacity(0.25).ignoresSafeArea()) +} 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 new file mode 100644 index 000000000..b92b78cfb --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/TheGiveMeMoneyButtonView.swift @@ -0,0 +1,76 @@ +// +// TheGiveMeMoneyButtonView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct TheGiveMeMoneyButtonView: View { + let primaryTitle: String + let secondaryTitle: String + let callback: () -> Void + + init( + primaryTitle: String = "", + secondaryTitle: String = "", + callback: @escaping () -> Void = {} + ) { + self.primaryTitle = primaryTitle + self.secondaryTitle = secondaryTitle + 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()) + } + } + .foregroundColor(AffineColors.layerPureWhite.color) + .padding(12) + } + .animation(.spring, value: primaryTitle) + .animation(.spring, value: secondaryTitle) + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + .background(AffineColors.buttonPrimary.color) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: 16) { + TheGiveMeMoneyButtonView( + primaryTitle: "Upgrade for $6.75 per month", + secondaryTitle: "" + ) + TheGiveMeMoneyButtonView( + primaryTitle: "Upgrade for $10 per month", + secondaryTitle: "" + ) + TheGiveMeMoneyButtonView( + primaryTitle: "$8.9 per month", + secondaryTitle: "billed annually" + ) + TheGiveMeMoneyButtonView( + primaryTitle: "Upgrade for $499", + secondaryTitle: "" + ) + } + .padding(32) +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeatureRowView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeatureRowView.swift new file mode 100644 index 000000000..8d09ad2d4 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeatureRowView.swift @@ -0,0 +1,53 @@ +// +// ProFeatureRowView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct ProFeatureRowView: View { + let feature: Feature + let index: Int + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 16)) + .foregroundColor(AffineColors.buttonPrimary.color) + + Text(feature.text) + .font(.system(size: 16)) + .contentTransition(.numericText()) + .foregroundColor(feature.isHighlighted ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + .transition(.opacity) + } +} + +#Preview { + VStack(alignment: .leading, spacing: 16) { + Divider() + ProFeatureRowView( + feature: .init( + "Hello World Feature Row View", + isHighlighted: true + ), + index: 0 + ) + Divider() + ProFeatureRowView( + feature: .init("Hello World Feature Row View"), + index: 0 + ) + Divider() + } + .padding() + .background(Color.gray.opacity(0.25).ignoresSafeArea()) +} 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 new file mode 100644 index 000000000..bf82bf3b7 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/ProFeaturesCardView.swift @@ -0,0 +1,65 @@ +// +// 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 new file mode 100644 index 000000000..7a9e949ca --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Pro/SKUnitProDetailView.swift @@ -0,0 +1,56 @@ +// +// SKUnitProDetailView.swift +// AffinePaywall +// +// Created by qaq on 9/17/25. +// + +import AffineResources +import SwiftUI + +struct SKUnitProDetailView: View { + @StateObject var viewModel: ViewModel + + @State var selection: SKUnitSubcategoryProPlan = .default + + var body: some View { + VStack(spacing: 24) { + Picker("Plan", selection: $selection) { + ForEach(SKUnitSubcategoryProPlan.allCases) { plan in + Text(plan.title).tag(plan) + } + } + .pickerStyle(.segmented) + .onChange(of: selection) { _ in + viewModel.select(subcategory: selection) + } + + HeadlineView(viewModel: viewModel) + + ScrollView { + ProFeaturesCardView( + features: selection.features, + headerText: selection.headerText + ) + .padding(16) + } + .padding(-16) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + } + } +} + +#Preview { + SKUnitProDetailView(viewModel: .vmPreviewForPro) + .padding() + .background( + AffineColors.layerBackgroundSecondary + .color + .ignoresSafeArea() + ) + .background(Color.gray.opacity(0.25).ignoresSafeArea()) +} 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 new file mode 100644 index 000000000..a020af78f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Feature.swift @@ -0,0 +1,19 @@ +// +// Feature.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +struct Feature: Identifiable, Equatable { + var id = UUID() + var text: String + var isHighlighted: Bool // For text like "Everything in AFFINE Pro" + + init(_ text: String, isHighlighted: Bool = false) { + self.text = text + self.isHighlighted = isHighlighted + } +} 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 new file mode 100644 index 000000000..b812bbf83 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift @@ -0,0 +1,27 @@ +// +// SKUnit+AI.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +extension SKUnit { + static let aiUnits: [SKUnit] = [ + SKUnit( + category: SKUnitCategory.ai, + primaryText: "AFFINE AI", + secondaryText: "A true multimodal AI copilot.", + pricing: [ + SKUnitPricingOption( + price: "$8.9 per month", + description: "", + isDefaultSelected: true, + primaryTitle: "$8.9 per month", + secondaryTitle: "billed annually" + ), + ] + ), + ] +} 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 new file mode 100644 index 000000000..92472b3bf --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Believer.swift @@ -0,0 +1,27 @@ +// +// 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 new file mode 100644 index 000000000..f2355972f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift @@ -0,0 +1,82 @@ +// +// SKUnit+Pro.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +extension SKUnit { + static let proUnits: [SKUnit] = [ + SKUnit( + category: SKUnitCategory.pro, + subcategory: SKUnitSubcategoryProPlan.default, + primaryText: "Pro", + secondaryText: "For family and small teams.", + pricing: [ + SKUnitPricingOption( + price: "$7.99", + description: "Monthly", + isDefaultSelected: false, + primaryTitle: "Upgrade for $7.99/month", + secondaryTitle: "" + ), + SKUnitPricingOption( + 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: "" + ), + ] + ), + ] +} 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 new file mode 100644 index 000000000..2ef8177ae --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit.swift @@ -0,0 +1,55 @@ +// +// SKUnit.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +struct SKUnit: Identifiable, Sendable { + let id = UUID() + let category: SKUnitCategory + let subcategory: any SKUnitSubcategorizable + let primaryText: String + let secondaryText: String + let pricing: [SKUnitPricingOption] + + init( + category: SKUnitCategory, + subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single, + primaryText: String, + secondaryText: String, + pricing: [SKUnitPricingOption] + ) { + self.category = category + self.subcategory = subcategory + self.primaryText = primaryText + self.secondaryText = secondaryText + self.pricing = pricing + } +} + +extension SKUnit { + static let allUnits: [SKUnit] = [ + proUnits, + aiUnits, + believerUnits, + ].flatMap(\.self) + + static func units(for category: SKUnitCategory) -> [SKUnit] { + allUnits.filter { $0.category == category } + } + + static func unit( + for type: SKUnitCategory, + subcategory: (any SKUnitSubcategorizable) = SKUnitSingleSubcategory.single + ) -> SKUnit? { + let subcategory = subcategory.subcategoryIdentifier + let item = allUnits + .filter { $0.category == type } + .filter { $0.subcategory.subcategoryIdentifier == subcategory } + assert(item.count == 1) + return item.first + } +} 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 new file mode 100644 index 000000000..40ea3d4ff --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitCategory.swift @@ -0,0 +1,26 @@ +// +// SKUnitCategory.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable { + var id: Int { rawValue } + + case pro + case ai + case believer +} + +extension SKUnitCategory { + var title: String { + 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/SKUnitPricingOption.swift new file mode 100644 index 000000000..8a790911e --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitPricingOption.swift @@ -0,0 +1,40 @@ +// +// SKUnitPricingOption.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +struct SKUnitPricingOption: Identifiable, Equatable { + var id: UUID + + // pricing selection button + var price: String + var description: String + var badge: String? + var isDefaultSelected: Bool + + // subscribe button titles + var primaryTitle: String + var secondaryTitle: String + + init( + id: UUID = UUID(), + price: String, + description: String, + badge: String? = nil, + isDefaultSelected: Bool = false, + primaryTitle: String, + secondaryTitle: String + ) { + self.id = id + self.price = price + self.description = description + self.badge = badge + self.isDefaultSelected = isDefaultSelected + self.primaryTitle = primaryTitle + self.secondaryTitle = secondaryTitle + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategorizable.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategorizable.swift new file mode 100644 index 000000000..a397bd2ea --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategorizable.swift @@ -0,0 +1,27 @@ +// +// SKUnitSubcategorizable.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +protocol SKUnitSubcategorizable: Identifiable, Equatable, Hashable, CaseIterable, Sendable { + var id: String { get } + var subcategoryIdentifier: String { get } +} + +extension SKUnitSubcategorizable { + var id: String { + subcategoryIdentifier + } +} + +extension SKUnitSubcategorizable where Self: RawRepresentable, Self.RawValue == String { + var subcategoryIdentifier: String { rawValue } +} + +enum SKUnitSingleSubcategory: String, SKUnitSubcategorizable { + case single +} 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 new file mode 100644 index 000000000..78ae59e36 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnitSubcategoryProPlan.swift @@ -0,0 +1,79 @@ +// +// SKUnitSubcategoryProPlan.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +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" + } + } + + var description: String { + switch self { + case .default: + "For family and small teams." + case .team: + "Best for scalable teams." + case .selfHost: + "Best for scalable teams." + } + } +} + +extension SKUnitSubcategoryProPlan { + var headerText: String { + switch self { + case .default: + "Include in Pro" + case .team: + "Include in Team Workspace" + case .selfHost: + "Both in Teams & Enterprise" + } + } + + var features: [Feature] { + switch self { + case .default: + [ + Feature("Everything in AFFINE FOSS & Basic."), + Feature("100 GB of Cloud Storage"), + Feature("100 MB of Maximum file size"), + Feature("Up to 10 members per Workspace"), + Feature("30-days Cloud Time Machine file version history"), + 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/ViewModel+Action.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift new file mode 100644 index 000000000..377ae6808 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift @@ -0,0 +1,31 @@ +// +// ViewModel+Action.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +extension ViewModel { + func purchase() { + let unit = selectedUnit + let option = selectedPricingOption + + print(#function, unit, option) + } + + func restore() { + let unit = selectedUnit + let option = selectedPricingOption + + print(#function, unit, option) + } + + func dismiss() { + let unit = selectedUnit + let option = selectedPricingOption + + print(#function, unit, option) + } +} 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 new file mode 100644 index 000000000..9b0a730c9 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Preview.swift @@ -0,0 +1,29 @@ +// +// ViewModel+Preview.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import Foundation + +extension ViewModel { + static let vmPreviewForPro: ViewModel = { + let vm = ViewModel() + vm.select(category: .pro) + vm.select(subcategory: SKUnitSubcategoryProPlan.default) + return vm + }() + + static let vmPreviewForAI: ViewModel = { + let vm = 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 new file mode 100644 index 000000000..aa2a52e67 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift @@ -0,0 +1,99 @@ +// +// ViewModel.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import SwiftUI + +@MainActor +class ViewModel: ObservableObject { + var availableUnits: [SKUnit] { + SKUnit.units(for: category) + } + + @Published private(set) var category: SKUnitCategory = .pro + @Published private(set) var subcategory: any SKUnitSubcategorizable = SKUnitSubcategoryProPlan.default + @Published private(set) var selectedPricingIdentifier: UUID = SKUnit.unit( + for: .pro, + subcategory: SKUnitSubcategoryProPlan.default + )!.pricing.first { $0.isDefaultSelected }!.id + + init() {} + + func select(category: SKUnitCategory) { + self.category = category + let units = SKUnit.units(for: category) + let subcategoryExists = units + .contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier } + if !subcategoryExists { + subcategory = units.first!.subcategory + } + _ = selectedPricingOption // ensure selectedPricingIdentifier is valid + } + + func select(subcategory: any SKUnitSubcategorizable) { + let units = SKUnit.units(for: category) + let subcategoryExists = units + .contains { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier } + if !subcategoryExists { + let category = availableUnits + .first { $0.subcategory.subcategoryIdentifier == subcategory.subcategoryIdentifier }! + .category + self.category = category + } else { + self.subcategory = subcategory + } + _ = selectedPricingOption // ensure selectedPricingIdentifier is valid + } + + func select(pricingOption option: SKUnitPricingOption) { + selectedPricingIdentifier = option.id + + let unit = availableUnits + .first { unit in + unit.pricing.contains { $0.id == option.id } + }! + category = unit.category + subcategory = unit.subcategory + + _ = selectedPricingOption // ensure selectedPricingIdentifier is valid + } +} + +@MainActor +extension ViewModel { + var selectedUnit: SKUnit { + if let unit = SKUnit.unit(for: category, subcategory: subcategory) { + return unit + } + let units = SKUnit.units(for: category) + if let last = units.last { + subcategory = last.subcategory + return last + } + let item = availableUnits.first! + category = item.category + subcategory = item.subcategory + return item + } + + var selectedPricingOption: SKUnitPricingOption { + let item = selectedUnit.pricing + .first { $0.id == selectedPricingIdentifier } + if let item { return item } + let defaultItem = selectedUnit.pricing.first { $0.isDefaultSelected } + if let defaultItem { + selectedPricingIdentifier = defaultItem.id + return defaultItem + } + let lastItem = selectedUnit.pricing.last! + selectedPricingIdentifier = lastItem.id + return lastItem + } + + var availablePricingOptions: [SKUnitPricingOption] { + selectedUnit.pricing + } +} 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 new file mode 100644 index 000000000..9ee871a65 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Page/AffinePaywallPageView.swift @@ -0,0 +1,77 @@ +// +// AffinePaywallPageView.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import AffineResources +import SwiftUI + +struct AffinePaywallPageView: View { + @StateObject var viewModel = ViewModel() + + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + CategorySelectionView( + selectedTab: viewModel.category, + onSelect: viewModel.select(category:) + ) + Spacer() + Button { + viewModel.dismiss() + } label: { + Image(AffineIcons.close.rawValue) + } + .buttonStyle(.plain) + .foregroundColor(AffineColors.textSecondary.color) + } + ZStack(alignment: .topLeading) { + Spacer() + .frame(maxWidth: .infinity, maxHeight: .infinity) + content + .frame(maxWidth: .infinity) + .transition( + .opacity + .combined(with: .scale( + scale: 0.95, + anchor: .init(x: 0.5, y: 0) + )) + ) + } + .animation(.spring.speed(2), value: viewModel.category) + + PurchaseFooterView(viewModel: viewModel) + .animation(.spring.speed(2), value: viewModel.selectedPricingIdentifier) + } + .padding() + .background( + AffineColors.layerBackgroundSecondary.color + ) + } + + @ViewBuilder + var content: some View { + switch viewModel.category { + case .pro: + SKUnitProDetailView(viewModel: viewModel) + case .ai: + SKUnitIntelligentDetailView(viewModel: viewModel) + case .believer: + SKUnitBelieverDetailView(viewModel: viewModel) + } + } +} + +#Preview { + struct PreviewWrapper: View { + @StateObject var viewModel = ViewModel() + var body: some View { + AffinePaywallPageView(viewModel: viewModel) + } + } + return PreviewWrapper() +} 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 new file mode 100644 index 000000000..cd4b5b28a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift @@ -0,0 +1,30 @@ +// +// File.swift +// AffinePaywall +// +// Created by qaq on 9/18/25. +// + +import UIKit +import SwiftUI + +public enum Paywall { + @MainActor + public static func presentWall( + toController controller: UIViewController, + type: String + ) { + let viewModel = ViewModel() + switch type { + // TODO: FIGURE OUT PAYWALL TYPES + default: + break + } + let view = AffinePaywallPageView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: view) + hostingController.modalPresentationStyle = .overFullScreen + hostingController.modalTransitionStyle = .coverVertical + hostingController.preferredContentSize = CGSize(width: 555, height: 555) // for iPads + controller.present(hostingController, animated: true) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/AI_CHECK.svg b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/AI_CHECK.svg new file mode 100644 index 000000000..9bb992a3b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/AI_CHECK.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/Contents.json new file mode 100644 index 000000000..58f352b1d --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_CHECK.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AI_CHECK.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/AI_PEN.svg b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/AI_PEN.svg new file mode 100644 index 000000000..1acf9773f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/AI_PEN.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/Contents.json new file mode 100644 index 000000000..b109fe578 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PEN.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AI_PEN.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B 1.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B 1.png new file mode 100644 index 000000000..b99cc69f6 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B 1.png differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B.png new file mode 100644 index 000000000..8b6226486 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/AI_PREVIEW_B.png differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/Contents.json new file mode 100644 index 000000000..874a031f0 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_DRAW.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "AI_PREVIEW_B.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "AI_PREVIEW_B 1.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C 1.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C 1.png new file mode 100644 index 000000000..ff3f08132 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C 1.png differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C.png new file mode 100644 index 000000000..32fb5680a Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/AI_PREVIEW_C.png differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/Contents.json new file mode 100644 index 000000000..5680eac40 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_PLAN.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "AI_PREVIEW_C.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "AI_PREVIEW_C 1.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A 1.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A 1.png new file mode 100644 index 000000000..a8bf3fe91 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A 1.png differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A.png new file mode 100644 index 000000000..5cd2b0263 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/AI_PREVIEW_A.png differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/Contents.json new file mode 100644 index 000000000..dd7c7136a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_PREVIEW_WRITE.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "AI_PREVIEW_A 1.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "AI_PREVIEW_A.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/AI_TEXT.svg b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/AI_TEXT.svg new file mode 100644 index 000000000..dbb383884 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/AI_TEXT.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/Contents.json new file mode 100644 index 000000000..01771f719 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/AI_TEXT.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AI_TEXT.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/BELIVER_ICON.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/BELIVER_ICON.png new file mode 100644 index 000000000..700f5483a Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/BELIVER_ICON.png differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Contents.json new file mode 100644 index 000000000..2a87649bc --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "Image.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "BELIVER_ICON.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Image.png b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Image.png new file mode 100644 index 000000000..2a516ddc1 Binary files /dev/null and b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/BELIVER_ICON.imageset/Image.png differ diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/Contents.json b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Resources/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 000000000..9e803e303 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Utils/ViewOffsetKey.swift @@ -0,0 +1,16 @@ +// +// 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() + } +}