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:
Kelson 2024-11-30 20:15:05 +01:00 committed by GitHub
commit cdeae9985f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1043 additions and 17 deletions

View File

@ -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
View 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: Whats 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
View 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"
}

View File

@ -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.";

View 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
}
}

View 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)
}

View 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)
)
}

View 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()
}
}

View 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)
}

View 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: {}
)
}

View File

@ -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")!)
}

View File

@ -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"