diff --git a/swift-paperless.xcodeproj/project.pbxproj b/swift-paperless.xcodeproj/project.pbxproj index 8f9d58b1..fbcecdfb 100644 --- a/swift-paperless.xcodeproj/project.pbxproj +++ b/swift-paperless.xcodeproj/project.pbxproj @@ -18,14 +18,13 @@ 7A4D58892D13568900FCB1F1 /* DataModel in Frameworks */ = {isa = PBXBuildFile; productRef = 7A4D58882D13568900FCB1F1 /* DataModel */; }; 7A4D588B2D13569500FCB1F1 /* DataModel in Frameworks */ = {isa = PBXBuildFile; productRef = 7A4D588A2D13569500FCB1F1 /* DataModel */; }; 7A4D588D2D13569A00FCB1F1 /* DataModel in Frameworks */ = {isa = PBXBuildFile; productRef = 7A4D588C2D13569A00FCB1F1 /* DataModel */; }; - 7A59C91B2F143239002EC151 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 7A59C91A2F143239002EC151 /* Common */; }; - 7A59C91D2F143239002EC151 /* Networking in Frameworks */ = {isa = PBXBuildFile; productRef = 7A59C91C2F143239002EC151 /* Networking */; }; 7A5C138A29AA7E2F00D71F51 /* Semaphore in Frameworks */ = {isa = PBXBuildFile; productRef = 7A5C138929AA7E2F00D71F51 /* Semaphore */; }; 7A64771C2BDD50D200217C18 /* libraries.md in Resources */ = {isa = PBXBuildFile; fileRef = 7A64771B2BDD50D200217C18 /* libraries.md */; }; 7A68F0B329B4B94B00878223 /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 7A68F0B229B4B94B00878223 /* Flow */; }; 7A6AA3452C1EDEBE007A1866 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 7A6AA3442C1EDEBE007A1866 /* MarkdownUI */; }; 7A6DA7002D7DD3E900B3B185 /* Networking in Frameworks */ = {isa = PBXBuildFile; productRef = 7A6DA6FF2D7DD3E900B3B185 /* Networking */; }; 7A6DA7022D7DD3F000B3B185 /* Networking in Frameworks */ = {isa = PBXBuildFile; productRef = 7A6DA7012D7DD3F000B3B185 /* Networking */; }; + 7A94EF072F1ECE05005DA431 /* Testing.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 7A94EF062F1ECE05005DA431 /* Testing.storekit */; }; 7AAC43182CA06F8D003E6A3A /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 7AAC43172CA06F8D003E6A3A /* AsyncAlgorithms */; }; 7AAC44542CA0A37D003E6A3A /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 7AAC44532CA0A37D003E6A3A /* Common */; }; 7AAC44562CA0A38B003E6A3A /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 7AAC44552CA0A38B003E6A3A /* Common */; }; @@ -40,6 +39,8 @@ 7AC40D402D134ED1003F68B1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7AC40C9A2D134ED1003F68B1 /* Assets.xcassets */; }; 7AC40D422D134ED1003F68B1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7AC40CA72D134ED1003F68B1 /* PrivacyInfo.xcprivacy */; }; 7AC40DBE2D134ED1003F68B1 /* swift_paperlessApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC40CA92D134ED1003F68B1 /* swift_paperlessApp.swift */; }; + 7AEAC2422F1FF4C2004B9D44 /* Swift Paperless.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 7AEAC2412F1FF4C2004B9D44 /* Swift Paperless.storekit */; }; + 7AEAC2432F1FF4C2004B9D44 /* Swift Paperless.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 7AEAC2412F1FF4C2004B9D44 /* Swift Paperless.storekit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -81,6 +82,7 @@ 7A5482C6299AD56E00D5061E /* swift-paperlessUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "swift-paperlessUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 7A64771B2BDD50D200217C18 /* libraries.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = libraries.md; path = docs/libraries.md; sourceTree = ""; }; 7A6DA7212D7DD42100B3B185 /* Networking */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Networking; sourceTree = ""; }; + 7A94EF062F1ECE05005DA431 /* Testing.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Testing.storekit; sourceTree = ""; }; 7AAC44512CA0A31E003E6A3A /* Common */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Common; sourceTree = ""; }; 7ABA3B872E6F3F53006BB39B /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 7AC40C9A2D134ED1003F68B1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -88,6 +90,7 @@ 7AC40CA72D134ED1003F68B1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 7AC40CA82D134ED1003F68B1 /* swift_paperless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = swift_paperless.entitlements; sourceTree = ""; }; 7AC40CA92D134ED1003F68B1 /* swift_paperlessApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = swift_paperlessApp.swift; sourceTree = ""; }; + 7AEAC2412F1FF4C2004B9D44 /* Swift Paperless.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Swift Paperless.storekit"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -284,15 +287,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 7A59BDFE2F129272002EC151 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 7A59C91D2F143239002EC151 /* Networking in Frameworks */, - 7A59C91B2F143239002EC151 /* Common in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -347,6 +341,8 @@ 7AC40CA72D134ED1003F68B1 /* PrivacyInfo.xcprivacy */, 7AC40CA82D134ED1003F68B1 /* swift_paperless.entitlements */, 7AC40CA92D134ED1003F68B1 /* swift_paperlessApp.swift */, + 7A94EF062F1ECE05005DA431 /* Testing.storekit */, + 7AEAC2412F1FF4C2004B9D44 /* Swift Paperless.storekit */, ); path = "swift-paperless"; sourceTree = ""; @@ -553,6 +549,7 @@ 7AC40CB62D134ED1003F68B1 /* Assets.xcassets in Resources */, 7ABA3B892E6F3F53006BB39B /* AppIcon.icon in Resources */, 7AC40CB82D134ED1003F68B1 /* PrivacyInfo.xcprivacy in Resources */, + 7AEAC2432F1FF4C2004B9D44 /* Swift Paperless.storekit in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -561,6 +558,8 @@ buildActionMask = 2147483647; files = ( 7AC40D402D134ED1003F68B1 /* Assets.xcassets in Resources */, + 7AEAC2422F1FF4C2004B9D44 /* Swift Paperless.storekit in Resources */, + 7A94EF072F1ECE05005DA431 /* Testing.storekit in Resources */, 7AC40D422D134ED1003F68B1 /* PrivacyInfo.xcprivacy in Resources */, 7A64771C2BDD50D200217C18 /* libraries.md in Resources */, 7ABA3B882E6F3F53006BB39B /* AppIcon.icon in Resources */, @@ -1251,14 +1250,6 @@ isa = XCSwiftPackageProductDependency; productName = DataModel; }; - 7A59C91A2F143239002EC151 /* Common */ = { - isa = XCSwiftPackageProductDependency; - productName = Common; - }; - 7A59C91C2F143239002EC151 /* Networking */ = { - isa = XCSwiftPackageProductDependency; - productName = Networking; - }; 7A5C138929AA7E2F00D71F51 /* Semaphore */ = { isa = XCSwiftPackageProductDependency; package = 7A5C138829AA7E2F00D71F51 /* XCRemoteSwiftPackageReference "Semaphore" */; diff --git a/swift-paperless.xcodeproj/xcshareddata/xcschemes/swift-paperless.xcscheme b/swift-paperless.xcodeproj/xcshareddata/xcschemes/swift-paperless.xcscheme index a28801ee..c1957fe1 100644 --- a/swift-paperless.xcodeproj/xcshareddata/xcschemes/swift-paperless.xcscheme +++ b/swift-paperless.xcodeproj/xcshareddata/xcschemes/swift-paperless.xcscheme @@ -57,6 +57,9 @@ isEnabled = "YES"> + + : ViewModifier { @Binding var item: Item? - var title: (Binding) -> Text - var actions: (Binding) -> M - var message: ((Binding) -> A)? + var title: (Item) -> Text + var actions: (Item) -> M + var message: ((Item) -> A)? var titleText: Text { - item == nil ? Text("nil") : title(Binding($item)!) + if let item = Binding($item) { + title(item.wrappedValue) + } else { + Text("nil") + } } func body(content: Content) -> some View { @@ -24,14 +28,14 @@ private struct AlertModifier: ViewModifier { titleText, isPresented: .present($item), actions: { if let item = Binding($item) { - actions(item) + actions(item.wrappedValue) } else { EmptyView() } }, message: { if let item = Binding($item) { - message?(item) + message?(item.wrappedValue) } else { EmptyView() } @@ -43,17 +47,17 @@ private struct AlertModifier: ViewModifier { extension View { func alert( unwrapping item: Binding, - title: @escaping (Binding) -> Text, - @ViewBuilder actions: @escaping (Binding) -> some View, - @ViewBuilder message: @escaping (Binding) -> some View + title: @escaping (Item) -> Text, + @ViewBuilder actions: @escaping (Item) -> some View, + @ViewBuilder message: @escaping (Item) -> some View ) -> some View { modifier(AlertModifier(item: item, title: title, actions: actions, message: message)) } func alert( unwrapping item: Binding, - title: @escaping (Binding) -> Text, - @ViewBuilder actions: @escaping (Binding) -> some View + title: @escaping (Item) -> Text, + @ViewBuilder actions: @escaping (Item) -> some View ) -> some View { modifier( AlertModifier(item: item, title: title, actions: actions, message: { _ in EmptyView() })) diff --git a/swift-paperless/Views/Error/ErrorDisplay.swift b/swift-paperless/Views/Error/ErrorDisplay.swift index 9d2f55dd..da230083 100644 --- a/swift-paperless/Views/Error/ErrorDisplay.swift +++ b/swift-paperless/Views/Error/ErrorDisplay.swift @@ -131,10 +131,10 @@ struct ErrorDisplay: ViewModifier { .alert( unwrapping: $detail, - title: { $detail in + title: { detail in Text(detail.message) }, - actions: { $detail in + actions: { detail in Button(String(localized: .localizable(.copyToClipboard))) { Pasteboard.general.string = detail.details } @@ -145,7 +145,7 @@ struct ErrorDisplay: ViewModifier { Button(String(localized: .localizable(.ok)), role: .cancel) {} }, - message: { $detail in + message: { detail in Text(detail.details!) } ) diff --git a/swift-paperless/Views/Settings/SettingsView.swift b/swift-paperless/Views/Settings/SettingsView.swift index 4508331e..e86da276 100644 --- a/swift-paperless/Views/Settings/SettingsView.swift +++ b/swift-paperless/Views/Settings/SettingsView.swift @@ -111,6 +111,13 @@ struct SettingsView: View { private var detailSection: some View { Section(String(localized: .settings(.detailsTitle))) { + NavigationLink { + TipJarView() + } label: { + Label(localized: .settings(.tipJarTitle), systemImage: "heart.fill") + .labelStyle(.iconTint(.red)) + } + NavigationLink { LibrariesView() } label: { diff --git a/swift-paperless/Views/Settings/TipJarView.swift b/swift-paperless/Views/Settings/TipJarView.swift new file mode 100644 index 00000000..060e4f09 --- /dev/null +++ b/swift-paperless/Views/Settings/TipJarView.swift @@ -0,0 +1,229 @@ +import StoreKit +import SwiftUI + +@Observable +@MainActor +class TipJarStore { + enum LoadState { + case idle + case loading + case loaded([ProductID: Product]) + case failed + } + + struct TipJarAlert: Identifiable { + let id = UUID() + let title: LocalizedStringResource + let message: LocalizedStringResource + } + + enum ProductID: String, CaseIterable { + case tip_small, tip_medium, tip_large, tip_xlarge + + var emoji: String { + switch self { + case .tip_small: "☕" + case .tip_medium: "🙌" + case .tip_large: "🤩" + case .tip_xlarge: "💰" + } + } + } + + private(set) var loadState: LoadState = .idle + var purchasingProductID: String? = nil + var alert: TipJarAlert? = nil + + @ObservationIgnored + private var updatesTask: Task? + + init() { + updatesTask = Task { await listenForTransactions() } + } + + deinit { + updatesTask?.cancel() + } + + func loadProducts() async { + loadState = .loading + do { + let raw = try await Product.products(for: ProductID.allCases.map { $0.rawValue }) + var products = [ProductID: Product]() + for product in raw { + guard let id = ProductID(rawValue: product.id) else { continue } + products[id] = product + } + loadState = .loaded(products) + } catch { + loadState = .failed + } + } + + func purchase(_ product: Product) async { + purchasingProductID = product.id + defer { purchasingProductID = nil } + + do { + let result = try await product.purchase() + switch result { + case .success(let verificationResult): + let transaction = try checkVerified(verificationResult) + await transaction.finish() + Haptics.shared.notification(.success) + alert = TipJarAlert( + title: .settings(.tipJarThanksTitle), + message: .settings(.tipJarThanksMessage)) + case .pending: + alert = TipJarAlert( + title: .settings(.tipJarPendingTitle), + message: .settings(.tipJarPendingMessage)) + case .userCancelled: + break + @unknown default: + break + } + } catch { + alert = TipJarAlert( + title: .settings(.tipJarPurchaseErrorTitle), + message: .settings(.tipJarPurchaseErrorMessage)) + } + } + + private func listenForTransactions() async { + for await result in Transaction.updates { + guard let transaction = try? checkVerified(result) else { continue } + await transaction.finish() + } + } + + private func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .verified(let safe): + return safe + case .unverified: + throw TipJarStoreError.failedVerification + } + } +} + +enum TipJarStoreError: Error { + case failedVerification +} + +struct TipJarView: View { + @State private var store = TipJarStore() + + var body: some View { + Form { + Section { + VStack { + HStack { + Text("🫙") + .font(.custom("big", size: 50, relativeTo: .title)) + Text(.settings(.tipJarTipsLabel)) + .font(.title) + .foregroundStyle(.accent) + .bold() + } + Text(.settings(.tipJarDescription)) + .font(.callout) + } + } + + Section(String(localized: .settings(.tipJarOptionsTitle))) { + tipOptions + } + } + .navigationTitle(Text(.settings(.tipJarTitle))) + .navigationBarTitleDisplayMode(.inline) + .task { + await store.loadProducts() + } + .refreshable { + await store.loadProducts() + } + + .alert( + unwrapping: $store.alert, + title: { alert in + Text(alert.title) + }, + actions: { alert in + Button(.localizable(.ok)) { store.alert = nil } + }, + message: { alert in + Text(alert.message) + }) + } + + @ViewBuilder + private var tipOptions: some View { + switch store.loadState { + case .idle, .loading: + HStack { + ProgressView(String(localized: .settings(.tipJarLoading))) + .frame(maxWidth: .infinity, alignment: .center) + } + case .failed: + Text(.settings(.tipJarLoadError)) + .foregroundStyle(.secondary) + case .loaded(let products): + if products.isEmpty { + Text(.settings(.tipJarNoProducts)) + .foregroundStyle(.secondary) + } else { + let knownProducts = TipJarStore.ProductID.allCases.compactMap { id in + products[id].map { (id, $0) } + } + ForEach(knownProducts, id: \.0) { id, product in + tipRow(for: product, id: id) + } + } + } + } + + private func tipRow(for product: Product, id: TipJarStore.ProductID) -> some View { + let isProcessing = store.purchasingProductID == product.id + + return Button { + Task { await store.purchase(product) } + } label: { + HStack(alignment: .center, spacing: 12) { + Text(id.emoji) + .font(.title) + VStack(alignment: .leading, spacing: 4) { + Text(product.displayName) + if !product.description.isEmpty { + Text(product.description) + .font(.footnote) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Spacer() + + VStack { + if isProcessing { + ProgressView() + } else { + Text(product.displayPrice) + } + } + .fontWeight(.semibold) + .foregroundStyle(.white) + .padding(.horizontal) + .padding(.vertical, 5) + .background(Capsule()) + } + .animation(.default, value: store.purchasingProductID) + } + .disabled(store.purchasingProductID != nil) + } +} + +#Preview("TipJarView") { + NavigationStack { + TipJarView() + } +}