forked from shadowfacts/Tusker
Add tip jar
This commit is contained in:
parent
e45459e556
commit
5a9513bb30
|
@ -17,6 +17,9 @@
|
||||||
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
|
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
|
||||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
|
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
|
||||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.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 */; };
|
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; };
|
||||||
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; };
|
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; };
|
||||||
D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
@ -772,6 +779,7 @@
|
||||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
||||||
D659F35E2953A212002D944A /* TTTKit in Frameworks */,
|
D659F35E2953A212002D944A /* TTTKit in Frameworks */,
|
||||||
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
|
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
|
||||||
|
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */,
|
||||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
||||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
||||||
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
|
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
|
||||||
|
@ -815,6 +823,15 @@
|
||||||
path = "Attachment Gallery";
|
path = "Attachment Gallery";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D60089172981FEA4005B4D00 /* Tip Jar */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D60088F12980DAA0005B4D00 /* TipJarView.swift */,
|
||||||
|
D60089182981FEBA005B4D00 /* ConfettiView.swift */,
|
||||||
|
);
|
||||||
|
path = "Tip Jar";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */ = {
|
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1120,6 +1137,7 @@
|
||||||
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
||||||
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
||||||
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
||||||
|
D60089172981FEA4005B4D00 /* Tip Jar */,
|
||||||
D68A76EF2953910A001DA1B3 /* About */,
|
D68A76EF2953910A001DA1B3 /* About */,
|
||||||
);
|
);
|
||||||
path = Preferences;
|
path = Preferences;
|
||||||
|
@ -1193,6 +1211,7 @@
|
||||||
D65A37F221472F300087646E /* Frameworks */ = {
|
D65A37F221472F300087646E /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D60088EE2980D8B5005B4D00 /* StoreKit.framework */,
|
||||||
D65F613723AFD65D00F3CFD3 /* Embassy.framework */,
|
D65F613723AFD65D00F3CFD3 /* Embassy.framework */,
|
||||||
D65F613523AFD65900F3CFD3 /* Ambassador.framework */,
|
D65F613523AFD65900F3CFD3 /* Ambassador.framework */,
|
||||||
D65F613023AE99E000F3CFD3 /* Ambassador.framework */,
|
D65F613023AE99E000F3CFD3 /* Ambassador.framework */,
|
||||||
|
@ -1494,6 +1513,7 @@
|
||||||
D6E4885C24A2890C0011C13E /* Tusker.entitlements */,
|
D6E4885C24A2890C0011C13E /* Tusker.entitlements */,
|
||||||
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
|
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
|
||||||
D6D4DDDB212518A200E1C4BB /* Info.plist */,
|
D6D4DDDB212518A200E1C4BB /* Info.plist */,
|
||||||
|
D60088F02980D938005B4D00 /* Tusker.storekit */,
|
||||||
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
||||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
|
||||||
D61F75B6293C119700C0B37F /* Filterer.swift */,
|
D61F75B6293C119700C0B37F /* Filterer.swift */,
|
||||||
|
@ -2118,6 +2138,7 @@
|
||||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
||||||
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
||||||
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
||||||
|
D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */,
|
||||||
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */,
|
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */,
|
||||||
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
|
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
|
||||||
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
|
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
|
||||||
|
@ -2187,6 +2208,7 @@
|
||||||
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
|
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
|
||||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
||||||
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
||||||
|
D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */,
|
||||||
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
|
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
|
||||||
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
||||||
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */,
|
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */,
|
||||||
|
|
|
@ -102,6 +102,9 @@ struct PreferencesView: View {
|
||||||
NavigationLink("About") {
|
NavigationLink("About") {
|
||||||
AboutView()
|
AboutView()
|
||||||
}
|
}
|
||||||
|
NavigationLink("Tip Jar") {
|
||||||
|
TipJarView()
|
||||||
|
}
|
||||||
NavigationLink("Acknowledgements") {
|
NavigationLink("Acknowledgements") {
|
||||||
AcknowledgementsView()
|
AcknowledgementsView()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,12 +2,12 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.icloud-container-environment</key>
|
|
||||||
<string>Production</string>
|
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
<key>com.apple.developer.aps-environment</key>
|
<key>com.apple.developer.aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-environment</key>
|
||||||
|
<string>Production</string>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>iCloud.$(BUNDLE_ID_PREFIX).Tusker</string>
|
<string>iCloud.$(BUNDLE_ID_PREFIX).Tusker</string>
|
||||||
|
@ -16,6 +16,8 @@
|
||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue