feat: tip IAP, commercial GitHub Pages, and App Store screenshots
Add optional StoreKit support tip (features stay free), redesign the landing page with zh/en + light/dark and product shots, and ship iPhone/iPad/Mac App Store screenshot sets.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
// TipProduct.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// StoreKit product identifiers for optional voluntary tips.
|
||||
// Tips are consumable IAP — they do not unlock features.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TipProduct {
|
||||
/// ¥28 voluntary support tip (Consumable). Must match App Store Connect Product ID.
|
||||
public static let supportID = "ByRockyACoffee"
|
||||
|
||||
public static var allProductIDs: [String] { [supportID] }
|
||||
|
||||
/// UserDefaults key for optional UX: how many times the user tipped (not entitlements).
|
||||
public static let supportCountDefaultsKey = "tipSupportPurchaseCount"
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// TipPurchaseManager.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Optional ¥30 consumable tip via StoreKit 2. Voluntary support only —
|
||||
// no feature gates, no App Group sync, no restore (Apple consumable rules).
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import StoreKit
|
||||
|
||||
public enum TipPurchaseState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case purchasing
|
||||
case succeeded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class TipPurchaseManager: ObservableObject {
|
||||
public static let shared = TipPurchaseManager()
|
||||
|
||||
@Published public private(set) var product: Product?
|
||||
@Published public private(set) var purchaseState: TipPurchaseState = .idle
|
||||
@Published public private(set) var supportCount: Int
|
||||
|
||||
private var transactionUpdatesTask: Task<Void, Never>?
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
supportCount = defaults.integer(forKey: TipProduct.supportCountDefaultsKey)
|
||||
transactionUpdatesTask = Task { [weak self] in
|
||||
await self?.listenForTransactionUpdates()
|
||||
}
|
||||
Task { await loadProducts() }
|
||||
}
|
||||
|
||||
deinit {
|
||||
transactionUpdatesTask?.cancel()
|
||||
}
|
||||
|
||||
/// Loads the tip product from the App Store / StoreKit Test configuration.
|
||||
public func loadProducts() async {
|
||||
guard purchaseState != .purchasing else { return }
|
||||
purchaseState = .loading
|
||||
do {
|
||||
let products = try await Product.products(for: TipProduct.allProductIDs)
|
||||
product = products.first(where: { $0.id == TipProduct.supportID })
|
||||
purchaseState = product == nil
|
||||
? .failed(SharedL10n.string("tip.error.productUnavailable"))
|
||||
: .idle
|
||||
} catch {
|
||||
product = nil
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the StoreKit purchase sheet for the support tip.
|
||||
public func purchase() async {
|
||||
guard let product else {
|
||||
purchaseState = .failed(SharedL10n.string("tip.error.productUnavailable"))
|
||||
return
|
||||
}
|
||||
purchaseState = .purchasing
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try Self.verified(verification)
|
||||
await handleCompletedTip(transaction)
|
||||
purchaseState = .succeeded
|
||||
case .userCancelled:
|
||||
purchaseState = .idle
|
||||
case .pending:
|
||||
purchaseState = .failed(SharedL10n.string("tip.error.pending"))
|
||||
@unknown default:
|
||||
purchaseState = .failed(SharedL10n.string("tip.error.unknown"))
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears transient success / failure state after the UI acknowledges it.
|
||||
public func acknowledgePurchaseState() {
|
||||
switch purchaseState {
|
||||
case .succeeded, .failed:
|
||||
purchaseState = .idle
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func listenForTransactionUpdates() async {
|
||||
for await update in Transaction.updates {
|
||||
guard let transaction = try? Self.verified(update),
|
||||
transaction.productID == TipProduct.supportID else { continue }
|
||||
await handleCompletedTip(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCompletedTip(_ transaction: Transaction) async {
|
||||
await transaction.finish()
|
||||
recordSupportPurchase()
|
||||
}
|
||||
|
||||
private func recordSupportPurchase() {
|
||||
supportCount += 1
|
||||
UserDefaults.standard.set(supportCount, forKey: TipProduct.supportCountDefaultsKey)
|
||||
}
|
||||
|
||||
private static func verified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .verified(let value):
|
||||
return value
|
||||
case .unverified:
|
||||
throw TipPurchaseError.failedVerification
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum TipPurchaseError: Error {
|
||||
case failedVerification
|
||||
}
|
||||
Reference in New Issue
Block a user