// // TipJarView.swift // Tusker // // Created by Shadowfacts on 1/24/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import SwiftUI import StoreKit import Combine struct TipJarView: View { private static let productIDs = [ "tusker.tip.small", "tusker.tip.medium", "tusker.tip.large", ] @State private var isLoaded = false @State private var products: [(Product, Bool)] = [] @State private var error: Error? @State private var showConfetti = false @State private var updatesObserver: Task? @State private var buttonWidth: CGFloat? @StateObject private var observer = UbiquitousKeyValueStoreObserver() var body: some View { ZStack { Color.appGroupedBackground .edgesIgnoringSafeArea(.all) productsView if showConfetti { ConfettiView() .transition(.opacity.animation(.default)) } } .navigationTitle("Tip Jar") .alertWithData("Error", data: $error, actions: { _ in Button("OK") {} }, message: { error in Text(error.localizedDescription) }) .task { updatesObserver = Task.detached { await observeTransactionUpdates() } do { products = try await Product.products(for: Self.productIDs).map { ($0, false) } products.sort(by: { $0.0.price < $1.0.price }) isLoaded = true } catch { self.error = .fetchingProducts(error) } } .onDisappear { updatesObserver?.cancel() } .onReceive(Just(showConfetti).filter { $0 }.delay(for: .seconds(5), scheduler: DispatchQueue.main)) { _ in showConfetti = false } } @ViewBuilder private var productsView: some View { if isLoaded { VStack { Text("If you're enjoying using Tusker and want to show your gratitude or help support its development, it is greatly appreciated!") .multilineTextAlignment(.center) .padding(.horizontal) VStack(alignment: .myAlignment) { ForEach($products, id: \.0.id) { $productAndPurchasing in TipRow(product: productAndPurchasing.0, buttonWidth: buttonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti) } } .onPreferenceChange(ButtonWidthKey.self) { newValue in self.buttonWidth = newValue } if let total = getTotalTips(), total > 0 { Text("You've tipped a total of \(Text(total, format: products[0].0.priceFormatStyle)) 😍") Text("Thank you!") } } } else { ProgressView() .progressViewStyle(.circular) } } private func observeTransactionUpdates() async { for await verificationResult in StoreKit.Transaction.updates { guard let index = products.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) else { continue } switch verificationResult { case .verified(let transaction): await transaction.finish() self.products[index].1 = false self.showConfetti = true case .unverified(_, let error): self.error = .verifyingTransaction(error) } } } } extension TipJarView { enum Error { case fetchingProducts(Swift.Error) case purchasing(Swift.Error) case verifyingTransaction(VerificationResult.VerificationError) var localizedDescription: String { switch self { case .fetchingProducts(let underlying): return "Error fetching products: \(String(describing: underlying))" case .purchasing(let underlying): return "Error purchasing: \(String(describing: underlying))" case .verifyingTransaction(let underlying): return "Error verifying transaction: \(String(describing: underlying))" } } } } private struct TipRow: View { let product: Product let buttonWidth: CGFloat? @Binding var isPurchasing: Bool @Binding var showConfetti: Bool @State private var error: TipJarView.Error? var body: some View { HStack { Text(product.displayName) .alignmentGuide(.myAlignment, computeValue: { context in context[.trailing] }) Button { Task { await self.purchase() } } label: { if isPurchasing { ProgressView() .progressViewStyle(.circular) .frame(width: buttonWidth, alignment: .center) } else { Text(product.displayPrice) .background(GeometryReader { proxy in Color.clear .preference(key: ButtonWidthKey.self, value: proxy.size.width) }) .frame(width: buttonWidth) } } .buttonStyle(.borderedProminent) } .alertWithData("Error", data: $error) { _ in Button("OK") {} } message: { error in Text(error.localizedDescription) } } private func purchase() async { isPurchasing = true let result: Product.PurchaseResult do { result = try await product.purchase() } catch { self.error = .purchasing(error) isPurchasing = false return } let transaction: StoreKit.Transaction switch result { case .success(let verificationResult): switch verificationResult { case .unverified(_, let reason): isPurchasing = false error = .verifyingTransaction(reason) return case .verified(let t): transaction = t } case .userCancelled: isPurchasing = false return case .pending: // pending transactions may still be completed, but we won't update UI in response to them isPurchasing = false return @unknown default: isPurchasing = false return } await transaction.finish() isPurchasing = false showConfetti = true addToTotalTips(amount: product.price) } } extension HorizontalAlignment { private enum MyTrailing: AlignmentID { static func defaultValue(in context: ViewDimensions) -> CGFloat { return context[.bottom] } } fileprivate static let myAlignment = HorizontalAlignment(MyTrailing.self) } private struct ButtonWidthKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } } private func getTotalTips() -> Decimal? { if let data = NSUbiquitousKeyValueStore.default.data(forKey: "totalTips"), let existing = try? PropertyListDecoder().decode(Decimal.self, from: data) { return existing } else { return nil } } private func addToTotalTips(amount: Decimal) { let newAmount = amount + (getTotalTips() ?? 0) if let data = try? PropertyListEncoder().encode(newAmount) { NSUbiquitousKeyValueStore.default.set(data, forKey: "totalTips") } } private class UbiquitousKeyValueStoreObserver: ObservableObject { private var cancellable: AnyCancellable? init() { self.cancellable = NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification) .receive(on: DispatchQueue.main) .sink { [unowned self] _ in self.objectWillChange.send() } } }