123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- //
- // 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 if !store.subscriptions.isEmpty {
- 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()
- }
|