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:
Rocky
2026-07-12 11:00:57 +08:00
parent 951e9514d7
commit 166019b384
51 changed files with 1938 additions and 518 deletions
@@ -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)
}
}