From 5a9513bb306e3d7c4ec1dff4f66dd19f2d76c5d1 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Wed, 25 Jan 2023 22:23:03 -0500 Subject: [PATCH] Add tip jar --- Tusker.xcodeproj/project.pbxproj | 22 ++ .../Screens/Preferences/PreferencesView.swift | 3 + .../Preferences/Tip Jar/ConfettiView.swift | 119 +++++++++ .../Preferences/Tip Jar/TipJarView.swift | 247 ++++++++++++++++++ Tusker/Tusker.entitlements | 6 +- Tusker/Tusker.storekit | 65 +++++ 6 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 Tusker/Screens/Preferences/Tip Jar/ConfettiView.swift create mode 100644 Tusker/Screens/Preferences/Tip Jar/TipJarView.swift create mode 100644 Tusker/Tusker.storekit diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 8a2676b5..4151d5ec 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; + D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D60088EE2980D8B5005B4D00 /* StoreKit.framework */; }; + D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60088F12980DAA0005B4D00 /* TipJarView.swift */; }; + D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60089182981FEBA005B4D00 /* ConfettiView.swift */; }; D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; }; D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; }; D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */; }; @@ -415,6 +418,10 @@ 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = ""; }; 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = ""; }; + D60088EE2980D8B5005B4D00 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; + D60088F02980D938005B4D00 /* Tusker.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tusker.storekit; sourceTree = ""; }; + D60088F12980DAA0005B4D00 /* TipJarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarView.swift; sourceTree = ""; }; + D60089182981FEBA005B4D00 /* ConfettiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiView.swift; sourceTree = ""; }; D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = ""; }; D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCollectionViewController.swift; sourceTree = ""; }; D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = ""; }; @@ -772,6 +779,7 @@ D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, D659F35E2953A212002D944A /* TTTKit in Frameworks */, D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */, + D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, @@ -815,6 +823,15 @@ path = "Attachment Gallery"; sourceTree = ""; }; + D60089172981FEA4005B4D00 /* Tip Jar */ = { + isa = PBXGroup; + children = ( + D60088F12980DAA0005B4D00 /* TipJarView.swift */, + D60089182981FEBA005B4D00 /* ConfettiView.swift */, + ); + path = "Tip Jar"; + sourceTree = ""; + }; D611C2CC232DC5FC00C86A49 /* Hashtag Cell */ = { isa = PBXGroup; children = ( @@ -1120,6 +1137,7 @@ 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */, D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */, D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */, + D60089172981FEA4005B4D00 /* Tip Jar */, D68A76EF2953910A001DA1B3 /* About */, ); path = Preferences; @@ -1193,6 +1211,7 @@ D65A37F221472F300087646E /* Frameworks */ = { isa = PBXGroup; children = ( + D60088EE2980D8B5005B4D00 /* StoreKit.framework */, D65F613723AFD65D00F3CFD3 /* Embassy.framework */, D65F613523AFD65900F3CFD3 /* Ambassador.framework */, D65F613023AE99E000F3CFD3 /* Ambassador.framework */, @@ -1494,6 +1513,7 @@ D6E4885C24A2890C0011C13E /* Tusker.entitlements */, D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */, D6D4DDDB212518A200E1C4BB /* Info.plist */, + D60088F02980D938005B4D00 /* Tusker.storekit */, D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, D61F75B6293C119700C0B37F /* Filterer.swift */, @@ -2118,6 +2138,7 @@ D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */, + D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */, D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */, D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */, D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, @@ -2187,6 +2208,7 @@ D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */, D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */, D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */, + D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */, D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */, D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */, D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */, diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index c4b92c53..fc40a070 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -102,6 +102,9 @@ struct PreferencesView: View { NavigationLink("About") { AboutView() } + NavigationLink("Tip Jar") { + TipJarView() + } NavigationLink("Acknowledgements") { AcknowledgementsView() } diff --git a/Tusker/Screens/Preferences/Tip Jar/ConfettiView.swift b/Tusker/Screens/Preferences/Tip Jar/ConfettiView.swift new file mode 100644 index 00000000..7997a2a9 --- /dev/null +++ b/Tusker/Screens/Preferences/Tip Jar/ConfettiView.swift @@ -0,0 +1,119 @@ +// +// ConfettiView.swift +// Tusker +// +// Created by Shadowfacts on 1/25/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct ConfettiView: View { + private static let colors = [ + Color.red, + .purple, + .blue, + .pink, + .yellow, + .green, + .teal, + .cyan, + .mint, + .indigo, + .orange, + ] + + @State private var size: CGSize? + @State private var startDate: Date? + + var body: some View { + allConfetti + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(GeometryReader { proxy in + Color.clear + .preference(key: SizeKey.self, value: proxy.size) + .onPreferenceChange(SizeKey.self) { newValue in + self.size = newValue + self.startDate = Date() + } + }) + } + + @ViewBuilder + private var allConfetti: some View { + if let size { + TimelineView(.animation) { context in + ZStack { + ForEach(0..<200) { idx in + let time = context.date.timeIntervalSince(startDate!) + ConfettiPiece(shape: Rectangle(), color: Self.colors[idx % Self.colors.count], canvasSize: size, time: time) + ConfettiPiece(shape: Circle(), color: Self.colors[idx % Self.colors.count], canvasSize: size, time: time) + ConfettiPiece(shape: Triangle(), color: Self.colors[idx % Self.colors.count], canvasSize: size, time: time) + } + } + } + } else { + Color.clear + } + } +} + +private struct SizeKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} + +private struct ConfettiPiece: View { + let shape: S + let color: Color + let canvasSize: CGSize + let time: TimeInterval + @State private var start: CGPoint = .zero + @State private var velocity: CGPoint = { + let angle = CGFloat.random(in: 0..<2) * .pi + let velocity = CGFloat.random(in: 25...250) + return CGPoint(x: cos(angle) * velocity, y: sin(angle) * velocity) + }() + private let gravity: CGFloat = 100 + @State private var rotationAxis: (CGFloat, CGFloat, CGFloat) = (.random() ? 1 : 0, .random() ? 1 : 0, .random() ? 1 : 0) + @State private var rotationsPerSecond = Double.random(in: 0.5..<2) + + private var x: CGFloat { + (canvasSize.width / 2) + velocity.x * time + } + + private var y: CGFloat { + (canvasSize.height / 2) + velocity.y * time + 0.5 * time * time * gravity + } + + private var angle: Angle { + .degrees(time * rotationsPerSecond * 360) + } + + var body: some View { + shape + .frame(width: 7, height: 7) + .foregroundColor(color) + .rotation3DEffect(angle, axis: rotationAxis) + .position(x: x, y: y) + } +} + +private struct Triangle: Shape { + func path(in rect: CGRect) -> Path { + var p = Path() + p.move(to: CGPoint(x: rect.midX, y: rect.minY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + p.closeSubpath() + return p + } +} + +struct ConfettiView_Previews: PreviewProvider { + static var previews: some View { + ConfettiView() + } +} diff --git a/Tusker/Screens/Preferences/Tip Jar/TipJarView.swift b/Tusker/Screens/Preferences/Tip Jar/TipJarView.swift new file mode 100644 index 00000000..694b1969 --- /dev/null +++ b/Tusker/Screens/Preferences/Tip Jar/TipJarView.swift @@ -0,0 +1,247 @@ +// +// 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 { + productsView + .overlay { + 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() + } + } +} diff --git a/Tusker/Tusker.entitlements b/Tusker/Tusker.entitlements index 78a92368..be4cea35 100644 --- a/Tusker/Tusker.entitlements +++ b/Tusker/Tusker.entitlements @@ -2,12 +2,12 @@ - com.apple.developer.icloud-container-environment - Production aps-environment development com.apple.developer.aps-environment development + com.apple.developer.icloud-container-environment + Production com.apple.developer.icloud-container-identifiers iCloud.$(BUNDLE_ID_PREFIX).Tusker @@ -16,6 +16,8 @@ CloudKit + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) com.apple.security.app-sandbox com.apple.security.application-groups diff --git a/Tusker/Tusker.storekit b/Tusker/Tusker.storekit new file mode 100644 index 00000000..6e4dd5e9 --- /dev/null +++ b/Tusker/Tusker.storekit @@ -0,0 +1,65 @@ +{ + "identifier" : "78325DF8", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "14.99", + "familyShareable" : false, + "internalID" : "6445511971", + "localizations" : [ + { + "description" : "Profess your undying love for Tusker.", + "displayName" : "Incredible Tip", + "locale" : "en_US" + } + ], + "productID" : "tusker.tip.large", + "referenceName" : "tusker.tip.large", + "type" : "Consumable" + }, + { + "displayPrice" : "6.99", + "familyShareable" : false, + "internalID" : "6445511605", + "localizations" : [ + { + "description" : "Help support Tusker's continued development.", + "displayName" : "Awesome Tip", + "locale" : "en_US" + } + ], + "productID" : "tusker.tip.medium", + "referenceName" : "tusker.tip.medium", + "type" : "Consumable" + }, + { + "displayPrice" : "1.99", + "familyShareable" : false, + "internalID" : "6445511634", + "localizations" : [ + { + "description" : "Show your appreciation for Tusker.", + "displayName" : "Nice Tip", + "locale" : "en_US" + } + ], + "productID" : "tusker.tip.small", + "referenceName" : "tusker.tip.small", + "type" : "Consumable" + } + ], + "settings" : { + "_applicationInternalID" : "1498334597", + "_developerTeamID" : "V4WK9KR9U2", + "_lastSynchronizedDate" : 696310076.23998904 + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 2, + "minor" : 0 + } +}