feat(keyboard): improve typing, voice flow, and polish reliability

Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
This commit is contained in:
Rocky
2026-08-05 21:39:31 +08:00
parent 38e5ad570d
commit 31f5937a7f
177 changed files with 8343 additions and 3904 deletions
@@ -1,158 +0,0 @@
// SevenDayUsageChart.swift
// OSGKeyboard · Shared
//
// 7-day dictation bar chart. Platform shells wrap this in their own page
// layout; the chart itself only needs points + UI language.
import Charts
import SwiftUI
public struct SevenDayUsageChart: View {
@Environment(\.themePalette) private var palette
public let points: [UsageStatisticsStore.DailyUsagePoint]
public let language: AppUILanguage
/// Bar area height. Phone stacked layout uses a shorter value.
public var chartMinHeight: CGFloat
/// When true, wrap content in `UsageSurfaceCard` (default). Pass false if
/// the caller already provides a surface.
public var embedsInCard: Bool
/// When true (iPad split), the chart fills all available height. When false
/// (phone stacked home), the bar area is a fixed `chartMinHeight` so the card
/// stays compact and does not steal space from surrounding content.
public var expands: Bool
public init(
points: [UsageStatisticsStore.DailyUsagePoint],
language: AppUILanguage,
chartMinHeight: CGFloat = 96,
embedsInCard: Bool = true,
expands: Bool = true
) {
self.points = points
self.language = language
self.chartMinHeight = chartMinHeight
self.embedsInCard = embedsInCard
self.expands = expands
}
private var total: Int {
points.reduce(0) { $0 + $1.value }
}
private var maxValue: Int {
max(points.map(\.value).max() ?? 0, 1)
}
private var chartLocale: Locale {
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 {
UsageSurfaceCard(padding: Spacing.md) {
chartContent
}
} else {
chartContent
}
}
}
private var chartContent: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
header
chart
.frame(maxWidth: .infinity, maxHeight: expands ? .infinity : nil)
}
.frame(
maxWidth: .infinity,
maxHeight: expands ? .infinity : nil,
alignment: .topLeading
)
}
// MARK: - Header
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text(SharedL10n.string("stat.weekChart.title", language: language).uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Text(SharedL10n.string("stat.weekChart.caption", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Spacer(minLength: Spacing.sm)
Text(UsageStatisticsStore.formatCount(total, language: language))
.font(TypeStyle.title2)
.foregroundStyle(palette.accent)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: total)
}
}
// MARK: - Bars
@ViewBuilder
private var chart: some View {
if total == 0 {
Text(SharedL10n.string("stat.weekChart.empty", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, maxHeight: expands ? .infinity : nil, alignment: .center)
.multilineTextAlignment(.center)
.frame(height: expands ? nil : chartMinHeight)
.frame(minHeight: expands ? chartMinHeight : nil)
} else {
Chart(points) { point in
BarMark(
x: .value("day", point.date, unit: .day),
y: .value("chars", point.value)
)
.cornerRadius(4)
.foregroundStyle(palette.accent.gradient)
}
.chartYScale(domain: 0...max(1, Int(ceil(Double(maxValue) * 1.15))))
.chartYAxis(.hidden)
.chartXAxis {
// 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)
.frame(height: expands ? nil : chartMinHeight)
.frame(minHeight: expands ? chartMinHeight : nil)
}
}
}
@@ -1,143 +0,0 @@
// 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 {
CardSection(title: SharedL10n.string("tip.title", language: language)) {
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)
.surfaceCard()
}
.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)
}
}
@@ -1,196 +0,0 @@
// UsageStatsCluster.swift
// OSGKeyboard · Shared
//
// Cross-platform home / dashboard stats: 7-day chart + cumulative metrics.
// Callers observe their store and pass plain values Shared stays unbound
// from platform singletons.
import SwiftUI
public struct UsageStatsCluster: View {
@Environment(\.themePalette) private var palette
public enum Layout: Sendable, Equatable {
/// Chart left, 2×2 `UsageStatCard` grid right (Mac / iPad).
case split
/// Chart above a compact single-card 2×2 grid (iPhone).
case stacked
}
/// 2×2 沿 HomeStatsCard
static let compactGridHeight: CGFloat = 166
public let layout: Layout
public let language: AppUILanguage
public let points: [UsageStatisticsStore.DailyUsagePoint]
public let dictationCharacterCount: Int
public let dictationDurationSeconds: TimeInterval
public let translationCharacterCount: Int
public let dictionaryTermCount: Int
/// iPhone SE stacked
public let compact: Bool
public init(
layout: Layout,
language: AppUILanguage,
points: [UsageStatisticsStore.DailyUsagePoint],
dictationCharacterCount: Int,
dictationDurationSeconds: TimeInterval,
translationCharacterCount: Int,
dictionaryTermCount: Int,
compact: Bool = false
) {
self.layout = layout
self.language = language
self.points = points
self.dictationCharacterCount = dictationCharacterCount
self.dictationDurationSeconds = dictationDurationSeconds
self.translationCharacterCount = translationCharacterCount
self.dictionaryTermCount = dictionaryTermCount
self.compact = compact
}
public var body: some View {
switch layout {
case .split:
splitBody
case .stacked:
stackedBody
}
}
// MARK: - Split (Mac / iPad)
private var splitBody: some View {
HStack(alignment: .top, spacing: Spacing.md) {
SevenDayUsageChart(points: points, language: language)
.frame(maxWidth: .infinity, maxHeight: .infinity)
splitStatGrid
.frame(maxWidth: .infinity)
}
.fixedSize(horizontal: false, vertical: true)
}
private var splitStatGrid: some View {
VStack(spacing: Spacing.md) {
HStack(spacing: Spacing.md) {
UsageStatCard(
title: SharedL10n.string("stat.words", language: language),
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
caption: SharedL10n.string("stat.transcribed", language: language),
systemImage: "text.alignleft",
accent: true
)
UsageStatCard(
title: SharedL10n.string("stat.dictationTime", language: language),
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
caption: SharedL10n.string("stat.cumulativeDuration", language: language),
systemImage: "waveform"
)
}
HStack(spacing: Spacing.md) {
UsageStatCard(
title: SharedL10n.string("stat.translation", language: language),
value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language),
caption: SharedL10n.string("stat.cumulativeTranslation", language: language),
systemImage: "character.bubble"
)
UsageStatCard(
title: SharedL10n.string("stat.dictionary", language: language),
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language),
caption: SharedL10n.string("stat.customTerms", language: language),
systemImage: "character.book.closed"
)
}
}
}
// MARK: - Stacked (iPhone)
private var stackedBody: some View {
VStack(spacing: compact ? Spacing.sm : Spacing.md) {
SevenDayUsageChart(
points: points,
language: language,
chartMinHeight: compact ? 72 : 96,
expands: false
)
compactStatGrid
}
}
/// Phone-friendly 2×2: value + label only, single card with hairline dividers.
private var compactStatGrid: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
compactCell(
systemImage: "waveform",
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
label: SharedL10n.string("stat.dictationTime", language: language)
)
compactDivider
compactCell(
systemImage: "text.alignleft",
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
label: SharedL10n.string("stat.words", language: language)
)
}
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
HStack(spacing: 0) {
compactCell(
systemImage: "character.bubble",
value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language),
label: SharedL10n.string("stat.translation", language: language)
)
compactDivider
compactCell(
systemImage: "character.book.closed",
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language),
label: SharedL10n.string("stat.dictionary", language: language)
)
}
}
// HomeStatsCard 166pt
.frame(height: UsageStatsCluster.compactGridHeight)
.background(palette.surface)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private var compactDivider: some View {
Rectangle()
.fill(palette.divider)
.frame(width: 0.5)
}
private func compactCell(systemImage: String, value: String, label: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(value)
.font(.system(size: 24, weight: .semibold, design: .rounded))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
Text(label)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Spacer(minLength: Spacing.xs)
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.accent)
.padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
}
}