feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding
- Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3),
global Option hotkey, and bottom overlay
- Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with
provider logos, model listing, and connection checks
- Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart)
- Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model
diagnostic toggle behind DEBUG
- Unify iOS onboarding tagline with the macOS brand line ("开口即文字。")
- Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// 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())
|
||||
}
|
||||
|
||||
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 {
|
||||
AxisMarks(values: points.map(\.date)) { _ in
|
||||
AxisValueLabel(format: .dateTime.weekday(.narrow))
|
||||
}
|
||||
}
|
||||
.environment(\.locale, chartLocale)
|
||||
.frame(height: expands ? nil : chartMinHeight)
|
||||
.frame(minHeight: expands ? chartMinHeight : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,10 +162,13 @@ public enum Radius {
|
||||
// MARK: - Typography
|
||||
|
||||
public enum SettingsListMetrics {
|
||||
/// Single-line list rows (provider, footer link, picker).
|
||||
public static let singleLineMinHeight: CGFloat = 52
|
||||
/// Two-line rows (engine option, labeled API field).
|
||||
public static let doubleLineMinHeight: CGFloat = 72
|
||||
/// Floor for settings list rows (slightly above HIG 44pt).
|
||||
/// Row height grows with content; this only enforces a touch-target minimum.
|
||||
public static let singleLineMinHeight: CGFloat = 48
|
||||
/// Horizontal inset inside a settings list row.
|
||||
public static let rowHorizontalPadding: CGFloat = Spacing.md
|
||||
/// Vertical inset inside a settings list row (`Spacing.sm`).
|
||||
public static let rowVerticalPadding: CGFloat = 12
|
||||
/// Space between a section label and its card.
|
||||
public static let sectionLabelSpacing: CGFloat = Spacing.sm
|
||||
}
|
||||
@@ -263,6 +266,19 @@ private struct SecondaryButtonModifier: ViewModifier {
|
||||
}
|
||||
|
||||
public extension View {
|
||||
/// Standard settings list row insets: horizontal + vertical padding and a
|
||||
/// touch-target floor. Height grows with content — do not hand-roll
|
||||
/// per-section padding/`minHeight` for ordinary settings rows.
|
||||
func settingsListRow(
|
||||
minHeight: CGFloat = SettingsListMetrics.singleLineMinHeight,
|
||||
alignment: Alignment = .center
|
||||
) -> some View {
|
||||
self
|
||||
.padding(.horizontal, SettingsListMetrics.rowHorizontalPadding)
|
||||
.padding(.vertical, SettingsListMetrics.rowVerticalPadding)
|
||||
.frame(minHeight: minHeight, alignment: alignment)
|
||||
}
|
||||
|
||||
/// Standard card surface used in the main app.
|
||||
func cardSurface(padding: CGFloat = Spacing.md) -> some View {
|
||||
modifier(CardSurfaceModifier(padding: padding))
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// UsageStatCard.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single cumulative metric tile. Compact = title / value / caption stack;
|
||||
// prominent = horizontal hero bar for the primary word-count metric.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct UsageStatCard: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
public let title: String
|
||||
public let value: String
|
||||
public let caption: String
|
||||
public var systemImage: String?
|
||||
public var accent: Bool
|
||||
/// Hero metric: wide horizontal layout for the primary word count.
|
||||
public var prominent: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
value: String,
|
||||
caption: String,
|
||||
systemImage: String? = nil,
|
||||
accent: Bool = false,
|
||||
prominent: Bool = false
|
||||
) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.caption = caption
|
||||
self.systemImage = systemImage
|
||||
self.accent = accent
|
||||
self.prominent = prominent
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
UsageSurfaceCard(padding: Spacing.md) {
|
||||
if prominent {
|
||||
prominentBody
|
||||
} else {
|
||||
compactBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactBody: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer()
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
Text(value)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// Wide "hero bar": icon badge + title/caption left, big number right.
|
||||
private var prominentBody: some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
if let systemImage {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: Spacing.md)
|
||||
Text(value)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// UsageSurfaceCard.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Flat semantic surface used by home / dashboard stats on every platform.
|
||||
// Deliberately shadowless — hierarchy comes from fill + hairline border.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct UsageSurfaceCard<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
public var padding: CGFloat
|
||||
public var cornerRadius: CGFloat
|
||||
@ViewBuilder public var content: () -> Content
|
||||
|
||||
public init(
|
||||
padding: CGFloat = Spacing.md,
|
||||
cornerRadius: CGFloat = Radius.medium,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.padding = padding
|
||||
self.cornerRadius = cornerRadius
|
||||
self.content = content
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user