|
@@ -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()
|
|
|
+}
|