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:
@@ -48,6 +48,21 @@ public struct SevenDayUsageChart: View {
|
||||
Locale(identifier: language.resolvedLanguageCode())
|
||||
}
|
||||
|
||||
/// The real day for each bar; used to decide which axis marks get a label.
|
||||
private var pointDates: Set<Date> {
|
||||
Set(points.map(\.date))
|
||||
}
|
||||
|
||||
/// Axis tick positions: the 7 real days plus one trailing boundary (last + 1
|
||||
/// day) so `centered: true` has a span to center the final day's label within.
|
||||
private var axisDates: [Date] {
|
||||
let dates = points.map(\.date)
|
||||
guard let last = dates.last,
|
||||
let boundary = Calendar.current.date(byAdding: .day, value: 1, to: last)
|
||||
else { return dates }
|
||||
return dates + [boundary]
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if embedsInCard {
|
||||
@@ -121,8 +136,18 @@ public struct SevenDayUsageChart: View {
|
||||
.chartYScale(domain: 0...max(1, Int(ceil(Double(maxValue) * 1.15))))
|
||||
.chartYAxis(.hidden)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: points.map(\.date)) { _ in
|
||||
AxisValueLabel(format: .dateTime.weekday(.narrow))
|
||||
// Center each weekday label under its bar. Date `BarMark`s draw the
|
||||
// bar centered within its day band, but axis labels default to the
|
||||
// day's leading tick, so `centered: true` re-centers them on the bar.
|
||||
//
|
||||
// `centered` positions a label between its tick and the *next* tick,
|
||||
// so the final day (Saturday) would be dropped for lack of a trailing
|
||||
// tick. We append one boundary tick (last day + 1) to give it a span,
|
||||
// and only draw labels for the real 7 days.
|
||||
AxisMarks(values: axisDates) { value in
|
||||
if let date = value.as(Date.self), pointDates.contains(date) {
|
||||
AxisValueLabel(format: .dateTime.weekday(.narrow), centered: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.locale, chartLocale)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// SonicParticleField.swift
|
||||
// OSGKeyboard · Shared Design System
|
||||
//
|
||||
// Ambient radial sonic-wave particles for onboarding welcome screens.
|
||||
// Pure SwiftUI Canvas + TimelineView — no Metal / SpriteKit dependency.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Ripple
|
||||
|
||||
private struct SonicRipple: Identifiable {
|
||||
let id = UUID()
|
||||
let origin: CGPoint
|
||||
let bornAt: TimeInterval
|
||||
}
|
||||
|
||||
// MARK: - Particle seed (immutable layout)
|
||||
|
||||
private struct SonicParticleSeed: Sendable {
|
||||
let ring: Int
|
||||
let angle: Double
|
||||
let phase: Double
|
||||
let size: CGFloat
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
/// Radial sonic-wave particle field with touch / pointer interaction.
|
||||
/// Intended as a decorative backdrop behind onboarding hero content.
|
||||
public struct SonicParticleField: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Optional accent override; defaults to `themePalette.accent`.
|
||||
public var accent: Color?
|
||||
/// Focal point for the pulse rings, in unit coordinates of the field bounds.
|
||||
public var focalPoint: UnitPoint
|
||||
/// When false, particles animate but ignore pointer input.
|
||||
public var isInteractive: Bool
|
||||
|
||||
@State private var touchLocation: CGPoint?
|
||||
@State private var isTouching = false
|
||||
@State private var ripples: [SonicRipple] = []
|
||||
|
||||
public init(
|
||||
accent: Color? = nil,
|
||||
focalPoint: UnitPoint = .center,
|
||||
isInteractive: Bool = true
|
||||
) {
|
||||
self.accent = accent
|
||||
self.focalPoint = focalPoint
|
||||
self.isInteractive = isInteractive
|
||||
}
|
||||
|
||||
private var resolvedAccent: Color {
|
||||
accent ?? palette.accent
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 45.0)) { timeline in
|
||||
GeometryReader { geo in
|
||||
let size = geo.size
|
||||
let center = CGPoint(
|
||||
x: size.width * focalPoint.x,
|
||||
y: size.height * focalPoint.y
|
||||
)
|
||||
let time = timeline.date.timeIntervalSinceReferenceDate
|
||||
let motionScale = reduceMotion ? 0.18 : 1.0
|
||||
|
||||
ZStack {
|
||||
RadialGradient(
|
||||
colors: [
|
||||
resolvedAccent.opacity(reduceMotion ? 0.05 : 0.10),
|
||||
resolvedAccent.opacity(0.03),
|
||||
palette.background.opacity(0)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: min(size.width, size.height) * 0.52
|
||||
)
|
||||
|
||||
Canvas { context, canvasSize in
|
||||
drawField(
|
||||
in: &context,
|
||||
size: canvasSize,
|
||||
center: center,
|
||||
time: time * motionScale,
|
||||
motionScale: motionScale
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(interactionGesture(in: size, time: time))
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(isInteractive)
|
||||
}
|
||||
|
||||
// MARK: - Drawing
|
||||
|
||||
private func drawField(
|
||||
in context: inout GraphicsContext,
|
||||
size: CGSize,
|
||||
center: CGPoint,
|
||||
time: TimeInterval,
|
||||
motionScale: Double
|
||||
) {
|
||||
let maxDimension = max(size.width, size.height)
|
||||
let ringSpacing = maxDimension * 0.075
|
||||
let baseRadius = maxDimension * 0.06
|
||||
let influenceRadius = maxDimension * 0.22
|
||||
|
||||
drawRipples(
|
||||
in: &context,
|
||||
time: time,
|
||||
maxRadius: maxDimension * 0.55
|
||||
)
|
||||
|
||||
for seed in Self.particleSeeds {
|
||||
let pulse = (time * 0.42 + seed.phase).truncatingRemainder(dividingBy: 1.0)
|
||||
let wobble = sin(time * 2.4 + seed.angle * 3.0) * 0.018
|
||||
let angle = seed.angle + wobble
|
||||
|
||||
let ringRadius = baseRadius + Double(seed.ring) * ringSpacing
|
||||
let expansion = pulse * ringSpacing * 0.92
|
||||
var radius = ringRadius + expansion
|
||||
|
||||
// Soft sonic bulge — particles swell mid-pulse.
|
||||
let swell = sin(pulse * .pi) * ringSpacing * 0.08
|
||||
radius += swell
|
||||
|
||||
var position = polarPoint(center: center, radius: radius, angle: angle)
|
||||
position = applyTouchInfluence(
|
||||
to: position,
|
||||
touch: touchLocation,
|
||||
influenceRadius: influenceRadius
|
||||
)
|
||||
|
||||
var opacity = (1.0 - pulse) * 0.62 + 0.12
|
||||
opacity += sin(time * 3.1 + seed.phase * 8.0) * 0.06
|
||||
opacity = max(0.08, min(0.78, opacity))
|
||||
|
||||
if motionScale < 1 {
|
||||
opacity *= 0.55
|
||||
}
|
||||
|
||||
opacity += rippleBoost(at: position, time: time, maxRadius: maxDimension * 0.55)
|
||||
|
||||
let particleColor = resolvedAccent.opacity(opacity)
|
||||
let rect = CGRect(
|
||||
x: position.x - seed.size * 0.5,
|
||||
y: position.y - seed.size * 0.5,
|
||||
width: seed.size,
|
||||
height: seed.size
|
||||
)
|
||||
|
||||
context.fill(
|
||||
Path(ellipseIn: rect),
|
||||
with: .color(particleColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawRipples(
|
||||
in context: inout GraphicsContext,
|
||||
time: TimeInterval,
|
||||
maxRadius: CGFloat
|
||||
) {
|
||||
for ripple in ripples {
|
||||
let age = time - ripple.bornAt
|
||||
guard age > 0, age < 2.4 else { continue }
|
||||
|
||||
let progress = age / 2.4
|
||||
let radius = maxRadius * CGFloat(progress)
|
||||
let opacity = (1.0 - progress) * 0.28
|
||||
let lineWidth = max(0.8, 2.4 - progress * 1.4)
|
||||
|
||||
var ringContext = context
|
||||
ringContext.stroke(
|
||||
Path(ellipseIn: CGRect(
|
||||
x: ripple.origin.x - radius,
|
||||
y: ripple.origin.y - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2
|
||||
)),
|
||||
with: .color(resolvedAccent.opacity(opacity)),
|
||||
lineWidth: lineWidth
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func rippleBoost(at point: CGPoint, time: TimeInterval, maxRadius: CGFloat) -> Double {
|
||||
var boost = 0.0
|
||||
for ripple in ripples {
|
||||
let age = time - ripple.bornAt
|
||||
guard age > 0, age < 2.4 else { continue }
|
||||
|
||||
let progress = age / 2.4
|
||||
let radius = Double(maxRadius) * progress
|
||||
let dist = hypot(point.x - ripple.origin.x, point.y - ripple.origin.y)
|
||||
let band = abs(dist - radius)
|
||||
if band < 18 {
|
||||
boost += (1.0 - band / 18.0) * (1.0 - progress) * 0.35
|
||||
}
|
||||
}
|
||||
return min(boost, 0.4)
|
||||
}
|
||||
|
||||
private func polarPoint(center: CGPoint, radius: Double, angle: Double) -> CGPoint {
|
||||
CGPoint(
|
||||
x: center.x + CGFloat(cos(angle) * radius),
|
||||
y: center.y + CGFloat(sin(angle) * radius)
|
||||
)
|
||||
}
|
||||
|
||||
private func applyTouchInfluence(
|
||||
to point: CGPoint,
|
||||
touch: CGPoint?,
|
||||
influenceRadius: CGFloat
|
||||
) -> CGPoint {
|
||||
guard let touch else { return point }
|
||||
|
||||
let dx = point.x - touch.x
|
||||
let dy = point.y - touch.y
|
||||
let distance = hypot(dx, dy)
|
||||
guard distance > 0.5, distance < influenceRadius else { return point }
|
||||
|
||||
let normalized = (influenceRadius - distance) / influenceRadius
|
||||
let strength = CGFloat(normalized * normalized) * (isTouching ? 22 : 10)
|
||||
return CGPoint(
|
||||
x: point.x + dx / distance * strength,
|
||||
y: point.y + dy / distance * strength
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
private func interactionGesture(in size: CGSize, time: TimeInterval) -> some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
guard isInteractive else { return }
|
||||
touchLocation = value.location
|
||||
isTouching = true
|
||||
registerRipple(at: value.location, time: time)
|
||||
}
|
||||
.onEnded { _ in
|
||||
isTouching = false
|
||||
touchLocation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func registerRipple(at location: CGPoint, time: TimeInterval) {
|
||||
let shouldAdd: Bool
|
||||
if let last = ripples.last {
|
||||
let moved = hypot(location.x - last.origin.x, location.y - last.origin.y)
|
||||
let elapsed = time - last.bornAt
|
||||
shouldAdd = moved > 36 || elapsed > 0.38
|
||||
} else {
|
||||
shouldAdd = true
|
||||
}
|
||||
|
||||
guard shouldAdd else { return }
|
||||
|
||||
ripples.append(SonicRipple(origin: location, bornAt: time))
|
||||
ripples = ripples.filter { time - $0.bornAt < 2.6 }
|
||||
}
|
||||
|
||||
// MARK: - Particle layout
|
||||
|
||||
private static let particleSeeds: [SonicParticleSeed] = {
|
||||
var seeds: [SonicParticleSeed] = []
|
||||
let ringCount = 7
|
||||
let particlesPerRing = [14, 18, 22, 26, 30, 34, 38]
|
||||
|
||||
for ring in 0..<ringCount {
|
||||
let count = particlesPerRing[ring]
|
||||
for index in 0..<count {
|
||||
let angle = (Double(index) / Double(count)) * (.pi * 2.0)
|
||||
+ Double(ring) * 0.11
|
||||
let phase = Double(ring) * 0.13 + Double(index) * 0.017
|
||||
let size = CGFloat(1.6 + (Double(index + ring) * 0.07).truncatingRemainder(dividingBy: 1.9))
|
||||
seeds.append(SonicParticleSeed(ring: ring, angle: angle, phase: phase, size: size))
|
||||
}
|
||||
}
|
||||
return seeds
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// SupportDeveloperSection.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Optional voluntary tip block for Settings. Does not gate features.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// iOS Settings card for the consumable support tip.
|
||||
public struct SupportDeveloperSection: View {
|
||||
@ObservedObject private var tipManager: TipPurchaseManager
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private let language: AppUILanguage
|
||||
|
||||
@State private var showThankYouAlert = false
|
||||
@State private var showErrorAlert = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init(
|
||||
language: AppUILanguage,
|
||||
tipManager: TipPurchaseManager = .shared
|
||||
) {
|
||||
self.language = language
|
||||
self.tipManager = tipManager
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
Text(SharedL10n.string("tip.title", language: language))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.textCase(.uppercase)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
SupportDeveloperTipBody(language: language)
|
||||
|
||||
if tipManager.supportCount > 0 {
|
||||
Text(
|
||||
SharedL10n.format(
|
||||
"tip.thankYou.past",
|
||||
language: language,
|
||||
tipManager.supportCount
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
|
||||
tipButton
|
||||
.disabled(isPurchaseInFlight)
|
||||
|
||||
Text(SharedL10n.string("tip.consumableNotice", language: language))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.onChange(of: tipManager.purchaseState) { _, newValue in
|
||||
switch newValue {
|
||||
case .succeeded:
|
||||
showThankYouAlert = true
|
||||
case .failed(let message):
|
||||
errorMessage = message
|
||||
showErrorAlert = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
SharedL10n.string("tip.thankYou.title", language: language),
|
||||
isPresented: $showThankYouAlert
|
||||
) {
|
||||
Button(SharedL10n.string("tip.alert.dismiss", language: language)) {
|
||||
tipManager.acknowledgePurchaseState()
|
||||
}
|
||||
} message: {
|
||||
Text(SharedL10n.string("tip.thankYou.message", language: language))
|
||||
}
|
||||
.alert(
|
||||
SharedL10n.string("tip.error.title", language: language),
|
||||
isPresented: $showErrorAlert
|
||||
) {
|
||||
Button(SharedL10n.string("tip.alert.dismiss", language: language)) {
|
||||
tipManager.acknowledgePurchaseState()
|
||||
}
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private var isPurchaseInFlight: Bool {
|
||||
switch tipManager.purchaseState {
|
||||
case .loading, .purchasing:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var tipButton: some View {
|
||||
Button {
|
||||
Task { await tipManager.purchase() }
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if isPurchaseInFlight {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(tipButtonTitle)
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.foregroundStyle(.white)
|
||||
.background(palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(tipButtonTitle)
|
||||
}
|
||||
|
||||
private var tipButtonTitle: String {
|
||||
if let product = tipManager.product {
|
||||
return SharedL10n.format("tip.button", language: language, product.displayPrice)
|
||||
}
|
||||
return SharedL10n.string("tip.buttonFallback", language: language)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared copy + tip button for macOS Settings (Mac chrome wraps this).
|
||||
public struct SupportDeveloperTipBody: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private let language: AppUILanguage
|
||||
|
||||
public init(language: AppUILanguage) {
|
||||
self.language = language
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Text(SharedL10n.string("tip.body", language: language))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -269,7 +269,7 @@
|
||||
"mac.onboarding.next" = "Next";
|
||||
"mac.onboarding.finish" = "Finish";
|
||||
"mac.onboarding.skipForNow" = "Skip for now";
|
||||
"mac.onboarding.welcome.title" = "Welcome to OSGKeyboard";
|
||||
"mac.onboarding.welcome.title" = "Speak it. It’s typed.";
|
||||
"mac.onboarding.welcome.subtitle" = "Set up dictation in a minute. Everything here can be changed later in Settings.";
|
||||
"mac.onboarding.welcome.privacy" = "Your voice stays local unless you choose a cloud provider.";
|
||||
"mac.onboarding.welcome.hotkey" = "Hold Option to dictate from any app.";
|
||||
@@ -358,3 +358,18 @@
|
||||
"settings.appLanguage.auto" = "Auto";
|
||||
"settings.appLanguage.english" = "English";
|
||||
"settings.appLanguage.chinese" = "Chinese";
|
||||
|
||||
/* Voluntary support tip (Consumable IAP — no feature unlock) */
|
||||
"tip.title" = "Support the Developer";
|
||||
"tip.body" = "OSGKeyboard is completely free with every feature unlocked. If you find it useful, you can leave an optional tip — it does not unlock anything extra.";
|
||||
"tip.button" = "Tip %@";
|
||||
"tip.buttonFallback" = "Send a Tip";
|
||||
"tip.consumableNotice" = "Consumable in-app purchase. Tips cannot be restored (Apple policy).";
|
||||
"tip.thankYou.title" = "Thank you!";
|
||||
"tip.thankYou.message" = "Your support helps keep OSGKeyboard free and open for everyone.";
|
||||
"tip.thankYou.past" = "You've supported us %lld time(s). Thank you!";
|
||||
"tip.error.title" = "Purchase Failed";
|
||||
"tip.error.productUnavailable" = "The tip product is unavailable right now. Try again later.";
|
||||
"tip.error.pending" = "Your purchase is pending approval.";
|
||||
"tip.error.unknown" = "The purchase could not be completed.";
|
||||
"tip.alert.dismiss" = "OK";
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
"mac.onboarding.next" = "下一步";
|
||||
"mac.onboarding.finish" = "完成";
|
||||
"mac.onboarding.skipForNow" = "暂时跳过";
|
||||
"mac.onboarding.welcome.title" = "欢迎使用 OSGKeyboard";
|
||||
"mac.onboarding.welcome.title" = "开口即文字。";
|
||||
"mac.onboarding.welcome.subtitle" = "用一分钟完成听写基础配置。这里的选项之后都可以在设置里修改。";
|
||||
"mac.onboarding.welcome.privacy" = "除非你选择云端服务商,语音会优先留在本机处理。";
|
||||
"mac.onboarding.welcome.hotkey" = "按住 Option 键即可在任意应用开始听写。";
|
||||
@@ -358,3 +358,18 @@
|
||||
"settings.appLanguage.auto" = "自动";
|
||||
"settings.appLanguage.english" = "英文";
|
||||
"settings.appLanguage.chinese" = "中文";
|
||||
|
||||
/* 自愿打赏(消耗型 IAP — 不解锁功能) */
|
||||
"tip.title" = "支持开发者";
|
||||
"tip.body" = "OSGKeyboard 全功能免费、无任何付费墙。若觉得好用,可以自愿打赏一杯咖啡——不会解锁额外功能。";
|
||||
"tip.button" = "打赏 %@";
|
||||
"tip.buttonFallback" = "打赏支持";
|
||||
"tip.consumableNotice" = "消耗型应用内购买,无法恢复购买(Apple 规则)。";
|
||||
"tip.thankYou.title" = "感谢支持!";
|
||||
"tip.thankYou.message" = "你的支持帮助我们继续免费、开源地维护 OSGKeyboard。";
|
||||
"tip.thankYou.past" = "你已支持 %lld 次,感谢!";
|
||||
"tip.error.title" = "购买失败";
|
||||
"tip.error.productUnavailable" = "暂时无法加载打赏商品,请稍后再试。";
|
||||
"tip.error.pending" = "购买待审批,请稍后查看。";
|
||||
"tip.error.unknown" = "无法完成购买。";
|
||||
"tip.alert.dismiss" = "好";
|
||||
|
||||
Reference in New Issue
Block a user