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.
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 572 KiB |
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Voluntary support tip**: Settings (top of the page) includes an optional ¥28 Consumable in-app tip (StoreKit 2). All features stay free — no paywall or unlock. / **自愿打赏**:设置页顶部新增可选 ¥28 消耗型应用内打赏(StoreKit 2)。全功能仍免费,无付费墙或功能解锁。
|
||||
|
||||
### Changed
|
||||
- **Unified welcome slogan**: iPad Home now reuses the iOS onboarding brand line, and macOS onboarding shows the same “Speak it. It’s typed.” welcome slogan. / **统一欢迎口号**:iPad 首页复用 iOS 引导页品牌句,macOS 引导页也显示同一句「开口即文字。」欢迎口号。
|
||||
- **GitHub Pages landing**: redesign as a commercial product page with zh/en, light/dark, brand mark, scroll motion, and App Store screenshots; emphasizes free, cross-platform, open source, privacy, and BYOK. / **GitHub Pages 落地页**:改版为商业产品页,支持中英与日夜模式、品牌标、滚动动效与 App Store 截图;突出免费、跨端、开源、隐私与 BYOK。
|
||||
|
||||
## [0.5.3] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "A1B2C3D4",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "28",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "6789921326",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Support me keep vibe coding~ Voluntary tip — does not unlock any features.",
|
||||
"displayName" : "Buy me a cup of coffee",
|
||||
"locale" : "en_US"
|
||||
},
|
||||
{
|
||||
"description" : "支持一下老刘的 Vibe Coding 工作。自愿打赏,不解锁任何功能。",
|
||||
"displayName" : "给老刘买杯冰美式",
|
||||
"locale" : "zh_Hans"
|
||||
}
|
||||
],
|
||||
"productID" : "ByRockyACoffee",
|
||||
"referenceName" : "给老刘买杯咖啡",
|
||||
"type" : "Consumable"
|
||||
}
|
||||
],
|
||||
"settings" : {
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_locale" : "zh_CN",
|
||||
"_storefront" : "CHN",
|
||||
"_storeKitErrors" : [
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Load Products"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Purchase"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Verification"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Store Sync"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Subscription Status"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Transaction"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Manage Subscriptions Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Refund Request Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Offer Code Redeem Sheet"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ struct HomeView: View {
|
||||
|
||||
private var wideHeroHeader: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text("home.wide.tagline")
|
||||
Text("onboarding.welcome.tagline")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -370,6 +370,17 @@ private struct WelcomePage: View {
|
||||
@State private var logoAppeared = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
SonicParticleField(focalPoint: UnitPoint(x: 0.5, y: 0.42))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.allowsHitTesting(true)
|
||||
|
||||
welcomeContent
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var welcomeContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
@@ -398,6 +409,7 @@ private struct WelcomePage: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ struct SettingsView: View {
|
||||
palette.background.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(spacing: Spacing.md) {
|
||||
if presentation == .tab {
|
||||
SupportDeveloperSection(language: config.uiLanguage)
|
||||
}
|
||||
languageAndPolishSection
|
||||
dictionaryAndPolishSection
|
||||
flowSessionSection
|
||||
|
||||
@@ -335,7 +335,6 @@
|
||||
"home.flow.startShort" = "Start";
|
||||
"home.preview.label" = "Try typing";
|
||||
"home.preview.placeholder" = "Tap to type and test…";
|
||||
"home.wide.tagline" = "Voice dictation, anywhere.";
|
||||
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.wide.mode.cloud" = "Cloud";
|
||||
"home.wide.mode.local" = "On-device";
|
||||
|
||||
@@ -334,7 +334,6 @@
|
||||
"home.flow.startShort" = "开启";
|
||||
"home.preview.label" = "输入测试";
|
||||
"home.preview.placeholder" = "点这里试试键盘";
|
||||
"home.wide.tagline" = "随处语音听写";
|
||||
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
|
||||
"home.wide.mode.cloud" = "云端";
|
||||
"home.wide.mode.local" = "本机";
|
||||
|
||||
@@ -38,8 +38,9 @@ struct DashboardView: View {
|
||||
.padding(.top, Spacing.sm)
|
||||
|
||||
// Leftover window height splits evenly above / below the mic
|
||||
// bar so spacing stays balanced at any window size.
|
||||
Spacer(minLength: Spacing.xs)
|
||||
// bar so spacing stays balanced at any window size. The top
|
||||
// gap keeps a larger floor so the mic never hugs the canvas.
|
||||
Spacer(minLength: 30)
|
||||
|
||||
BottomDictationBar(viewModel: viewModel)
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
|
||||
@@ -261,6 +261,10 @@ struct MacOnboardingView: View {
|
||||
@ViewBuilder
|
||||
private var heroIcon: some View {
|
||||
if model.step == .welcome {
|
||||
ZStack {
|
||||
SonicParticleField()
|
||||
.frame(width: 300, height: 300)
|
||||
|
||||
Image("OSGBrandMark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
@@ -268,6 +272,9 @@ struct MacOnboardingView: View {
|
||||
.frame(width: 128, height: 128)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : palette.accent)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
} else {
|
||||
ZStack {
|
||||
Circle()
|
||||
|
||||
@@ -39,6 +39,7 @@ struct MacSettingsView: View {
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
supportSection
|
||||
generalSection
|
||||
recognitionSection
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
@@ -298,6 +299,17 @@ struct MacSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Support developer
|
||||
|
||||
private var supportSection: some View {
|
||||
MacSettingsSection(
|
||||
title: MacL10n.string("tip.title", language: lang),
|
||||
footer: MacL10n.string("tip.consumableNotice", language: lang)
|
||||
) {
|
||||
MacSupportDeveloperTipRows(language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legal
|
||||
|
||||
private var legalSection: some View {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// MacSupportDeveloperTipRows.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Optional consumable tip rows for macOS Settings.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacSupportDeveloperTipRows: View {
|
||||
@ObservedObject private var tipManager = TipPurchaseManager.shared
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let language: AppUILanguage
|
||||
|
||||
@State private var showThankYouAlert = false
|
||||
@State private var showErrorAlert = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: MacMetrics.settingsRowGap) {
|
||||
SupportDeveloperTipBody(language: language)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
|
||||
if tipManager.supportCount > 0 {
|
||||
Text(
|
||||
SharedL10n.format(
|
||||
"tip.thankYou.past",
|
||||
language: language,
|
||||
tipManager.supportCount
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
|
||||
MacSettingsToolButton(title: tipButtonTitle, disabled: isPurchaseInFlight) {
|
||||
Task { await tipManager.purchase() }
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
.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 tipButtonTitle: String {
|
||||
if let product = tipManager.product {
|
||||
return SharedL10n.format("tip.button", language: language, product.displayPrice)
|
||||
}
|
||||
return SharedL10n.string("tip.buttonFallback", language: language)
|
||||
}
|
||||
}
|
||||
@@ -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" = "好";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// TipProductTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Locks tip product identifiers and optional support-count persistence.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class TipProductTests: XCTestCase {
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "tip.tests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defaults = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSupportProductIDIsStable() {
|
||||
XCTAssertEqual(TipProduct.supportID, "ByRockyACoffee")
|
||||
XCTAssertEqual(TipProduct.allProductIDs, [TipProduct.supportID])
|
||||
}
|
||||
|
||||
func testSupportCountDefaultsKeyIsStable() {
|
||||
XCTAssertEqual(TipProduct.supportCountDefaultsKey, "tipSupportPurchaseCount")
|
||||
}
|
||||
|
||||
func testSupportCountPersistsInUserDefaults() {
|
||||
defaults.set(2, forKey: TipProduct.supportCountDefaultsKey)
|
||||
XCTAssertEqual(defaults.integer(forKey: TipProduct.supportCountDefaultsKey), 2)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Price** | Free (0 USD) |
|
||||
| **In-App Purchases** | Optional voluntary tip — Consumable `ByRockyACoffee` (¥28 China tier; no feature unlock) |
|
||||
| **Availability** | All App Store territories (default) |
|
||||
| **Pre-order** | No |
|
||||
| **Volume purchase** | No |
|
||||
@@ -83,6 +84,12 @@ WHY OSGKEYBOARD
|
||||
touches (voice audio + transcripts, on-device by default, never
|
||||
linked or tracked); we don't run a server.
|
||||
|
||||
OPTIONAL SUPPORT
|
||||
|
||||
OSGKeyboard is completely free — every feature is available without
|
||||
payment. If you'd like to support development, Settings includes an
|
||||
optional in-app tip (Consumable). It does not unlock anything extra.
|
||||
|
||||
BUILT FOR
|
||||
|
||||
• iOS 26 and later, iPhone and iPad.
|
||||
@@ -271,6 +278,11 @@ to end, please:
|
||||
5. The privacy policy is at
|
||||
https://hkgood.github.io/OSGKeyboard/privacy/
|
||||
|
||||
6. Optional tip (Consumable IAP ByRockyACoffee): open
|
||||
Settings → "Support the Developer". All features remain free before
|
||||
and after purchase; the tip does not unlock anything. Consumable
|
||||
tips cannot be restored (stated in UI).
|
||||
|
||||
Source code: https://github.com/hkgood/OSGKeyboard
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# StoreKit — Voluntary Support Tip
|
||||
|
||||
OSGKeyboard offers a single **Consumable** in-app purchase:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Type** | Consumable |
|
||||
| **Product ID** | `ByRockyACoffee` |
|
||||
| **Reference name (Connect)** | 给老刘买杯咖啡 |
|
||||
| **Display name (en)** | Buy me a cup of coffee |
|
||||
| **Display name (zh-Hans)** | 给老刘买杯冰美式 |
|
||||
| **Price (China)** | ¥28 (nearest available App Store tier) |
|
||||
| **Price (US, suggested)** | $3.99 equivalent tier |
|
||||
|
||||
**Important:** this tip does **not** unlock translation, dictionary limits,
|
||||
long Flow sessions, or any other capability. The app remains fully free.
|
||||
|
||||
Consumable purchases **cannot be restored** (Apple policy). Settings copy
|
||||
explains this to users.
|
||||
|
||||
---
|
||||
|
||||
## App Store Connect setup
|
||||
|
||||
1. Open **App Store Connect → OSGKeyboard → In-App Purchases**.
|
||||
2. Create **Consumable** with Product ID `ByRockyACoffee`.
|
||||
3. Add localizations (en + zh-Hans) using the strings in
|
||||
`OSGKeyboardShared/*/Shared.strings` (`tip.*` keys) and
|
||||
`docs/APPSTORE_METADATA.md`.
|
||||
4. Set pricing: **China ¥28** (App Store only offers fixed tiers — ¥28 is
|
||||
the nearest to ¥30); pick equivalent tiers for other territories.
|
||||
5. Submit the IAP for review **with** the app version that includes the
|
||||
Settings → Support the Developer entry.
|
||||
|
||||
### Review screenshot (required — fixes「元数据丢失」)
|
||||
|
||||
App Store Connect → IAP **ByRockyACoffee** → **审核信息** → **截屏**:
|
||||
|
||||
1. Run the app (Simulator or device) with Settings open at the top
|
||||
**支持开发者** card showing the green **打赏 ¥28.00** button.
|
||||
2. Capture that screen (⌘S in Simulator, or device screenshot).
|
||||
3. Upload to **截屏 → 选取文件**.
|
||||
4. Optional **审核备注**:
|
||||
|
||||
```
|
||||
Optional voluntary tip only (Consumable IAP ByRockyACoffee).
|
||||
All features free before and after purchase. Settings tab → top of page.
|
||||
Consumable — cannot restore (stated in UI).
|
||||
```
|
||||
|
||||
Save — status should become **准备提交**.
|
||||
|
||||
---
|
||||
|
||||
## Local testing (StoreKit Test — no Connect / sandbox account)
|
||||
|
||||
Uses [`OSGKeyboard.storekit`](../OSGKeyboard.storekit). The **OSGKeyboard**
|
||||
scheme already references it in `project.yml` (`storeKitConfiguration`).
|
||||
|
||||
1. `xcodegen generate && open OSGKeyboard.xcodeproj`
|
||||
2. **Product → Scheme → Edit Scheme → Run → Options**
|
||||
- Confirm **StoreKit Configuration** = `OSGKeyboard.storekit`
|
||||
3. Run **OSGKeyboard** on Simulator (e.g. iPhone 17) or a plugged-in device
|
||||
4. Open **Settings** tab → top card **支持开发者**
|
||||
5. Tap **打赏 ¥28.00** → StoreKit Test purchase sheet appears
|
||||
6. **Buy** → thank-you alert; **Cancel** → no error
|
||||
7. Repeat buy once (Consumable allows multiple)
|
||||
|
||||
**Debug menu (optional):** Xcode → **Debug → StoreKit → Manage Transactions**
|
||||
to view / delete test purchases.
|
||||
|
||||
---
|
||||
|
||||
## Sandbox testing (real App Store sandbox — after Connect IAP is 准备提交)
|
||||
|
||||
1. App Store Connect → **用户和访问** → **沙盒** → create a **Sandbox Tester**
|
||||
2. On device: **设置 → App Store → 沙盒账户** → sign in (not your real Apple ID)
|
||||
3. Install via **TestFlight** or **Debug run without** `.storekit`:
|
||||
- To hit Connect products: Edit Scheme → Run → Options → set StoreKit
|
||||
Configuration to **None**, then run on device
|
||||
4. Settings → **支持开发者** → purchase with sandbox account
|
||||
5. Sandbox charges are free; receipt is real sandbox flow
|
||||
|
||||
---
|
||||
|
||||
## Submit to App Review (after local + sandbox pass)
|
||||
|
||||
Yes — **wait until testing looks good**, then:
|
||||
|
||||
1. IAP **ByRockyACoffee** status = **准备提交** (pricing + screenshot + localizations)
|
||||
2. Bump app version in `project.yml` / `CHANGELOG.md` if needed
|
||||
3. **Archive** → upload build to App Store Connect
|
||||
4. Open the new **App Store version** page → **App 内购买项目** → **+** → select **ByRockyACoffee**
|
||||
5. Fill metadata, attach build, submit **version + IAP together** (first IAP rule)
|
||||
|
||||
### Sandbox checklist
|
||||
|
||||
- [ ] Product loads and shows localized price
|
||||
- [ ] Successful purchase shows thank-you alert
|
||||
- [ ] User cancel returns to idle (no error spam)
|
||||
- [ ] Repeat purchase works (Consumable allows multiple)
|
||||
- [ ] No Restore button for this product (Consumable)
|
||||
- [ ] Translation, Flow, dictionary, BYOK unchanged after tipping
|
||||
|
||||
### macOS note
|
||||
|
||||
The menu-bar Mac build (`com.osgkeyboard.mac`) ships via Developer ID outside
|
||||
the Mac App Store today. StoreKit products load only for App Store builds.
|
||||
The Mac Settings UI is present for parity; tip IAP requires an App Store
|
||||
distribution if Mac tipping is enabled later.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `OSGKeyboardShared/Services/Tip/TipProduct.swift` | Product ID constants |
|
||||
| `OSGKeyboardShared/Services/Tip/TipPurchaseManager.swift` | StoreKit 2 purchase flow |
|
||||
| `OSGKeyboardShared/DesignSystem/SupportDeveloperSection.swift` | iOS Settings UI |
|
||||
| `OSGKeyboardMac/MacSupportDeveloperTipRows.swift` | macOS Settings UI |
|
||||
| `OSGKeyboard.storekit` | Local StoreKit Test catalog |
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="36 367 952 291" fill="none" aria-hidden="true">
|
||||
<g fill="currentColor">
|
||||
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z"/>
|
||||
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z"/>
|
||||
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z"/>
|
||||
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z"/>
|
||||
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 157 KiB |
@@ -44,8 +44,8 @@ settings:
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "0.5.3"
|
||||
CURRENT_PROJECT_VERSION: "22"
|
||||
MARKETING_VERSION: "0.5.4"
|
||||
CURRENT_PROJECT_VERSION: "23"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
@@ -211,6 +211,7 @@ targets:
|
||||
embed: true
|
||||
- sdk: Speech.framework
|
||||
- sdk: ActivityKit.framework
|
||||
- sdk: StoreKit.framework
|
||||
|
||||
# =========================================================
|
||||
# Keyboard Extension
|
||||
@@ -349,6 +350,7 @@ targets:
|
||||
- sdk: Speech.framework
|
||||
- sdk: AVFoundation.framework
|
||||
- sdk: Charts.framework
|
||||
- sdk: StoreKit.framework
|
||||
|
||||
# =========================================================
|
||||
# 单元测试
|
||||
@@ -485,6 +487,7 @@ targets:
|
||||
- sdk: Speech.framework
|
||||
- sdk: AVFoundation.framework
|
||||
- sdk: Charts.framework
|
||||
- sdk: StoreKit.framework
|
||||
|
||||
schemes:
|
||||
OSGKeyboard:
|
||||
@@ -495,6 +498,7 @@ schemes:
|
||||
OSGKeyboardLiveActivity: all
|
||||
run:
|
||||
config: Debug
|
||||
storeKitConfiguration: OSGKeyboard.storekit
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
|
||||