Add tip jar

This commit is contained in:
Shadowfacts 2023-01-25 22:23:03 -05:00
parent e45459e556
commit 5a9513bb30
6 changed files with 460 additions and 2 deletions

View File

@ -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 = "<group>"; };
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D60088F12980DAA0005B4D00 /* TipJarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarView.swift; sourceTree = "<group>"; };
D60089182981FEBA005B4D00 /* ConfettiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiView.swift; sourceTree = "<group>"; };
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = "<group>"; };
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCollectionViewController.swift; sourceTree = "<group>"; };
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
D60089172981FEA4005B4D00 /* Tip Jar */ = {
isa = PBXGroup;
children = (
D60088F12980DAA0005B4D00 /* TipJarView.swift */,
D60089182981FEBA005B4D00 /* ConfettiView.swift */,
);
path = "Tip Jar";
sourceTree = "<group>";
};
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 */,

View File

@ -102,6 +102,9 @@ struct PreferencesView: View {
NavigationLink("About") {
AboutView()
}
NavigationLink("Tip Jar") {
TipJarView()
}
NavigationLink("Acknowledgements") {
AcknowledgementsView()
}

View File

@ -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<S: Shape>: 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()
}
}

View File

@ -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<Void, Never>?
@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<StoreKit.Transaction>.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()
}
}
}

View File

@ -2,12 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.$(BUNDLE_ID_PREFIX).Tusker</string>
@ -16,6 +16,8 @@
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>

65
Tusker/Tusker.storekit Normal file
View File

@ -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
}
}