diff --git a/Toki Trainer.xcodeproj/project.pbxproj b/Toki Trainer.xcodeproj/project.pbxproj index 11598b3..ecd6f33 100644 --- a/Toki Trainer.xcodeproj/project.pbxproj +++ b/Toki Trainer.xcodeproj/project.pbxproj @@ -37,11 +37,14 @@ 7E943A2A273211C300E7DDF4 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E943A29273211C300E7DDF4 /* Persistence.swift */; }; 7E943A2D273211C300E7DDF4 /* Toki_Trainer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 7E943A2B273211C300E7DDF4 /* Toki_Trainer.xcdatamodeld */; }; 7EF546162737B8FB00537AE6 /* FlashCardResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF546152737B8FA00537AE6 /* FlashCardResultsView.swift */; }; + C13909F62B30ACC300B235EE /* TransactionObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13909F52B30ACC300B235EE /* TransactionObserver.swift */; }; C13FCE342A9D170B00E8976B /* toki-dictionary.json in Resources */ = {isa = PBXBuildFile; fileRef = C13FCE372A9D170B00E8976B /* toki-dictionary.json */; }; C13FCE352A9D170B00E8976B /* toki-dictionary.json in Resources */ = {isa = PBXBuildFile; fileRef = C13FCE372A9D170B00E8976B /* toki-dictionary.json */; }; C13FCE382A9D171300E8976B /* toki-lessons.json in Resources */ = {isa = PBXBuildFile; fileRef = C13FCE3A2A9D171300E8976B /* toki-lessons.json */; }; C13FCE3B2A9D171600E8976B /* toki-partsofspeech.json in Resources */ = {isa = PBXBuildFile; fileRef = C13FCE3D2A9D171600E8976B /* toki-partsofspeech.json */; }; C19DAB4E2AB38F2C00B17941 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C19DAB4D2AB38F2C00B17941 /* Localizable.xcstrings */; }; + C1A70F5D2B2D78B300CDE5C8 /* ContributeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A70F5C2B2D78B200CDE5C8 /* ContributeView.swift */; }; + C1A70F5F2B2D900200CDE5C8 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1A70F5E2B2D900200CDE5C8 /* StoreKit.framework */; }; E1A8B364290B905600B53385 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A8B363290B905600B53385 /* ViewExtensions.swift */; }; E1D79AE328EC396200A104BF /* DictionaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D79AE228EC396200A104BF /* DictionaryView.swift */; }; E1D79AE528F1914600A104BF /* TranslatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D79AE428F1914600A104BF /* TranslatorView.swift */; }; @@ -101,6 +104,7 @@ 7E943A29273211C300E7DDF4 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; 7E943A2C273211C300E7DDF4 /* Toki_Trainer.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Toki_Trainer.xcdatamodel; sourceTree = ""; }; 7EF546152737B8FA00537AE6 /* FlashCardResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashCardResultsView.swift; sourceTree = ""; }; + C13909F52B30ACC300B235EE /* TransactionObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionObserver.swift; sourceTree = ""; }; C13FCE362A9D170B00E8976B /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = Base; path = "Base.lproj/toki-dictionary.json"; sourceTree = ""; }; C13FCE392A9D171300E8976B /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = Base; path = "Base.lproj/toki-lessons.json"; sourceTree = ""; }; C13FCE3C2A9D171600E8976B /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = Base; path = "Base.lproj/toki-partsofspeech.json"; sourceTree = ""; }; @@ -112,6 +116,8 @@ C13FCE432A9D181B00E8976B /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = fr; path = "fr.lproj/toki-partsofspeech.json"; sourceTree = ""; }; C18C977E2B07FC9C0049EEF6 /* Toki-Trainer-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Toki-Trainer-Info.plist"; sourceTree = SOURCE_ROOT; }; C19DAB4D2AB38F2C00B17941 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + C1A70F5C2B2D78B200CDE5C8 /* ContributeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributeView.swift; sourceTree = ""; }; + C1A70F5E2B2D900200CDE5C8 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; E1A8B363290B905600B53385 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; E1D79AE228EC396200A104BF /* DictionaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryView.swift; sourceTree = ""; }; E1D79AE428F1914600A104BF /* TranslatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslatorView.swift; sourceTree = ""; }; @@ -133,6 +139,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C1A70F5F2B2D900200CDE5C8 /* StoreKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -146,6 +153,7 @@ 7E2811152733027F0063DC78 /* TokiJSONLoader.swift */, 7E2811162733027F0063DC78 /* TokiPartOfSpeech.swift */, 7E716B3D273986E5009E2CF6 /* TokiLesson.swift */, + C13909F52B30ACC300B235EE /* TransactionObserver.swift */, ); path = Models; sourceTree = ""; @@ -159,6 +167,7 @@ 7E71E6EC2735D70C007CFF72 /* FlashCardView.swift */, 7EF546152737B8FA00537AE6 /* FlashCardResultsView.swift */, 7E716B4127398CDF009E2CF6 /* FlashCardLessonsView.swift */, + C1A70F5C2B2D78B200CDE5C8 /* ContributeView.swift */, ); path = Views; sourceTree = ""; @@ -186,6 +195,7 @@ 7E449773275AA0600016B6DC /* Frameworks */ = { isa = PBXGroup; children = ( + C1A70F5E2B2D900200CDE5C8 /* StoreKit.framework */, 7E449774275AA0600016B6DC /* WidgetKit.framework */, 7E449776275AA0600016B6DC /* SwiftUI.framework */, ); @@ -400,7 +410,9 @@ buildActionMask = 2147483647; files = ( 7E943A2D273211C300E7DDF4 /* Toki_Trainer.xcdatamodeld in Sources */, + C1A70F5D2B2D78B300CDE5C8 /* ContributeView.swift in Sources */, 7E943A2A273211C300E7DDF4 /* Persistence.swift in Sources */, + C13909F62B30ACC300B235EE /* TransactionObserver.swift in Sources */, 7E20D5FF2733AFE700D75B9A /* PartsOfSpeechView.swift in Sources */, 7E71E6F12736DAE4007CFF72 /* FlashCardsViewModel.swift in Sources */, E1D79AEB28F194EF00A104BF /* LanguageDirectionView.swift in Sources */, @@ -574,7 +586,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -630,7 +642,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -661,6 +673,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -695,6 +708,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Toki Trainer.xcodeproj/xcuserdata/madeline.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Toki Trainer.xcodeproj/xcuserdata/madeline.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..4112255 --- /dev/null +++ b/Toki Trainer.xcodeproj/xcuserdata/madeline.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/Toki Trainer/Constants.swift b/Toki Trainer/Constants.swift index ad5eded..9ddee8c 100644 --- a/Toki Trainer/Constants.swift +++ b/Toki Trainer/Constants.swift @@ -24,7 +24,25 @@ struct K { "oth": UIColor.systemBrown, "extra": UIColor.systemBrown ] - + + struct UserDefaults { + static let donationHearts = "donationHearts" + static let hasDonated = "hasDonated" + } + + struct ConsumableTransactions { + static let TierOne = "SingleTimeTipTierOne" + static let TierTwo = "SingleTimeTipTierTwo" + static let TierThree = "SingleTimeTipTierThree" + static let TierFour = "SingleTimeTipTierFour" + } + + struct MonthlyTransactions { + static let TierOne = "TierOne" + static let TierTwo = "TierTwo" + static let TierThree = "TierThree" + } + static var getFlashCardAnswersFetchRequest: NSFetchRequest { let request: NSFetchRequest = FlashCardAnswer.fetchRequest() request.sortDescriptors = [] diff --git a/Toki Trainer/Localizable.xcstrings b/Toki Trainer/Localizable.xcstrings index 1c0be52..89a48dd 100644 --- a/Toki Trainer/Localizable.xcstrings +++ b/Toki Trainer/Localizable.xcstrings @@ -20,6 +20,12 @@ } } } + }, + "%lld 💕" : { + + }, + "Contribute" : { + }, "Correct" : { "localizations" : { @@ -50,6 +56,12 @@ } } } + }, + "Donate Monthly" : { + + }, + "Donate Once" : { + }, "Enter Toki Pona Word or Phrase" : { "localizations" : { @@ -110,6 +122,9 @@ } } } + }, + "One-Time Donation" : { + }, "Parts of Speech" : { "localizations" : { @@ -170,6 +185,9 @@ } } } + }, + "Thank you for donating!" : { + }, "Words" : { "localizations" : { @@ -180,6 +198,9 @@ } } } + }, + "Write Review" : { + } }, "version" : "1.0" diff --git a/Toki Trainer/Models/TransactionObserver.swift b/Toki Trainer/Models/TransactionObserver.swift new file mode 100644 index 0000000..5693c2d --- /dev/null +++ b/Toki Trainer/Models/TransactionObserver.swift @@ -0,0 +1,98 @@ +// +// Store.swift +// Toki Trainer +// +// Created by Madeline Pace on 12/18/23. +// + +import CoreData +import StoreKit + +@MainActor +final class TransactionObserver: ObservableObject { + + var updates: Task? = nil + + @Published var donationHearts: Int = 0 + @Published var hasDonated = false + + + init() { + updates = newTransactionListenerTask() + donationHearts = UserDefaults.standard.integer(forKey: K.UserDefaults.donationHearts) + hasDonated = UserDefaults.standard.bool(forKey: K.UserDefaults.hasDonated) + } + + deinit { + updates?.cancel() + } + + func addDonationHearts(_ amount: Int) { + if hasDonated == false { + hasDonated = true + UserDefaults.standard.set(hasDonated, forKey: K.UserDefaults.hasDonated) + } + + DispatchQueue.main.async { + self.donationHearts += amount + } + UserDefaults.standard.set(donationHearts, forKey: K.UserDefaults.donationHearts) + } + + private func newTransactionListenerTask() -> Task { + Task(priority: .background) { + for await verificationResult in Transaction.updates { + await self.handle(updatedTransaction: verificationResult) + } + } + } + + private func handle(updatedTransaction verificationResult: VerificationResult) async { + guard case .verified(let transaction) = verificationResult else { + return + } + + switch transaction.productType { + case Product.ProductType.consumable: + processConsumable(transaction.productID) + case Product.ProductType.nonRenewable, Product.ProductType.autoRenewable: + processSubscription(transaction.productID) + default: + return + } + + print("Finishing transaction") + await transaction.finish() + } + + func processConsumable(_ productID: String) { + print("Consumable ID: \(productID)") + switch productID { + case K.ConsumableTransactions.TierOne: + self.addDonationHearts(100) + case K.ConsumableTransactions.TierTwo: + self.addDonationHearts(500) + case K.ConsumableTransactions.TierThree: + self.addDonationHearts(1000) + case K.ConsumableTransactions.TierFour: + self.addDonationHearts(2000) + default: + return + } + } + + func processSubscription(_ productID: String) { + // TODO: Write function + print("Subscription ID: \(productID)") + switch productID { + case K.MonthlyTransactions.TierOne: + self.addDonationHearts(100) + case K.MonthlyTransactions.TierTwo: + self.addDonationHearts(500) + case K.MonthlyTransactions.TierThree: + self.addDonationHearts(1000) + default: + return + } + } +} diff --git a/Toki Trainer/Views/ContentView.swift b/Toki Trainer/Views/ContentView.swift index ff40d11..512623f 100644 --- a/Toki Trainer/Views/ContentView.swift +++ b/Toki Trainer/Views/ContentView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import StoreKit import CoreData extension String: Identifiable { @@ -14,7 +15,9 @@ extension String: Identifiable { struct ContentView: View { @Environment(\.managedObjectContext) private var viewContext - + + @StateObject var transactions: TransactionObserver = TransactionObserver() + var body: some View { TabView { DictionaryView() @@ -32,9 +35,15 @@ struct ContentView: View { Image(systemName: "character.textbox") Text("Flash Cards") } + ContributeView() + .tabItem { + Image(systemName: "heart.circle.fill") + Text("Contribute") + } } + .environmentObject(transactions) } - + func openPartsOfSpeechView() { print("Button pressed.") } diff --git a/Toki Trainer/Views/ContributeView.swift b/Toki Trainer/Views/ContributeView.swift new file mode 100644 index 0000000..4f1938f --- /dev/null +++ b/Toki Trainer/Views/ContributeView.swift @@ -0,0 +1,263 @@ +// +// 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() + HStack { + ReviewButton() + 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) + } +} + +// 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: 80) + .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: 80) + .padding(8) + .sheet(item: self.$IAPs) { IAPs in + if(productsFetched) { + Text("One-Time Donation") + .font(.largeTitle) + 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) + } + } 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: 80) + .padding(8) + .sheet(isPresented: $toggleSheet, content: { + SubscriptionStoreView(groupID: self.groupID) + }) + } +} + +#Preview { + ContributeView().environmentObject(TransactionObserver()) +} + +#Preview { + @State var donationHearts = 5000 + + return ThankYouBannerView(donationHearts: $donationHearts) +}