mirror of
https://github.com/kiwix/kiwix-apple.git
synced 2025-09-24 04:03:03 -04:00
Merge pull request #1022 from kiwix/753-implement-apple-pay-and-use-the-donate-with-apple-pay-button-for-support-kiwix
753 donate with apple pay button for iOS to support Kiwix
This commit is contained in:
commit
cdeae9985f
@ -18,6 +18,7 @@ import UserNotifications
|
||||
import Combine
|
||||
import Defaults
|
||||
import CoreKiwix
|
||||
import PassKit
|
||||
|
||||
#if os(macOS)
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
@ -29,8 +30,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
@main
|
||||
struct Kiwix: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@Environment(\.openWindow) var openWindow
|
||||
@StateObject private var libraryRefreshViewModel = LibraryViewModel()
|
||||
private let notificationCenterDelegate = NotificationCenterDelegate()
|
||||
private var amountSelected = PassthroughSubject<SelectedAmount?, Never>()
|
||||
@State private var selectedAmount: SelectedAmount?
|
||||
@StateObject var formReset = FormReset()
|
||||
|
||||
init() {
|
||||
UNUserNotificationCenter.current().delegate = notificationCenterDelegate
|
||||
@ -79,6 +84,66 @@ struct Kiwix: App {
|
||||
}
|
||||
.frame(width: 550, height: 400)
|
||||
}
|
||||
Window("payment.donate.title".localized, id: "donation") {
|
||||
Group {
|
||||
if let selectedAmount {
|
||||
PaymentSummary(selectedAmount: selectedAmount, onComplete: {
|
||||
closeDonation()
|
||||
switch Payment.showResult() {
|
||||
case .none: break
|
||||
case .thankYou:
|
||||
openWindow(id: "donation-thank-you")
|
||||
case .error:
|
||||
openWindow(id: "donation-error")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
PaymentForm(amountSelected: amountSelected)
|
||||
.frame(width: 320, height: 320)
|
||||
}
|
||||
}
|
||||
.onReceive(amountSelected) { amount in
|
||||
selectedAmount = amount
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { notification in
|
||||
if let window = notification.object as? NSWindow,
|
||||
window.identifier?.rawValue == "donation" {
|
||||
formReset.reset()
|
||||
selectedAmount = nil
|
||||
}
|
||||
}
|
||||
.environmentObject(formReset)
|
||||
}
|
||||
.windowResizability(.contentMinSize)
|
||||
.windowStyle(.titleBar)
|
||||
.commandsRemoved()
|
||||
.defaultSize(width: 320, height: 400)
|
||||
|
||||
Window("", id: "donation-thank-you") {
|
||||
PaymentResultPopUp(state: .thankYou)
|
||||
.padding()
|
||||
}
|
||||
.windowResizability(.contentMinSize)
|
||||
.commandsRemoved()
|
||||
.defaultSize(width: 320, height: 198)
|
||||
|
||||
Window("", id: "donation-error") {
|
||||
PaymentResultPopUp(state: .error)
|
||||
.padding()
|
||||
}
|
||||
.windowResizability(.contentMinSize)
|
||||
.commandsRemoved()
|
||||
.defaultSize(width: 320, height: 198)
|
||||
}
|
||||
|
||||
private func closeDonation() {
|
||||
// after upgrading to macOS 14, use:
|
||||
// @Environment(\.dismissWindow) var dismissWindow
|
||||
// and call:
|
||||
// dismissWindow(id: "donation")
|
||||
NSApplication.shared.windows.first { window in
|
||||
window.identifier?.rawValue == "donation"
|
||||
}?.close()
|
||||
}
|
||||
|
||||
private class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
@ -98,6 +163,7 @@ struct Kiwix: App {
|
||||
}
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(\.openWindow) var openWindow
|
||||
@Environment(\.controlActiveState) var controlActiveState
|
||||
@StateObject private var navigation = NavigationViewModel()
|
||||
@StateObject private var windowTracker = WindowTracker()
|
||||
@ -127,7 +193,12 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 150)
|
||||
.frame(minWidth: 160)
|
||||
// .safeAreaInset(edge: .bottom) {
|
||||
// SupportKiwixButton {
|
||||
// openWindow(id: "donation")
|
||||
// }
|
||||
// }
|
||||
} detail: {
|
||||
switch navigation.currentItem {
|
||||
case .loading:
|
||||
|
230
Model/Payment.swift
Normal file
230
Model/Payment.swift
Normal file
@ -0,0 +1,230 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import PassKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import StripeApplePay
|
||||
import os
|
||||
|
||||
/// Payment processing based on:
|
||||
/// Apple-Pay button:
|
||||
/// https://developer.apple.com/documentation/passkit_apple_pay_and_wallet/apple_pay#2872687
|
||||
/// as described in: What’s new in Wallet and Apple Pay from WWDC 2022
|
||||
/// (https://developer.apple.com/videos/play/wwdc2022/10041/)
|
||||
///
|
||||
/// Combined with Stripe's lightweight Apple Pay framework
|
||||
/// https://github.com/stripe/stripe-ios/blob/master/StripeApplePay/README.md
|
||||
/// based on the App Clip example project:
|
||||
/// https://github.com/stripe/stripe-ios/tree/master/Example/AppClipExample
|
||||
///
|
||||
/// Whereas the Stripe SDK is based on the older
|
||||
/// PKPaymentAuthorizationController (before PayWithApplePayButton was available)
|
||||
/// https://developer.apple.com/documentation/passkit_apple_pay_and_wallet/apple_pay#2870963
|
||||
///
|
||||
/// The Stripe SDK has been brought up to date (with 2022 WWDC changes)
|
||||
/// and modified to be compatible with macOS as well, see SPM dependencies
|
||||
/// https://github.com/CodeLikeW/stripe-apple-pay
|
||||
/// https://github.com/CodeLikeW/stripe-core
|
||||
struct Payment {
|
||||
|
||||
enum FinalResult {
|
||||
case thankYou
|
||||
case error
|
||||
}
|
||||
|
||||
/// Decides if the Thank You / Error pop up should be shown
|
||||
/// - Returns: `FinalResult` only once
|
||||
@MainActor
|
||||
static func showResult() -> FinalResult? {
|
||||
// make sure `true` is "read only once"
|
||||
let value = Self.finalResult
|
||||
Self.finalResult = nil
|
||||
return value
|
||||
}
|
||||
@MainActor
|
||||
static private var finalResult: Payment.FinalResult?
|
||||
|
||||
let completeSubject = PassthroughSubject<Void, Never>()
|
||||
|
||||
static let kiwixPaymentServer = URL(string: "https://api.donation.kiwix.org/v1/stripe")!
|
||||
static let merchantSessionURL = URL(string: "https://apple-pay-gateway.apple.com" )!
|
||||
static let merchantId = "merchant.org.kiwix.apple"
|
||||
static let paymentSubscriptionManagingURL = "https://www.kiwix.org"
|
||||
static let supportedNetworks: [PKPaymentNetwork] = [
|
||||
.amex,
|
||||
.bancomat,
|
||||
.bancontact,
|
||||
.cartesBancaires,
|
||||
.chinaUnionPay,
|
||||
.dankort,
|
||||
.discover,
|
||||
.eftpos,
|
||||
.electron,
|
||||
.elo,
|
||||
.girocard,
|
||||
.interac,
|
||||
.idCredit,
|
||||
.JCB,
|
||||
.mada,
|
||||
.maestro,
|
||||
.masterCard,
|
||||
.mir,
|
||||
.privateLabel,
|
||||
.quicPay,
|
||||
.suica,
|
||||
.visa,
|
||||
.vPay
|
||||
]
|
||||
static let capabilities: PKMerchantCapability = [.threeDSecure, .credit, .debit, .emv]
|
||||
|
||||
/// NOTE: consider that these currencies support double precision, eg: 5.25 USD.
|
||||
/// Revisit `SelectedAmount`, and `SelectedPaymentAmount`
|
||||
/// before adding a zero-decimal currency such as: ¥100
|
||||
static let currencyCodes = ["USD", "EUR", "CHF"]
|
||||
static let defaultCurrencyCode = "USD"
|
||||
private static let minimumAmount: Double = 5
|
||||
/// The Sripe `amount` value supports up to eight digits
|
||||
/// (e.g., a value of 99999999 for a USD charge of $999,999.99).
|
||||
/// see: https://docs.stripe.com/api/payment_intents/object#payment_intent_object-amount
|
||||
static let maximumAmount: Int = 99999999
|
||||
static func isInValidRange(amount: Double?) -> Bool {
|
||||
guard let amount else { return false }
|
||||
return minimumAmount <= amount && amount <= Double(maximumAmount)*100.0
|
||||
}
|
||||
|
||||
static let oneTimes: [AmountOption] = [
|
||||
.init(value: 10),
|
||||
.init(value: 34, isAverage: true),
|
||||
.init(value: 50)
|
||||
]
|
||||
|
||||
static let monthlies: [AmountOption] = [
|
||||
.init(value: 5),
|
||||
.init(value: 8, isAverage: true),
|
||||
.init(value: 10)
|
||||
]
|
||||
|
||||
/// Checks Apple Pay capabilities, and returns the button label accrodingly
|
||||
/// Setup button if no cards added yet,
|
||||
/// nil if Apple Pay is not supported
|
||||
/// or donation button, if all is OK
|
||||
static func paymentButtonType() -> PayWithApplePayButtonLabel? {
|
||||
if PKPaymentAuthorizationController.canMakePayments() {
|
||||
return PayWithApplePayButtonLabel.donate
|
||||
}
|
||||
if PKPaymentAuthorizationController.canMakePayments(
|
||||
usingNetworks: Payment.supportedNetworks,
|
||||
capabilities: Payment.capabilities) {
|
||||
return PayWithApplePayButtonLabel.setUp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func donationRequest(for selectedAmount: SelectedAmount) -> PKPaymentRequest {
|
||||
let request = PKPaymentRequest()
|
||||
request.merchantIdentifier = Self.merchantId
|
||||
request.merchantCapabilities = Self.capabilities
|
||||
request.countryCode = "CH"
|
||||
request.currencyCode = selectedAmount.currency
|
||||
request.supportedNetworks = Self.supportedNetworks
|
||||
request.merchantCapabilities = .threeDSecure
|
||||
request.requiredBillingContactFields = [.emailAddress]
|
||||
let recurring: PKRecurringPaymentRequest? = if selectedAmount.isMonthly {
|
||||
PKRecurringPaymentRequest(paymentDescription: "payment.description.label".localized,
|
||||
regularBilling: .init(label: "payment.monthly_support.label".localized,
|
||||
amount: NSDecimalNumber(value: selectedAmount.value),
|
||||
type: .final),
|
||||
managementURL: URL(string: Self.paymentSubscriptionManagingURL)!)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
request.recurringPaymentRequest = recurring
|
||||
request.paymentSummaryItems = [
|
||||
PKPaymentSummaryItem(
|
||||
label: "payment.summary.title".localized,
|
||||
amount: NSDecimalNumber(value: selectedAmount.value),
|
||||
type: .final
|
||||
)
|
||||
]
|
||||
return request
|
||||
}
|
||||
|
||||
func onPaymentAuthPhase(selectedAmount: SelectedAmount,
|
||||
phase: PayWithApplePayButtonPaymentAuthorizationPhase) {
|
||||
switch phase {
|
||||
case .willAuthorize:
|
||||
os_log("onPaymentAuthPhase: .willAuthorize")
|
||||
case .didAuthorize(let payment, let resultHandler):
|
||||
os_log("onPaymentAuthPhase: .didAuthorize")
|
||||
// call our server to get payment / setup intent and return the client.secret
|
||||
Task { @MainActor [resultHandler] in
|
||||
let paymentServer = StripeKiwix(endPoint: Self.kiwixPaymentServer,
|
||||
payment: payment)
|
||||
do {
|
||||
let publicKey = try await paymentServer.publishableKey()
|
||||
StripeAPI.defaultPublishableKey = publicKey
|
||||
} catch let serverError {
|
||||
Self.finalResult = .error
|
||||
resultHandler(.init(status: .failure, errors: [serverError]))
|
||||
return
|
||||
}
|
||||
// we should update the return path for confirmations
|
||||
// see: https://github.com/kiwix/kiwix-apple/issues/1032
|
||||
let stripe = StripeApplePaySimple()
|
||||
let result = await stripe.complete(payment: payment,
|
||||
returnURLPath: nil,
|
||||
usingClientSecretProvider: {
|
||||
await paymentServer.clientSecretForPayment(selectedAmount: selectedAmount)
|
||||
})
|
||||
// calling any UI refreshing state / subject from here
|
||||
// will block the UI in the payment state forever
|
||||
// therefore it's defered via static finalResult
|
||||
switch result.status {
|
||||
case .success:
|
||||
Self.finalResult = .thankYou
|
||||
case .failure:
|
||||
Self.finalResult = .error
|
||||
default:
|
||||
Self.finalResult = nil
|
||||
}
|
||||
resultHandler(result)
|
||||
os_log("onPaymentAuthPhase: .didAuthorize: \(result.status == .success)")
|
||||
}
|
||||
case .didFinish:
|
||||
os_log("onPaymentAuthPhase: .didFinish")
|
||||
completeSubject.send(())
|
||||
@unknown default:
|
||||
os_log("onPaymentAuthPhase: @unknown default")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@available(macOS 13.0, *)
|
||||
func onMerchantSessionUpdate() async -> PKPaymentRequestMerchantSessionUpdate {
|
||||
guard let session = await StripeKiwix.stripeSession(endPoint: Self.kiwixPaymentServer) else {
|
||||
await MainActor.run {
|
||||
Self.finalResult = .error
|
||||
}
|
||||
return .init(status: .failure, merchantSession: nil)
|
||||
}
|
||||
return .init(status: .success, merchantSession: session)
|
||||
}
|
||||
}
|
||||
|
||||
private enum MerchantSessionError: Error {
|
||||
case invalidStatus
|
||||
}
|
118
Model/StripeKiwix.swift
Normal file
118
Model/StripeKiwix.swift
Normal file
@ -0,0 +1,118 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import PassKit
|
||||
import os
|
||||
|
||||
struct StripeKiwix {
|
||||
|
||||
/// The very maximum amount stripe payment intent can handle
|
||||
/// see: https://docs.stripe.com/api/payment_intents/object#payment_intent_object-amount
|
||||
static let maxAmount: Int = 999999999
|
||||
|
||||
enum StripeError: Error {
|
||||
case serverError
|
||||
}
|
||||
|
||||
let endPoint: URL
|
||||
let payment: PKPayment
|
||||
|
||||
func publishableKey() async throws -> String {
|
||||
let (data, response) = try await URLSession.shared.data(from: endPoint.appending(path: "config"))
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode) else {
|
||||
throw StripeError.serverError
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let json = try decoder.decode(PublishableKey.self, from: data)
|
||||
return json.publishableKey
|
||||
}
|
||||
|
||||
func clientSecretForPayment(selectedAmount: SelectedAmount) async -> Result<String, Error> {
|
||||
do {
|
||||
// for monthly we should create a setup-intent:
|
||||
// see: https://github.com/kiwix/kiwix-apple/issues/1032
|
||||
var request = URLRequest(url: endPoint.appending(path: "payment-intent"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(SelectedPaymentAmount(from: selectedAmount))
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode) else {
|
||||
throw StripeError.serverError
|
||||
}
|
||||
let json = try JSONDecoder().decode(ClientSecretKey.self, from: data)
|
||||
return .success(json.secret)
|
||||
} catch let serverError {
|
||||
return .failure(serverError)
|
||||
}
|
||||
}
|
||||
|
||||
static func stripeSession(endPoint: URL) async -> PKPaymentMerchantSession? {
|
||||
do {
|
||||
var request = URLRequest(url: endPoint.appending(path: "payment-session"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
request.httpBody = try encoder.encode(SessionParams())
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode) else {
|
||||
throw StripeError.serverError
|
||||
}
|
||||
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
||||
os_log("Merchant session not established: unable to decode server response", type: .debug)
|
||||
return nil
|
||||
}
|
||||
return PKPaymentMerchantSession(dictionary: dictionary)
|
||||
} catch let serverError {
|
||||
os_log("Merchant session not established: %@", type: .debug, serverError.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response structure for GET {endPoint}/config
|
||||
/// {"publishable_key":"pk_test_..."}
|
||||
private struct PublishableKey: Decodable {
|
||||
let publishableKey: String
|
||||
}
|
||||
|
||||
/// Response structure for POST {endPoint}/create-payment-intent
|
||||
/// {"secret":"pi_..."}
|
||||
private struct ClientSecretKey: Decodable {
|
||||
let secret: String
|
||||
}
|
||||
|
||||
private struct SelectedPaymentAmount: Encodable {
|
||||
let amount: Int
|
||||
let currency: String
|
||||
|
||||
init(from selectedAmount: SelectedAmount) {
|
||||
// Amount intended to be collected by this PaymentIntent.
|
||||
// A positive integer representing how much to charge in the smallest currency unit
|
||||
// (e.g., 100 cents to charge $1.00 or 100 to charge ¥100, a zero-decimal currency).
|
||||
// The minimum amount is $0.50 US or equivalent in charge currency.
|
||||
amount = Int(selectedAmount.value * 100.0)
|
||||
currency = selectedAmount.currency
|
||||
assert(Payment.currencyCodes.contains(currency))
|
||||
}
|
||||
}
|
||||
private struct SessionParams: Encodable {
|
||||
let validationUrl = "apple-pay-gateway-cert.apple.com"
|
||||
}
|
@ -277,3 +277,22 @@
|
||||
"enum.navigation_item.settings" = "Settings";
|
||||
"enum.search_result_snippet_mode.disabled" = "Disabled";
|
||||
"enum.search_result_snippet_mode.matches" = "Matches";
|
||||
|
||||
"payment.donate.title" = "Donate";
|
||||
"payment.description.label" = "Support Kiwix";
|
||||
"payment.summary_page.title" = "Support Kiwix";
|
||||
"payment.support_button.label" = "Support Kiwix";
|
||||
"payment.monthly_support.label" = "Monthly support for Kiwix";
|
||||
"payment.summary.title" = "Kiwix";
|
||||
"payment.textfield.custom_amount.label" = "Custom amount";
|
||||
"payment.confirm.button.title" = "Confirm";
|
||||
"payment.selection.average_monthly_donation.subtitle" = "Average monthly donation";
|
||||
"payment.selection.last_year_average.subtitle" = "Last year's average";
|
||||
"payment.selection.custom_amount" = "Custom amount";
|
||||
"payment.selection.option.one_time" = "One time";
|
||||
"payment.selection.option.monthly" = "Monthly";
|
||||
"payment.support_fallback_message" = "We are sorry, your device does not support Apple Pay.";
|
||||
"payment.success.title" = "Thank you so much for your donation.";
|
||||
"payment.success.description" = "Your generosity means everything to us.";
|
||||
"payment.error.title" = "Well that's awkward.";
|
||||
"payment.error.description" = "There has been an issue with your payment. Please try again.";
|
||||
|
41
Views/Buttons/SupportKiwixButton.swift
Normal file
41
Views/Buttons/SupportKiwixButton.swift
Normal file
@ -0,0 +1,41 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct SupportKiwixButton: View {
|
||||
|
||||
let openDonation: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
openDonation()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text("payment.support_button.label".localized)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(6)
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(BorderlessButtonStyle())
|
||||
.padding()
|
||||
#endif
|
||||
}
|
||||
}
|
93
Views/Payment/CustomAmount.swift
Normal file
93
Views/Payment/CustomAmount.swift
Normal file
@ -0,0 +1,93 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct CustomAmount: View {
|
||||
private let selected: PassthroughSubject<SelectedAmount?, Never>
|
||||
private let isMonthly: Bool
|
||||
@State private var customAmount: Double?
|
||||
@State private var customCurrency: String = Payment.defaultCurrencyCode
|
||||
@FocusState private var focusedField: FocusedField?
|
||||
private var currencies = Payment.currencyCodes
|
||||
|
||||
public init(selected: PassthroughSubject<SelectedAmount?, Never>, isMonthly: Bool) {
|
||||
self.selected = selected
|
||||
self.isMonthly = isMonthly
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
List {
|
||||
HStack {
|
||||
TextField("payment.textfield.custom_amount.label".localized,
|
||||
value: $customAmount,
|
||||
format: .number.precision(.fractionLength(2)))
|
||||
.focused($focusedField, equals: .customAmount)
|
||||
#if os(iOS)
|
||||
.padding(6)
|
||||
.keyboardType(.decimalPad)
|
||||
#else
|
||||
.textFieldStyle(.plain)
|
||||
.fontWeight(.bold)
|
||||
.font(Font.headline)
|
||||
.padding(4)
|
||||
.border(Color.accentColor.opacity(0.618), width: 2)
|
||||
#endif
|
||||
Picker("", selection: $customCurrency) {
|
||||
ForEach(currencies, id: \.self) {
|
||||
Text(Locale.current.localizedString(forCurrencyCode: $0) ?? $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.frame(maxHeight: 100)
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
if let customAmount {
|
||||
selected.send(
|
||||
SelectedAmount(
|
||||
value: customAmount,
|
||||
currency: customCurrency,
|
||||
isMonthly: isMonthly
|
||||
)
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Text("payment.confirm.button.title")
|
||||
}
|
||||
.buttonStyle(BorderedProminentButtonStyle())
|
||||
.padding()
|
||||
.disabled( !Payment.isInValidRange(amount: customAmount) )
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.task { @MainActor in
|
||||
focusedField = .customAmount
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private enum FocusedField: String {
|
||||
case customAmount
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CustomAmount(selected: PassthroughSubject<SelectedAmount?, Never>(), isMonthly: true)
|
||||
}
|
107
Views/Payment/ListOfAmounts.swift
Normal file
107
Views/Payment/ListOfAmounts.swift
Normal file
@ -0,0 +1,107 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct ListOfAmounts: View {
|
||||
let amountSelected: PassthroughSubject<SelectedAmount?, Never>
|
||||
|
||||
@Binding public var isMonthly: Bool
|
||||
@State private var listState: ListState = .list
|
||||
#if os(macOS)
|
||||
@EnvironmentObject var formReset: FormReset
|
||||
#endif
|
||||
|
||||
init(amountSelected: PassthroughSubject<SelectedAmount?, Never>, isMonthly: Binding<Bool>) {
|
||||
self.amountSelected = amountSelected
|
||||
_isMonthly = isMonthly
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if case .customAmount = listState {
|
||||
CustomAmount(selected: amountSelected, isMonthly: isMonthly)
|
||||
#if os(macOS)
|
||||
.onReceive(formReset.objectWillChange) { _ in
|
||||
reset()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
listing()
|
||||
// doesn't need reset, since this is the default state
|
||||
}
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
listState = .list
|
||||
}
|
||||
|
||||
private func listing() -> some View {
|
||||
let items = isMonthly ? Payment.monthlies : Payment.oneTimes
|
||||
let averageText: String = if isMonthly {
|
||||
"payment.selection.average_monthly_donation.subtitle".localized
|
||||
} else {
|
||||
"payment.selection.last_year_average.subtitle".localized
|
||||
}
|
||||
let defaultCurrency: String = Payment.defaultCurrencyCode
|
||||
return List {
|
||||
ForEach(items) { amount in
|
||||
Button(
|
||||
action: {
|
||||
amountSelected.send(
|
||||
SelectedAmount(
|
||||
value: amount.value,
|
||||
currency: defaultCurrency,
|
||||
isMonthly: isMonthly
|
||||
)
|
||||
)
|
||||
},
|
||||
label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(amount.value, format: .currency(code: defaultCurrency))
|
||||
.frame(alignment: .leading)
|
||||
if amount.isAverage {
|
||||
Text(averageText)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
})
|
||||
.padding(6)
|
||||
}
|
||||
Button(action: {
|
||||
listState = .customAmount
|
||||
}, label: {
|
||||
Text("payment.selection.custom_amount".localized)
|
||||
})
|
||||
.padding(6)
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(LinkButtonStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private enum ListState {
|
||||
case list
|
||||
case customAmount
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ListOfAmounts(
|
||||
amountSelected: PassthroughSubject<SelectedAmount?, Never>(),
|
||||
isMonthly: Binding<Bool>.constant(true)
|
||||
)
|
||||
}
|
109
Views/Payment/PaymentForm.swift
Normal file
109
Views/Payment/PaymentForm.swift
Normal file
@ -0,0 +1,109 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct PaymentForm: View {
|
||||
let amountSelected: PassthroughSubject<SelectedAmount?, Never>
|
||||
@State var isMonthly: Bool = false
|
||||
@Environment(\.dismiss) var dismiss
|
||||
#if os(macOS)
|
||||
@EnvironmentObject var formReset: FormReset
|
||||
#endif
|
||||
|
||||
init(amountSelected: PassthroughSubject<SelectedAmount?, Never>) {
|
||||
self.amountSelected = amountSelected
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
isMonthly = false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("payment.donate.title".localized)
|
||||
.font(.title)
|
||||
.padding(.init(top: 12, leading: 0, bottom: 8, trailing: 0))
|
||||
Spacer()
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Button("", systemImage: "x.circle.fill") {
|
||||
dismiss()
|
||||
}
|
||||
.font(.title)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding()
|
||||
}
|
||||
#endif
|
||||
|
||||
VStack {
|
||||
// Re-enable as part of: https://github.com/kiwix/kiwix-apple/issues/1032
|
||||
// Picker("", selection: $isMonthly) {
|
||||
// Label("payment.selection.option.one_time".localized, systemImage: "heart.circle").tag(false)
|
||||
// Label("payment.selection.option.monthly".localized, systemImage: "arrow.clockwise.heart").tag(true)
|
||||
// }.pickerStyle(.segmented)
|
||||
// .padding([.leading, .trailing, .bottom])
|
||||
|
||||
ListOfAmounts(amountSelected: amountSelected, isMonthly: $isMonthly)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding()
|
||||
.navigationTitle("payment.donate.title".localized)
|
||||
.onReceive(formReset.objectWillChange) { _ in
|
||||
reset()
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PaymentForm(amountSelected: PassthroughSubject<SelectedAmount?, Never>())
|
||||
}
|
||||
|
||||
struct SelectedAmount {
|
||||
let value: Double
|
||||
let currency: String
|
||||
let isMonthly: Bool
|
||||
|
||||
init(value: Double, currency: String, isMonthly: Bool) {
|
||||
// make sure we won't go over Stripe's max amount
|
||||
self.value = min(value, Double(StripeKiwix.maxAmount) * 100.0)
|
||||
self.currency = currency
|
||||
self.isMonthly = isMonthly
|
||||
}
|
||||
}
|
||||
|
||||
struct AmountOption: Identifiable {
|
||||
// stabelise the scroll, if we have the same amount
|
||||
// for both one-time and monthly and we switch in-between them
|
||||
let id = UUID()
|
||||
let value: Double
|
||||
let isAverage: Bool
|
||||
|
||||
init(value: Double, isAverage: Bool = false) {
|
||||
self.value = value
|
||||
self.isAverage = isAverage
|
||||
}
|
||||
}
|
||||
|
||||
final class FormReset: ObservableObject {
|
||||
func reset() {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
78
Views/Payment/PaymentResultPopUp.swift
Normal file
78
Views/Payment/PaymentResultPopUp.swift
Normal file
@ -0,0 +1,78 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PaymentResultPopUp: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass?
|
||||
#endif
|
||||
|
||||
let state: State
|
||||
|
||||
enum State {
|
||||
case thankYou
|
||||
case error
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
// iPhone Landscape
|
||||
if verticalSizeClass == .compact {
|
||||
// needs a close button
|
||||
closeButton
|
||||
}
|
||||
#endif
|
||||
VStack(spacing: 16) {
|
||||
switch state {
|
||||
case .thankYou:
|
||||
Text("payment.success.title".localized)
|
||||
.font(.title)
|
||||
Text("payment.success.description".localized)
|
||||
.font(.headline)
|
||||
case .error:
|
||||
Text("payment.error.title".localized)
|
||||
.font(.title)
|
||||
Text("payment.error.description".localized)
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var closeButton: some View {
|
||||
HStack(alignment: .top) {
|
||||
Spacer()
|
||||
Button("", systemImage: "x.circle.fill") {
|
||||
dismiss()
|
||||
}
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
.buttonStyle(BorderlessButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PaymentResultPopUp(state: .thankYou)
|
||||
}
|
79
Views/Payment/PaymentSummary.swift
Normal file
79
Views/Payment/PaymentSummary.swift
Normal file
@ -0,0 +1,79 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import SwiftUI
|
||||
import PassKit
|
||||
import Combine
|
||||
|
||||
struct PaymentSummary: View {
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
private let selectedAmount: SelectedAmount
|
||||
private let payment: Payment
|
||||
private let onComplete: @MainActor () -> Void
|
||||
|
||||
init(selectedAmount: SelectedAmount,
|
||||
onComplete: @escaping @MainActor () -> Void) {
|
||||
self.selectedAmount = selectedAmount
|
||||
self.onComplete = onComplete
|
||||
payment = Payment()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("payment.summary_page.title".localized)
|
||||
.font(.largeTitle)
|
||||
.padding()
|
||||
if selectedAmount.isMonthly {
|
||||
Text("payment.selection.option.monthly".localized).font(.title)
|
||||
.padding()
|
||||
} else {
|
||||
Text("payment.selection.option.one_time".localized).font(.title)
|
||||
.padding()
|
||||
}
|
||||
Text(selectedAmount.value.formatted(.currency(code: selectedAmount.currency))).font(.title).bold()
|
||||
if let buttonLabel = Payment.paymentButtonType() {
|
||||
PayWithApplePayButton(
|
||||
buttonLabel,
|
||||
request: payment.donationRequest(for: selectedAmount),
|
||||
onPaymentAuthorizationChange: { phase in
|
||||
payment.onPaymentAuthPhase(selectedAmount: selectedAmount,
|
||||
phase: phase)
|
||||
},
|
||||
onMerchantSessionRequested: payment.onMerchantSessionUpdate
|
||||
)
|
||||
.frame(width: 186, height: 44)
|
||||
.padding()
|
||||
} else {
|
||||
Text("payment.support_fallback_message".localized)
|
||||
.foregroundStyle(.red)
|
||||
.font(.callout)
|
||||
}
|
||||
}.onReceive(payment.completeSubject) {
|
||||
debugPrint("PaymentSummary::payment.completeSubject")
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PaymentSummary(
|
||||
selectedAmount: SelectedAmount(value: 34,
|
||||
currency: "CHF",
|
||||
isMonthly: true),
|
||||
onComplete: {}
|
||||
)
|
||||
}
|
@ -126,7 +126,24 @@ struct SettingSection<Content: View>: View {
|
||||
|
||||
#elseif os(iOS)
|
||||
|
||||
import PassKit
|
||||
import Combine
|
||||
|
||||
struct Settings: View {
|
||||
|
||||
enum DonationPopupState {
|
||||
case selection
|
||||
case selectedAmount(SelectedAmount)
|
||||
case thankYou
|
||||
case error
|
||||
}
|
||||
|
||||
private var amountSelected = PassthroughSubject<SelectedAmount?, Never>()
|
||||
@State private var showDonationPopUp: Bool = false
|
||||
@State private var donationPopUpState: DonationPopupState = .selection
|
||||
func openDonation() {
|
||||
showDonationPopUp = true
|
||||
}
|
||||
@Default(.backupDocumentDirectory) private var backupDocumentDirectory
|
||||
@Default(.downloadUsingCellular) private var downloadUsingCellular
|
||||
@Default(.externalLinkLoadingPolicy) private var externalLinkLoadingPolicy
|
||||
@ -140,24 +157,77 @@ struct Settings: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if FeatureFlags.hasLibrary {
|
||||
List {
|
||||
readingSettings
|
||||
librarySettings
|
||||
catalogSettings
|
||||
backupSettings
|
||||
miscellaneous
|
||||
Group {
|
||||
if FeatureFlags.hasLibrary {
|
||||
List {
|
||||
readingSettings
|
||||
librarySettings
|
||||
catalogSettings
|
||||
backupSettings
|
||||
miscellaneous
|
||||
}
|
||||
.modifier(ToolbarRoleBrowser())
|
||||
.navigationTitle("settings.navigation.title".localized)
|
||||
} else {
|
||||
List {
|
||||
readingSettings
|
||||
miscellaneous
|
||||
}
|
||||
.modifier(ToolbarRoleBrowser())
|
||||
.navigationTitle("settings.navigation.title".localized)
|
||||
}
|
||||
.modifier(ToolbarRoleBrowser())
|
||||
.navigationTitle("settings.navigation.title".localized)
|
||||
} else {
|
||||
List {
|
||||
readingSettings
|
||||
miscellaneous
|
||||
}
|
||||
.modifier(ToolbarRoleBrowser())
|
||||
.navigationTitle("settings.navigation.title".localized)
|
||||
}
|
||||
.sheet(isPresented: $showDonationPopUp, onDismiss: {
|
||||
let result = Payment.showResult()
|
||||
switch result {
|
||||
case .none:
|
||||
// reset
|
||||
donationPopUpState = .selection
|
||||
return
|
||||
case .some(let finalResult):
|
||||
Task {
|
||||
// we need to close the sheet in order to dismiss ApplePay,
|
||||
// and we need to re-open it again with a delay to show thank you state
|
||||
// Swift UI cannot yet handle multiple sheets
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
await MainActor.run {
|
||||
switch finalResult {
|
||||
case .thankYou:
|
||||
donationPopUpState = .thankYou
|
||||
case .error:
|
||||
donationPopUpState = .error
|
||||
}
|
||||
showDonationPopUp = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, content: {
|
||||
Group {
|
||||
switch donationPopUpState {
|
||||
case .selection:
|
||||
PaymentForm(amountSelected: amountSelected)
|
||||
.presentationDetents([.fraction(0.65)])
|
||||
case .selectedAmount(let selectedAmount):
|
||||
PaymentSummary(selectedAmount: selectedAmount, onComplete: {
|
||||
showDonationPopUp = false
|
||||
})
|
||||
.presentationDetents([.fraction(0.65)])
|
||||
case .thankYou:
|
||||
PaymentResultPopUp(state: .thankYou)
|
||||
.presentationDetents([.fraction(0.33)])
|
||||
case .error:
|
||||
PaymentResultPopUp(state: .error)
|
||||
.presentationDetents([.fraction(0.33)])
|
||||
}
|
||||
}
|
||||
.onReceive(amountSelected) { value in
|
||||
if let amount = value {
|
||||
donationPopUpState = .selectedAmount(amount)
|
||||
} else {
|
||||
donationPopUpState = .selection
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var readingSettings: some View {
|
||||
@ -244,6 +314,11 @@ struct Settings: View {
|
||||
|
||||
var miscellaneous: some View {
|
||||
Section("settings.miscellaneous.title".localized) {
|
||||
if Payment.paymentButtonType() != nil {
|
||||
SupportKiwixButton {
|
||||
openDonation()
|
||||
}
|
||||
}
|
||||
Button("settings.miscellaneous.button.feedback".localized) {
|
||||
UIApplication.shared.open(URL(string: "mailto:feedback@kiwix.org")!)
|
||||
}
|
||||
|
@ -47,6 +47,9 @@ packages:
|
||||
Defaults:
|
||||
url: https://github.com/sindresorhus/Defaults
|
||||
majorVersion: 6.0.0
|
||||
StripeApplePay:
|
||||
url: https://github.com/CodeLikeW/stripe-apple-pay
|
||||
majorVersion: 24.0.0
|
||||
|
||||
targetTemplates:
|
||||
ApplicationTemplate:
|
||||
@ -68,8 +71,10 @@ targetTemplates:
|
||||
- sdk: WebKit.framework
|
||||
- sdk: NotificationCenter.framework
|
||||
- sdk: QuickLook.framework
|
||||
- sdk: PassKit.framework
|
||||
- sdk: SystemConfiguration.framework
|
||||
- package: Defaults
|
||||
- package: StripeApplePay
|
||||
sources:
|
||||
- path: App
|
||||
- path: Model
|
||||
@ -93,6 +98,7 @@ targets:
|
||||
entitlements:
|
||||
properties:
|
||||
com.apple.security.files.downloads.read-write: true
|
||||
com.apple.developer.in-app-payments: [merchant.org.kiwix.apple]
|
||||
settings:
|
||||
base:
|
||||
MARKETING_VERSION: "3.6.0"
|
||||
|
Loading…
x
Reference in New Issue
Block a user