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 */; };
|
||||
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 */,
|
||||
|
|
|
@ -102,6 +102,9 @@ struct PreferencesView: View {
|
|||
NavigationLink("About") {
|
||||
AboutView()
|
||||
}
|
||||
NavigationLink("Tip Jar") {
|
||||
TipJarView()
|
||||
}
|
||||
NavigationLink("Acknowledgements") {
|
||||
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">
|
||||
<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>
|
||||
|
|
|
@ -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