mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-12 02:24:01 +00:00
donation: added new view to handle IAP donations for iOS
This commit is contained in:
@@ -104,7 +104,8 @@ private struct VMListModifier: ViewModifier {
|
||||
@EnvironmentObject private var data: UTMData
|
||||
@State private var settingsPresented = false
|
||||
@State private var sheetPresented = false
|
||||
|
||||
@State private var donatePresented = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
#if os(macOS)
|
||||
@@ -126,6 +127,15 @@ private struct VMListModifier: ViewModifier {
|
||||
newButton
|
||||
}
|
||||
#endif
|
||||
#if !WITH_REMOTE
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
donatePresented.toggle()
|
||||
} label: {
|
||||
Label("Donate", systemImage: "heart.fill")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(visionOS) && !WITH_REMOTE
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Settings") {
|
||||
@@ -147,23 +157,37 @@ private struct VMListModifier: ViewModifier {
|
||||
#if !WITH_REMOTE
|
||||
UTMSettingsView()
|
||||
#endif
|
||||
} else if donatePresented {
|
||||
#if !os(macOS) && !WITH_REMOTE
|
||||
UTMDonateView()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onChange(of: data.showNewVMSheet) { newValue in
|
||||
if newValue {
|
||||
settingsPresented = false
|
||||
donatePresented = false
|
||||
sheetPresented = true
|
||||
}
|
||||
}
|
||||
.onChange(of: settingsPresented) { newValue in
|
||||
if newValue {
|
||||
data.showNewVMSheet = false
|
||||
donatePresented = false
|
||||
sheetPresented = true
|
||||
}
|
||||
}
|
||||
.onChange(of: donatePresented) { newValue in
|
||||
if newValue {
|
||||
data.showNewVMSheet = false
|
||||
settingsPresented = false
|
||||
sheetPresented = true
|
||||
}
|
||||
}
|
||||
.onChange(of: sheetPresented) { newValue in
|
||||
if !newValue {
|
||||
settingsPresented = false
|
||||
donatePresented = false
|
||||
data.showNewVMSheet = false
|
||||
}
|
||||
}
|
||||
@@ -174,6 +198,11 @@ private struct VMListModifier: ViewModifier {
|
||||
.sheet(isPresented: $data.showNewVMSheet) {
|
||||
VMWizardView()
|
||||
}
|
||||
#if !os(macOS) && !WITH_REMOTE
|
||||
.sheet(isPresented: $donatePresented) {
|
||||
UTMDonateView()
|
||||
}
|
||||
#endif
|
||||
.onReceive(NSNotification.OpenVirtualMachine) { _ in
|
||||
data.showNewVMSheet = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"identifier" : "A2B91788",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "0.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "4FFB8333",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "The most basic building block.",
|
||||
"displayName" : "Transistor",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "consumable.small",
|
||||
"referenceName" : "Transistor",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "4.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "B6BBB675",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Each one has a unique functionality.",
|
||||
"displayName" : "Chip",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "consumable.medium",
|
||||
"referenceName" : "Chip",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "9.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "FAEB2D3C",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Gets the work done.",
|
||||
"displayName" : "Computer",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "consumable.large",
|
||||
"referenceName" : "Computer",
|
||||
"type" : "Consumable"
|
||||
}
|
||||
],
|
||||
"settings" : {
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_locale" : "en_US",
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Load Products"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Purchase"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Verification"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Store Sync"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Subscription Status"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Transaction"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Manage Subscriptions Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Refund Request Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Offer Code Redeem Sheet"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "D96518D8",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "Donation",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "0.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 3,
|
||||
"internalID" : "C7B7ED7B",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Ones and zeros would be lonely without them.",
|
||||
"displayName" : "Logic",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "subscription.small",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Logic",
|
||||
"subscriptionGroupID" : "D96518D8",
|
||||
"type" : "RecurringSubscription"
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "4.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "A193204D",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Converts inputs to outputs in a fancy way.",
|
||||
"displayName" : "Function",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "subscription.medium",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Function",
|
||||
"subscriptionGroupID" : "D96518D8",
|
||||
"type" : "RecurringSubscription"
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "9.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "08CDD5EB",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Maybe it was all a mistake.",
|
||||
"displayName" : "Software",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "subscription.large",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Software",
|
||||
"subscriptionGroupID" : "D96518D8",
|
||||
"type" : "RecurringSubscription"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 3,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// Copyright © 2024 osy. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
@available(iOS 15, *)
|
||||
class UTMDonateStore: ObservableObject {
|
||||
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState
|
||||
|
||||
enum StoreError: Error {
|
||||
case failedVerification
|
||||
}
|
||||
|
||||
let productImages: [String: String] = [
|
||||
"consumable.small": "switch.2",
|
||||
"consumable.medium": "memorychip",
|
||||
"consumable.large": "pc",
|
||||
"subscription.small": "sum",
|
||||
"subscription.medium": "function",
|
||||
"subscription.large": "opticaldisc",
|
||||
]
|
||||
|
||||
@Published private(set) var consumables: [Product]
|
||||
@Published private(set) var subscriptions: [Product]
|
||||
|
||||
@Published private(set) var purchasedSubscriptions: [Product] = []
|
||||
@Published private(set) var subscriptionGroupStatus: RenewalState?
|
||||
|
||||
@Published private(set) var isLoaded: Bool = false
|
||||
@Published private(set) var id: UUID = UUID()
|
||||
|
||||
var updateListenerTask: Task<Void, Error>? = nil
|
||||
|
||||
init() {
|
||||
//Initialize empty products, and then do a product request asynchronously to fill them in.
|
||||
consumables = []
|
||||
subscriptions = []
|
||||
|
||||
//Start a transaction listener as close to app launch as possible so you don't miss any transactions.
|
||||
updateListenerTask = listenForTransactions()
|
||||
|
||||
Task {
|
||||
//During store initialization, request products from the App Store.
|
||||
await requestProducts()
|
||||
|
||||
//Deliver products that the customer purchases.
|
||||
await updateCustomerProductStatus()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
updateListenerTask?.cancel()
|
||||
}
|
||||
|
||||
func listenForTransactions() -> Task<Void, Error> {
|
||||
return Task.detached {
|
||||
//Iterate through any transactions that don't come from a direct call to `purchase()`.
|
||||
for await result in Transaction.updates {
|
||||
do {
|
||||
let transaction = try self.checkVerified(result)
|
||||
|
||||
//Deliver products to the user.
|
||||
await self.updateCustomerProductStatus()
|
||||
|
||||
//Always finish a transaction.
|
||||
await transaction.finish()
|
||||
} catch {
|
||||
//StoreKit has a transaction that fails verification. Don't deliver content to the user.
|
||||
logger.error("Transaction failed verification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func requestProducts() async {
|
||||
isLoaded = false
|
||||
do {
|
||||
let storeProducts = try await Product.products(for: productImages.keys)
|
||||
|
||||
var newConsumables: [Product] = []
|
||||
var newSubscriptions: [Product] = []
|
||||
|
||||
//Filter the products into categories based on their type.
|
||||
for product in storeProducts {
|
||||
switch product.type {
|
||||
case .consumable:
|
||||
newConsumables.append(product)
|
||||
case .autoRenewable:
|
||||
newSubscriptions.append(product)
|
||||
default:
|
||||
//Ignore this product.
|
||||
logger.error("Unknown product: \(product)")
|
||||
}
|
||||
}
|
||||
|
||||
//Sort each product category by price, lowest to highest, to update the store.
|
||||
consumables = sortByPrice(newConsumables)
|
||||
subscriptions = sortByPrice(newSubscriptions)
|
||||
} catch {
|
||||
logger.error("Failed product request from the App Store server: \(error)")
|
||||
}
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
func purchase(with action: () async throws -> Product.PurchaseResult) async throws -> Transaction? {
|
||||
//Begin purchasing the `Product` the user selects.
|
||||
let result = try await action()
|
||||
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
//Check whether the transaction is verified. If it isn't,
|
||||
//this function rethrows the verification error.
|
||||
let transaction = try checkVerified(verification)
|
||||
|
||||
//The transaction is verified. Deliver content to the user.
|
||||
await updateCustomerProductStatus()
|
||||
|
||||
//Always finish a transaction.
|
||||
await transaction.finish()
|
||||
|
||||
return transaction
|
||||
case .userCancelled, .pending:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isPurchased(_ product: Product) async throws -> Bool {
|
||||
//Determine whether the user purchases a given product.
|
||||
switch product.type {
|
||||
case .autoRenewable:
|
||||
return purchasedSubscriptions.contains(product)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func checkVerified<T>(_ result: VerificationResult<T>) 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
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateCustomerProductStatus() async {
|
||||
var purchasedSubscriptions: [Product] = []
|
||||
|
||||
//Iterate through all of the user's purchased products.
|
||||
for await result in Transaction.currentEntitlements {
|
||||
do {
|
||||
//Check whether the transaction is verified. If it isn’t, catch `failedVerification` error.
|
||||
let transaction = try checkVerified(result)
|
||||
|
||||
//Check the `productType` of the transaction and get the corresponding product from the store.
|
||||
switch transaction.productType {
|
||||
case .autoRenewable:
|
||||
if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) {
|
||||
purchasedSubscriptions.append(subscription)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
logger.error("failed to update product status: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
//Update the store information with auto-renewable subscription products.
|
||||
self.purchasedSubscriptions = purchasedSubscriptions
|
||||
|
||||
//Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer
|
||||
//is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription
|
||||
//group, so products in the subscriptions array all belong to the same group. The statuses that
|
||||
//`product.subscription.status` returns apply to the entire subscription group.
|
||||
subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state
|
||||
}
|
||||
|
||||
func sortByPrice(_ products: [Product]) -> [Product] {
|
||||
products.sorted(by: { return $0.price < $1.price })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//
|
||||
// Copyright © 2024 osy. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
struct UTMDonateView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
private var appIcon: String? {
|
||||
guard let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
|
||||
let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||
let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String],
|
||||
let iconFileName = iconFiles.last else {
|
||||
return nil
|
||||
}
|
||||
return iconFileName
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
if let appIcon = appIcon, let image = UIImage(named: appIcon) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12.632, style: .continuous))
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
Text("Your support is the driving force that helps UTM stay independent. Your contribution, no matter the size, makes a significant difference. It enables us to develop new features and maintain existing ones. Thank you for considering a donation to support us.")
|
||||
.padding()
|
||||
if #available(iOS 15, *) {
|
||||
StoreView()
|
||||
} else {
|
||||
List {
|
||||
Link("GitHub Sponsors", destination: URL(string: "https://github.com/sponsors/utmapp")!)
|
||||
}
|
||||
}
|
||||
}.navigationTitle("Support UTM")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Close") {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15, *)
|
||||
private struct StoreView: View {
|
||||
@StateObject private var store = UTMDonateStore()
|
||||
|
||||
var body: some View {
|
||||
if !store.isLoaded {
|
||||
ProgressView()
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
if !store.consumables.isEmpty {
|
||||
Section("One Time Donation") {
|
||||
ForEach(store.consumables) { item in
|
||||
ListCellView(store: store, product: item)
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
}
|
||||
if !store.subscriptions.isEmpty {
|
||||
Section("Recurring Donation") {
|
||||
ForEach(store.subscriptions) { item in
|
||||
ListCellView(store: store, product: item)
|
||||
}
|
||||
Link("Manage Subscriptions…", destination: URL(string: "itms-apps://apps.apple.com/account/subscriptions")!)
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
}
|
||||
if store.consumables.isEmpty && store.subscriptions.isEmpty {
|
||||
Link("GitHub Sponsors", destination: URL(string: "https://github.com/sponsors/utmapp")!)
|
||||
} else {
|
||||
Button("Restore Purchases") {
|
||||
Task {
|
||||
try? await AppStore.sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15, *)
|
||||
private struct ListCellView: View {
|
||||
@ObservedObject var store: UTMDonateStore
|
||||
@State var isPurchased: Bool = false
|
||||
@State var errorTitle = ""
|
||||
@State var isShowingError: Bool = false
|
||||
@State var isLoaded: Bool = false
|
||||
|
||||
#if os(visionOS)
|
||||
@Environment(\.purchase) var purchase
|
||||
#endif
|
||||
|
||||
let product: Product
|
||||
let purchasingEnabled: Bool
|
||||
|
||||
var systemImage: String? {
|
||||
store.productImages[product.id]
|
||||
}
|
||||
|
||||
init(store: UTMDonateStore, product: Product, purchasingEnabled: Bool = true) {
|
||||
self.store = store
|
||||
self.product = product
|
||||
self.purchasingEnabled = purchasingEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: systemImage ?? "heart.fill")
|
||||
.font(.system(size: 36))
|
||||
.frame(width: 48, height: 48)
|
||||
.padding(.trailing, 20)
|
||||
if purchasingEnabled {
|
||||
productDetail
|
||||
Spacer()
|
||||
buyButton
|
||||
.buttonStyle(BuyButtonStyle(isPurchased: isPurchased))
|
||||
.disabled(isPurchased)
|
||||
} else {
|
||||
productDetail
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $isShowingError, content: {
|
||||
Alert(title: Text(errorTitle), message: nil, dismissButton: .default(Text("OK")))
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var productDetail: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(product.displayName)
|
||||
.bold()
|
||||
Text(product.description)
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeButton(_ subscription: Product.SubscriptionInfo) -> some View {
|
||||
let unit: String
|
||||
let plural = 1 < subscription.subscriptionPeriod.value
|
||||
switch subscription.subscriptionPeriod.unit {
|
||||
case .day:
|
||||
unit = plural ? String.localizedStringWithFormat(NSLocalizedString("%d days", comment: "UTMDonateView"), subscription.subscriptionPeriod.value) : NSLocalizedString("day", comment: "UTMDonateView")
|
||||
case .week:
|
||||
unit = plural ? String.localizedStringWithFormat(NSLocalizedString("%d weeks", comment: "UTMDonateView"), subscription.subscriptionPeriod.value) : NSLocalizedString("week", comment: "UTMDonateView")
|
||||
case .month:
|
||||
unit = plural ? String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "UTMDonateView"), subscription.subscriptionPeriod.value) : NSLocalizedString("month", comment: "UTMDonateView")
|
||||
case .year:
|
||||
unit = plural ? String.localizedStringWithFormat(NSLocalizedString("%d years", comment: "UTMDonateView"), subscription.subscriptionPeriod.value) : NSLocalizedString("year", comment: "UTMDonateView")
|
||||
@unknown default:
|
||||
unit = NSLocalizedString("period", comment: "UTMDonateView")
|
||||
}
|
||||
|
||||
return VStack {
|
||||
Text(product.displayPrice)
|
||||
.foregroundColor(.white)
|
||||
.bold()
|
||||
.padding(EdgeInsets(top: -4.0, leading: 0.0, bottom: -8.0, trailing: 0.0))
|
||||
Divider()
|
||||
.background(Color.white)
|
||||
Text(unit)
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 12))
|
||||
.padding(EdgeInsets(top: -8.0, leading: 0.0, bottom: -4.0, trailing: 0.0))
|
||||
}
|
||||
}
|
||||
|
||||
var buyButton: some View {
|
||||
Button(action: {
|
||||
Task {
|
||||
await buy()
|
||||
}
|
||||
}) {
|
||||
if !isLoaded {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else if isPurchased {
|
||||
Text(Image(systemName: "checkmark"))
|
||||
.bold()
|
||||
.foregroundColor(.white)
|
||||
} else {
|
||||
if let subscription = product.subscription {
|
||||
subscribeButton(subscription)
|
||||
} else {
|
||||
Text(product.displayPrice)
|
||||
.foregroundColor(.white)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
isPurchased = (try? await store.isPurchased(product)) ?? false
|
||||
isLoaded = true
|
||||
}
|
||||
}
|
||||
.onChange(of: store.purchasedSubscriptions) { _ in
|
||||
Task {
|
||||
isPurchased = (try? await store.isPurchased(product)) ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buy() async {
|
||||
do {
|
||||
if try await store.purchase(with: {
|
||||
#if os(visionOS)
|
||||
try await purchase(product)
|
||||
#else
|
||||
try await product.purchase()
|
||||
#endif
|
||||
}) != nil {
|
||||
withAnimation {
|
||||
isPurchased = true
|
||||
}
|
||||
}
|
||||
} catch UTMDonateStore.StoreError.failedVerification {
|
||||
errorTitle = NSLocalizedString("Your purchase could not be verified by the App Store.", comment: "UTMDonateView")
|
||||
isShowingError = true
|
||||
} catch {
|
||||
logger.error("Failed purchase for \(product.id): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BuyButtonStyle: ButtonStyle {
|
||||
let isPurchased: Bool
|
||||
|
||||
init(isPurchased: Bool = false) {
|
||||
self.isPurchased = isPurchased
|
||||
}
|
||||
|
||||
func makeBody(configuration: Self.Configuration) -> some View {
|
||||
var bgColor: Color = isPurchased ? Color.green : Color.blue
|
||||
bgColor = configuration.isPressed ? bgColor.opacity(0.7) : bgColor.opacity(1)
|
||||
|
||||
return configuration.label
|
||||
.frame(width: 50)
|
||||
.padding(10)
|
||||
.background(bgColor)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
UTMDonateView()
|
||||
}
|
||||
@@ -426,6 +426,10 @@
|
||||
CE19392826DCB094005CEC17 /* RAMSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE19392526DCB093005CEC17 /* RAMSlider.swift */; };
|
||||
CE1AEC3F2B78B30700992AFC /* MacDeviceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1AEC3E2B78B30700992AFC /* MacDeviceLabel.swift */; };
|
||||
CE1AEC402B78B30700992AFC /* MacDeviceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1AEC3E2B78B30700992AFC /* MacDeviceLabel.swift */; };
|
||||
CE231D422BDDF280006D6DC3 /* UTMDonateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE231D412BDDF280006D6DC3 /* UTMDonateView.swift */; };
|
||||
CE231D432BDDF280006D6DC3 /* UTMDonateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE231D412BDDF280006D6DC3 /* UTMDonateView.swift */; };
|
||||
CE231D462BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE231D452BDDFD03006D6DC3 /* UTMDonateStore.swift */; };
|
||||
CE231D472BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE231D452BDDFD03006D6DC3 /* UTMDonateStore.swift */; };
|
||||
CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */; };
|
||||
CE25124929BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */; };
|
||||
CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124A29BFE273000790AB /* UTMScriptable.swift */; };
|
||||
@@ -1786,6 +1790,9 @@
|
||||
CE1AEC3E2B78B30700992AFC /* MacDeviceLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacDeviceLabel.swift; sourceTree = "<group>"; };
|
||||
CE20FAE62448D2BE0059AE11 /* VMScroll.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VMScroll.h; sourceTree = "<group>"; };
|
||||
CE20FAE72448D2BE0059AE11 /* VMScroll.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VMScroll.m; sourceTree = "<group>"; };
|
||||
CE231D412BDDF280006D6DC3 /* UTMDonateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDonateView.swift; sourceTree = "<group>"; };
|
||||
CE231D442BDDFA61006D6DC3 /* Donation.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donation.storekit; sourceTree = "<group>"; };
|
||||
CE231D452BDDFD03006D6DC3 /* UTMDonateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDonateStore.swift; sourceTree = "<group>"; };
|
||||
CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingGuestProcessImpl.swift; sourceTree = "<group>"; };
|
||||
CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingGuestFileImpl.swift; sourceTree = "<group>"; };
|
||||
CE25124A29BFE273000790AB /* UTMScriptable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptable.swift; sourceTree = "<group>"; };
|
||||
@@ -2674,6 +2681,8 @@
|
||||
CE08334A2B784FD400522C03 /* RemoteContentView.swift */,
|
||||
841E58D02893AF5400137A20 /* UTMApp.swift */,
|
||||
CEBBF1A424B56A2900C15049 /* UTMDataExtension.swift */,
|
||||
CE231D452BDDFD03006D6DC3 /* UTMDonateStore.swift */,
|
||||
CE231D412BDDF280006D6DC3 /* UTMDonateView.swift */,
|
||||
841E58CA28937EE200137A20 /* UTMExternalSceneDelegate.swift */,
|
||||
841E58CD28937FED00137A20 /* UTMSingleWindowView.swift */,
|
||||
842B9F8C28CC58B700031EE7 /* UTMPatches.swift */,
|
||||
@@ -2698,6 +2707,7 @@
|
||||
CEB5C1192B8C4CD4008AAE5C /* Info-RemotePlist.strings */,
|
||||
CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */,
|
||||
5286EC91243748AC007E6CBC /* Settings.bundle */,
|
||||
CE231D442BDDFA61006D6DC3 /* Donation.storekit */,
|
||||
);
|
||||
path = iOS;
|
||||
sourceTree = "<group>";
|
||||
@@ -3493,6 +3503,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CE2D926A24AD46670059923A /* VMDisplayMetalViewController+Pointer.h in Sources */,
|
||||
CE231D462BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */,
|
||||
84C4D9022880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift in Sources */,
|
||||
CE2D956F24AD4F990059923A /* VMRemovableDrivesView.swift in Sources */,
|
||||
8443EFF22845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */,
|
||||
@@ -3619,6 +3630,7 @@
|
||||
848F71EC277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
|
||||
CE2D955D24AD4F990059923A /* VMConfigSoundView.swift in Sources */,
|
||||
CE2D930424AD46670059923A /* UTMLegacyQemuConfiguration.m in Sources */,
|
||||
CE231D422BDDF280006D6DC3 /* UTMDonateView.swift in Sources */,
|
||||
841E999828AC817D003C6CB6 /* UTMQemuVirtualMachine.swift in Sources */,
|
||||
CE2D957924AD4F990059923A /* VMDetailsView.swift in Sources */,
|
||||
CE2D930524AD46670059923A /* VMDisplayMetalViewController.m in Sources */,
|
||||
@@ -3856,6 +3868,7 @@
|
||||
84C505AD28C588EC007CE8FF /* SizeTextField.swift in Sources */,
|
||||
CEA45E27263519B5002FA97D /* VMRemovableDrivesView.swift in Sources */,
|
||||
CEF0306E26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
|
||||
CE231D432BDDF280006D6DC3 /* UTMDonateView.swift in Sources */,
|
||||
CE65BABF26A4D8DD0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */,
|
||||
848F71ED277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
|
||||
83A004BA26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */,
|
||||
@@ -3900,6 +3913,7 @@
|
||||
CEA45E6A263519B5002FA97D /* VMDisplayMetalViewController+Touch.m in Sources */,
|
||||
CEA45E6B263519B5002FA97D /* UTMLegacyQemuConfiguration+Display.m in Sources */,
|
||||
CEA45E6C263519B5002FA97D /* UTMVirtualMachine.swift in Sources */,
|
||||
CE231D472BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */,
|
||||
84B36D2A27B790BE00C22685 /* DestructiveButton.swift in Sources */,
|
||||
CEF469EF2BD2D165005A0B68 /* VMWizardStartViewTCI.swift in Sources */,
|
||||
842B9F8E28CC58B700031EE7 /* UTMPatches.swift in Sources */,
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
ReferencedContainer = "container:UTM.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../Platform/iOS/Donation.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
Reference in New Issue
Block a user