// // ContributeView.swift // Toki Trainer // // Created by Madeline Pace on 12/16/23. // import SwiftUI import StoreKit typealias Transaction = StoreKit.Transaction public enum StoreError: Error { case failedVerification } // MARK: ContributeView struct ContributeView: View { private let supportString = """ Hi 👋 I'm Maddie, the primary developer for Toki Trainer. This app is free and open source. If you find it \ useful, please consider supporting my development efforts. I don't collect your data and am committed to providing \ a high-quality, ad-free experience. Learning toki pona \ easily and comfortably is the whole point, and ads would \ ruin that! Taking a moment to add an App Store Review helps a ton. \ Please also consider donating financially if you can. 💕 """ private var recurringIAPs = ["TierThree"] @EnvironmentObject var transactions: TransactionObserver var body: some View { VStack { Spacer() if transactions.hasDonated { ThankYouBannerView(donationHearts: $transactions.donationHearts) } Text(supportString) .padding(16) Spacer() // Link("Source Code", destination: URL(string: "https://git.corrupt.link/maddiefuzz/TokiTrainer")!) // .padding(12) // .padding([.bottom], 12) HStack { ReviewButton() SourceCodeButton() } HStack { SingleDonationButton() RecurringDonationButton() } Spacer() } } } // MARK: ThankYouBannerView struct ThankYouBannerView: View { @Binding var donationHearts: Int var body: some View { ZStack { Rectangle() .fill(.blue) .cornerRadius(15) VStack { Text("Thank you for donating!") .multilineTextAlignment(.center) .font(.title) Text("\(donationHearts) 💕") .padding(10) .font(.title3) } } .frame(width: 250, height: 140) } } struct SourceCodeButton: View { var body: some View { Link(destination: URL(string: "https://git.corrupt.link/maddiefuzz/TokiTrainer")!) { VStack { Image(systemName: "apple.terminal") .font(.system(size: 24, weight: .regular)) .padding(2) Text("Source Code") } } .frame(width: 100) .padding(8) } } // MARK: ReviewButton struct ReviewButton: View { @Environment(\.requestReview) private var requestReview var body: some View { Button(action: { print("Review requested") presentReviewRequest() }, label: { VStack { Image(systemName: "star.bubble") .font(.system(size: 24, weight: .regular)) .padding(2) Text("Write Review") } }) .frame(width: 100) .padding(8) } func presentReviewRequest() { Task { await requestReview() } } } // Workaround for .sheet(item:) expecting an Identifiable struct DonationProducts: Identifiable { let id = UUID() var products = [Product]() } // MARK: SingleDonationButton struct SingleDonationButton: View { @Environment(\.purchase) private var purchase: PurchaseAction @EnvironmentObject var transactions: TransactionObserver @State private var IAPs: DonationProducts? @State private var productsFetched = false @State private var disabledPurchaseButtons = false private var singleIAPs = ["SingleTimeTipTierOne", "SingleTimeTipTierTwo", "SingleTimeTipTierThree", "SingleTimeTipTierFour"] var body: some View { Button { Task { try await loadSingleProducts() } } label: { VStack { Image(systemName: "dollarsign.circle") .font(.system(size: 24, weight: .regular)) .padding(2) Text("Donate Once") } } .frame(width: 100) .padding(8) .sheet(item: self.$IAPs) { IAPs in if(productsFetched) { VStack { Spacer() Text("One-Time Donation") .font(.largeTitle) Spacer() ForEach(IAPs.products) { product in HStack { VStack { Text(product.displayName) .font(.title) .frame(maxWidth: .infinity, alignment: .leading) Text(product.description) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.leading, 12) Spacer() Button { print("Purchase this one: \(product)") disabledPurchaseButtons = true Task { let _ = try? await purchase(product) disabledPurchaseButtons = false } } label: { Text(product.displayPrice) .frame(minWidth: 50) .padding(8) .fontWeight(/*@START_MENU_TOKEN@*/.bold/*@END_MENU_TOKEN@*/) } .padding(.trailing, 12) .buttonStyle(.bordered) .disabled(disabledPurchaseButtons) } .padding(12) } Spacer() LegalLinksView() .padding(16) .padding([.bottom], 20) } //.padding([.top], 60) } else { ProgressView() } } } private func purchase(_ product: Product) async throws -> Transaction? { let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) await transaction.finish() print("Purchase success") transactions.processConsumable(transaction.productID) return transaction case .userCancelled, .pending: return nil default: return nil } } func checkVerified(_ result: VerificationResult) throws -> T { //Check whether the JWS passes StoreKit verification. switch result { case .unverified: //StoreKit parses the JWS, but it fails verification. throw StoreError.failedVerification case .verified(let safe): //The result is verified. Return the unwrapped value. return safe } } private func loadSingleProducts() async throws { self.IAPs = DonationProducts() self.IAPs?.products = try await Product.products(for: singleIAPs).sorted(by: { p1, p2 in p1.price < p2.price }) self.productsFetched = true } } // MARK: RecurringDonationButton struct RecurringDonationButton: View { @State var toggleSheet = false private let groupID = "21424772" var body: some View { Button { print("Subscription button pressed") toggleSheet.toggle() } label: { VStack { Image(systemName: "dollarsign.arrow.circlepath") .font(.system(size: 24, weight: .regular)) .padding(2) Text("Donate Monthly") } } .frame(width: 100) .padding(8) .sheet(isPresented: $toggleSheet, content: { VStack { SubscriptionStoreView(groupID: self.groupID) // HStack { // Link("Terms of Use", destination: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!) // .padding(12) // Link("Privacy Policy", destination: URL(string: "https://maddie.info/null_privacy_policy")!) // } LegalLinksView() .padding(16) .padding([.bottom], 20) } }) } } struct LegalLinksView: View { var body: some View { HStack { Link("Terms of Use", destination: URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")!) .padding(12) Link("Privacy Policy", destination: URL(string: "https://maddie.info/null_privacy_policy")!) } } } #Preview { ContributeView().environmentObject(TransactionObserver()) } #Preview { @State var donationHearts = 5000 return ThankYouBannerView(donationHearts: $donationHearts) }