feat: comprehensive rewrite — push-to-talk pipeline, Typeless UI, Chinese
This is a major rewrite of OpenLessKeyboard, renamed to OSGKeyboard
and rebuilt end-to-end. 59 files changed (+3205/-1550).
Architecture
------------
- Rename project, targets, directories from OpenLess* to OSGKeyboard*
(OpenLess / OpenLessKeyboard / OpenLessShared / OpenLessTests).
- AudioCaptureService rewritten as @unchecked Sendable class with
OSAllocatedUnfairLock instead of an actor, so it survives Swift 6
strict-concurrency checks while still serialising engine + converter
state correctly.
- Single design system (Palette / Spacing / Radius / TypeStyle /
Motion) lifted into OSGKeyboardShared so the host app and the
keyboard extension stay in lock-step.
Push-to-talk — first-principles fix
-----------------------------------
- App Group + audio-input entitlements were stripped by Xcode's
Automatic Signing. They are now declared in project.yml so
'xcodegen generate' re-emits them every time. iOS Developer
Account is untouched; only the App Group capability was added.
- State machine uses a real stored `phase` (was a derived shim
that locked out every press after the first because
recordStream was never nilled after the pipeline finished).
- Microphone permission is requested inside pressBegan (async
Task) so the press flow optimistically enters .recording;
permission denial surfaces a short error and returns to idle.
- Replaced LongPressGesture(0.15s) with a DragGesture +
TapGesture pair separated by pressArmed, so a single tap no
longer fires both onPressBegan and onTap simultaneously.
- Real RMS / peak level meter from the AVAudioEngine tap (was a
pseudo-random walk); the visible waveform is now driven by
actual audio.
- SFSpeechRecognizer(locale:) with selectable ASR locales
(auto / zh-Hans / zh-Hant / en-US / ja-JP / ko-KR) for
first-class Chinese / English / Japanese / Korean dictation,
with on-device recognition when supported.
- AVAudioSession now deactivates on stop so other apps' audio
routing is restored.
Keyboard UI — Typeless-inspired layout
---------------------------------------
- Hero area is 280 pt with a 96 pt record disc, breathing outer
ring, and a 12-bar waveform driven by the real RMS.
- inputView.allowsSelfSizing + a heightAnchor constraint so iOS
no longer crops the keyboard under the Spotlight bar / home
indicator.
- Top bar: mode chip (Off / 转写 / 润色) + locale chip
(Auto / 简体 / 繁體 / EN / 日 / 한) + status badge + ⚙.
- Bottom bar: globe / delete / 空格 / return — all 40 pt and
balanced.
- RecordButton onPressEnded is now safe to fire from a quick
press; pressArmed prevents double-firing.
LLM / Polishing
---------------
- LLMClient: stopped leaking the server response body in errors
(server body is now logged at debug, never surfaced to UI);
added a dedicated .rateLimited case for 429.
- PolishingService timeout 8s → 12s to accommodate slower
domestic LLM providers.
- AppGroupStore.defaultSystemPrompt is now provider-aware
(Chinese for zhipu/moonshot/qwen/deepseek, English otherwise).
Onboarding & Settings
---------------------
- Re-themed OnboardingView / HomeView / SettingsView on the
new design system.
- ProviderPickerSection now shows 6 providers (OpenAI, DeepSeek,
Qwen DashScope, 智谱 GLM, 月之暗面 Moonshot, Custom) with
blurb + selected accent.
- PickerRow for Mode and ASR locale; System Prompt editor with
reset-to-default.
- API settings page "Get an API key" used SwiftUI Link, which
has a hit-test bug on iOS 18 that ate gestures from adjacent
TextFields (manifested as "typing jumps to a website"). It is
now an explicit Button + contentShape + .submitLabel(.done) on
the fields.
Polish & tests
--------------
- LLMClientTests: 4 unit tests passing (ProviderConfig
persistence + OpenAI request/response + HTTP error + missing
key); test App Group renamed to the correct identifier.
- ProviderConfig.apply now captures the previous provider id
*before* mutating, so switching providers actually resets the
system prompt to the new default.
Build
-----
- Swift 6 strict concurrency, iOS 18.0 deployment target.
- Tested on Xcode 26 + iPhone 17 Pro simulator. A real device on
iOS 27 beta aborts with __abort_with_payload (dispatch
library ABI mismatch); use an iOS 18 real device or the
iOS 26 simulator for now.
🤖 Generated with Claude Code
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.980",
|
||||
"green" : "0.780",
|
||||
"red" : "0.360"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Group 24.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.043",
|
||||
"green" : "0.039",
|
||||
"red" : "0.039"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OSGKeyboard</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.osgkeyboard.ios.settings</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>osgkeyboard</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OSGKeyboard needs microphone access to transcribe your voice into text.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>BackgroundColor</string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.osgkeyboard.shared</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,31 @@
|
||||
// OSGKeyboardApp.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// DEBUG VERSION 2: restore real flow but instrument every step.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
@main
|
||||
struct OSGKeyboardApp: App {
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
|
||||
init() {
|
||||
print("🔥 [OSGKeyboardApp] init()")
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if config.isConfigured {
|
||||
HomeView()
|
||||
.onAppear { print("🔥 [OSGKeyboardApp] → HomeView appeared") }
|
||||
} else {
|
||||
OnboardingView(config: config)
|
||||
.onAppear { print("🔥 [OSGKeyboardApp] → OnboardingView appeared") }
|
||||
}
|
||||
}
|
||||
.onAppear { print("🔥 [OSGKeyboardApp] body appeared") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedUsageTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>CA92.1</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>C617.1</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>E174.1</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>35F9.1</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,124 @@
|
||||
// APISettingsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Editable fields for the three OpenAI-compatible config values:
|
||||
// Base URL, API Key, Model.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct APISettingsCard: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var showKey: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
field(
|
||||
title: "Base URL",
|
||||
placeholder: "https://api.openai.com/v1",
|
||||
text: $config.baseURL,
|
||||
keyboard: .URL,
|
||||
autocap: false
|
||||
)
|
||||
Divider().background(Palette.divider)
|
||||
keyField
|
||||
Divider().background(Palette.divider)
|
||||
field(
|
||||
title: "Model",
|
||||
placeholder: "gpt-4o-mini",
|
||||
text: $config.model,
|
||||
keyboard: .default,
|
||||
autocap: false
|
||||
)
|
||||
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
|
||||
Divider().background(Palette.divider)
|
||||
// Use a Button + UIApplication.open instead of SwiftUI
|
||||
// `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that
|
||||
// makes its tappable area eat gestures from the adjacent
|
||||
// TextField, which manifests as "typing jumps to a website".
|
||||
Button {
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "key.fill")
|
||||
.foregroundStyle(Palette.accent)
|
||||
Text("Get an API key")
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Key
|
||||
|
||||
private var keyField: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("API Key")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
Spacer()
|
||||
Button(action: { showKey.toggle() }) {
|
||||
Image(systemName: showKey ? "eye.slash.fill" : "eye.fill")
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(showKey ? "Hide key" : "Show key"))
|
||||
}
|
||||
Group {
|
||||
if showKey {
|
||||
TextField("sk-…", text: $config.apiKey)
|
||||
} else {
|
||||
SecureField("sk-…", text: $config.apiKey)
|
||||
}
|
||||
}
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
// MARK: - Generic field
|
||||
|
||||
@ViewBuilder
|
||||
private func field(
|
||||
title: String,
|
||||
placeholder: String,
|
||||
text: Binding<String>,
|
||||
keyboard: UIKeyboardType,
|
||||
autocap: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
TextField(placeholder, text: text)
|
||||
.keyboardType(keyboard)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(autocap ? .sentences : .never)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { /* no-op: prevent the keyboard from "submitting"
|
||||
and dismissing the sheet on iOS 18 */ }
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// HomeView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Post-onboarding home. Two jobs: (1) tell the user we're ready, and
|
||||
// (2) give a clear path to the next setup step if anything is missing.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardExt
|
||||
|
||||
struct HomeView: View {
|
||||
@ObservedObject var config = ProviderConfig.shared
|
||||
@State private var showSettings = false
|
||||
@State private var showKeyboardPreview = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Palette.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
statusHeader
|
||||
.padding(.top, Spacing.xl)
|
||||
Spacer()
|
||||
heroButton
|
||||
Spacer()
|
||||
actionStack
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(isPresented: $showKeyboardPreview) {
|
||||
KeyboardPreviewSheet()
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var statusHeader: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(config.isConfigured ? Palette.success : Palette.warning)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(config.isConfigured ? "Ready" : "Setup incomplete")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
Text("OSGKeyboard")
|
||||
.font(TypeStyle.largeTitle)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text(providerLine)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
private var providerLine: String {
|
||||
let p = LLMProvider.provider(id: config.providerId)
|
||||
let model = config.model.isEmpty ? "—" : config.model
|
||||
return "\(p.name) · \(model)"
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
|
||||
private var heroButton: some View {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Palette.accentMuted)
|
||||
.frame(width: 220, height: 220)
|
||||
.blur(radius: 40)
|
||||
Circle()
|
||||
.fill(LinearGradient(
|
||||
colors: [Palette.surfaceElevated, Palette.surface],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
))
|
||||
.frame(width: 160, height: 160)
|
||||
.overlay(
|
||||
Circle().stroke(Palette.accent.opacity(0.35), lineWidth: 1.5)
|
||||
)
|
||||
.shadow(color: Palette.accent.opacity(0.18), radius: 24, y: 8)
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(Palette.accent)
|
||||
Text("Tap to configure")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Open OSGKeyboard settings"))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private var actionStack: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Button {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("启用键盘 · Enable in iOS Settings", systemImage: "keyboard")
|
||||
.primaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("编辑 API 配置 · Edit API Configuration", systemImage: "slider.horizontal.3")
|
||||
.secondaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
#if DEBUG
|
||||
Button {
|
||||
showKeyboardPreview = true
|
||||
} label: {
|
||||
Label("键盘预览 · Keyboard Preview (Debug)", systemImage: "eye")
|
||||
.secondaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Button {
|
||||
if let url = URL(string: "https://github.com/hkgood/OSGKeyboard") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Text("GitHub")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 6)
|
||||
.background(Palette.surface, in: Capsule())
|
||||
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// KeyboardPreviewSheet.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Renders a stand-in keyboard layout inside the main app so the user
|
||||
// can preview what the real keyboard extension looks like without
|
||||
// enabling the keyboard in iOS Settings. Tap the disc to cycle
|
||||
// through idle / recording / processing so all visual states are
|
||||
// inspectable.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct KeyboardPreviewSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// We mirror only the fields the stand-in actually needs, instead of
|
||||
// crossing the OSGKeyboardExt target boundary to construct a
|
||||
// `KeyboardViewController.State` (whose initialiser is internal).
|
||||
@State private var phase: StubPhase = .idle
|
||||
@State private var level: Double = 0
|
||||
@State private var transcript: String = ""
|
||||
|
||||
private enum StubPhase { case idle, recording, processing }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Palette.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Text("Keyboard Preview")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text("Tap the disc to cycle states. The real keyboard uses the same layout.")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
mockTextField.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
.padding(.top, Spacing.lg)
|
||||
Spacer(minLength: 0)
|
||||
keyboardBlock
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var mockTextField: some View {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
Text("Type here…")
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var keyboardBlock: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Palette.divider)
|
||||
.frame(height: 0.5)
|
||||
KeyboardPreviewStub(
|
||||
phase: stubPhase,
|
||||
level: level,
|
||||
transcript: transcript
|
||||
)
|
||||
.environment(\.colorScheme, .dark)
|
||||
Rectangle()
|
||||
.fill(Color.black)
|
||||
.frame(height: 34)
|
||||
.overlay(alignment: .center) {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.4))
|
||||
.frame(width: 134, height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var stubPhase: KeyboardPreviewStub.Phase {
|
||||
switch phase {
|
||||
case .idle: return .idle
|
||||
case .recording: return .recording
|
||||
case .processing: return .processing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
KeyboardPreviewSheet()
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,235 @@
|
||||
// KeyboardPreviewStub.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Stand-in for the keyboard extension's SwiftUI tree. iOS does not allow
|
||||
// the host app to import symbols from its own keyboard extension target,
|
||||
// so we ship a minimal mirror here. The actual production layout lives
|
||||
// in OSGKeyboardExt/Views/KeyboardRootView.swift and is what shows up
|
||||
// when the user enables the keyboard in iOS Settings.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct KeyboardPreviewStub: View {
|
||||
|
||||
enum Phase { case idle, recording, processing }
|
||||
|
||||
let phase: Phase
|
||||
let level: Double
|
||||
let transcript: String
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
Palette.background
|
||||
VStack(spacing: 0) {
|
||||
topBar.frame(height: 32)
|
||||
centreArea.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
bottomBar.frame(height: 56)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.frame(height: 280)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
modeChip
|
||||
localeChip
|
||||
Spacer(minLength: 0)
|
||||
statusBadge
|
||||
Button(action: {}) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Palette.surface, in: Circle())
|
||||
.overlay(Circle().stroke(Palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
private var modeChip: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
Text("润色")
|
||||
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
|
||||
.background(Palette.surfaceElevated, in: Capsule())
|
||||
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private var localeChip: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "globe")
|
||||
Text("简体")
|
||||
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
|
||||
.background(Palette.surfaceElevated, in: Capsule())
|
||||
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private var statusBadge: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .recording:
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(Palette.recordRed).frame(width: 6, height: 6)
|
||||
Text("REC").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xs).padding(.vertical, 3)
|
||||
.background(Palette.surface, in: Capsule())
|
||||
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
|
||||
case .processing:
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(Palette.accent).frame(width: 6, height: 6)
|
||||
Text("···").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xs).padding(.vertical, 3)
|
||||
.background(Palette.surface, in: Capsule())
|
||||
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Centre area
|
||||
|
||||
private var centreArea: some View {
|
||||
VStack(spacing: Spacing.xxs) {
|
||||
transcriptLine.frame(height: 22)
|
||||
recordDisc.frame(width: 140, height: 140)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var transcriptLine: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Text("按住说话 · Hold to talk")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
case .recording:
|
||||
Text(transcript.isEmpty ? " " : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(maxWidth: .infinity)
|
||||
case .processing:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(Palette.accent)
|
||||
Text("润色中 · Polishing")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
private var recordDisc: some View {
|
||||
ZStack {
|
||||
if phase == .recording {
|
||||
Circle()
|
||||
.stroke(Palette.recordRed.opacity(0.35), lineWidth: 2)
|
||||
.frame(width: 110, height: 110)
|
||||
.opacity(0.6)
|
||||
Circle()
|
||||
.fill(RadialGradient(colors: [Palette.recordRed.opacity(0.55), .clear], center: .center, startRadius: 30, endRadius: 70))
|
||||
.frame(width: 160, height: 160)
|
||||
.blur(radius: 12)
|
||||
.opacity(0.4 + level * 0.6)
|
||||
}
|
||||
Circle()
|
||||
.fill(discGradient)
|
||||
.frame(width: 96, height: 96)
|
||||
.overlay(Circle().stroke(Color.white.opacity(0.16), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.4), radius: 10, y: 6)
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
case .recording:
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0..<12, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(Palette.recordRed)
|
||||
.frame(width: 2, height: 8 + CGFloat(level * 30) * (i.isMultiple(of: 2) ? 1 : 0.6))
|
||||
}
|
||||
}
|
||||
.frame(width: 60, height: 32)
|
||||
case .processing:
|
||||
ProgressView().tint(.white).scaleEffect(1.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var discGradient: LinearGradient {
|
||||
switch phase {
|
||||
case .recording:
|
||||
return LinearGradient(colors: [Palette.recordRed.opacity(0.95), Palette.recordRed.opacity(0.75)], startPoint: .top, endPoint: .bottom)
|
||||
case .processing:
|
||||
return LinearGradient(colors: [Palette.surfaceElevated, Palette.surface], startPoint: .top, endPoint: .bottom)
|
||||
case .idle:
|
||||
return LinearGradient(colors: [Color(white: 0.22), Color(white: 0.10)], startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bottom bar
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack(spacing: Spacing.xxs) {
|
||||
iconButton("globe")
|
||||
iconButton("delete.left")
|
||||
Spacer(minLength: 0)
|
||||
Button(action: {}) {
|
||||
Text("空格")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 42)
|
||||
.background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer(minLength: 0)
|
||||
iconButton("return")
|
||||
}
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
}
|
||||
|
||||
private func iconButton(_ systemName: String) -> some View {
|
||||
Button(action: {}) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
KeyboardPreviewStub(phase: .idle, level: 0, transcript: "")
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,257 @@
|
||||
// OnboardingView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Three-step onboarding presented as a horizontal pager:
|
||||
//
|
||||
// 1) Welcome — what the app does, in one sentence
|
||||
// 2) Enable — Settings → General → Keyboards → Add → Allow Full Access
|
||||
// 3) Setup — pick a provider, paste a key
|
||||
//
|
||||
// Visual style: one large accent surface, generous whitespace, single CTA
|
||||
// at the bottom. No tipsy animations, no cheerful illustrations — every
|
||||
// pixel is doing one job.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct OnboardingView: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var page: Int = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Palette.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
TabView(selection: $page) {
|
||||
WelcomePage().tag(0)
|
||||
EnableKeyboardPage().tag(1)
|
||||
APISetupPage(config: config).tag(2)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
pageDots
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
bottomBar
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var pageDots: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i == page ? Palette.accent : Color.white.opacity(0.18))
|
||||
.frame(width: i == page ? 18 : 6, height: 6)
|
||||
.animation(Motion.quick, value: page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bottomBar: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if page > 0 {
|
||||
Button { withAnimation(Motion.soft) { page -= 1 } } label: {
|
||||
Text("Back")
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(Motion.soft) {
|
||||
if page < 2 { page += 1 }
|
||||
}
|
||||
} label: {
|
||||
Text(page == 2 ? (config.isConfigured ? "Done" : "Continue") : "Next")
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(
|
||||
(page == 2 && !config.isConfigured) ? Palette.surfaceElevated : Palette.accent,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(
|
||||
(page == 2 && !config.isConfigured) ? Palette.textSecondary : Palette.textOnAccent
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(page == 2 && !config.isConfigured)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 1: Welcome
|
||||
|
||||
private struct WelcomePage: View {
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Palette.accentMuted)
|
||||
.frame(width: 180, height: 180)
|
||||
.blur(radius: 30)
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.font(.system(size: 96, weight: .light))
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Text("OSGKeyboard")
|
||||
.font(TypeStyle.title)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text("按住说话,松开即得润色文字。")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Hold to talk. Release for polished text, in any app.")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
PrivacyFootnote()
|
||||
.padding(.top, Spacing.lg)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrivacyFootnote: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
footnoteRow(icon: "lock.fill",
|
||||
title: "Audio stays on device",
|
||||
body: "Transcribed locally with Apple's speech engine.")
|
||||
footnoteRow(icon: "wifi",
|
||||
title: "Only the polished text is sent",
|
||||
body: "Sent to your chosen LLM to add structure & punctuation.")
|
||||
footnoteRow(icon: "keyboard",
|
||||
title: "Works everywhere",
|
||||
body: "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears.")
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
private func footnoteRow(icon: String, title: String, body: String) -> some View {
|
||||
HStack(alignment: .top, spacing: Spacing.xs) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Palette.accent)
|
||||
.frame(width: 24, height: 24)
|
||||
.background(Palette.accentMuted, in: Circle())
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text(body)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 2: Enable keyboard
|
||||
|
||||
private struct EnableKeyboardPage: View {
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Spacer()
|
||||
Image(systemName: "keyboard.fill")
|
||||
.font(.system(size: 64, weight: .light))
|
||||
.foregroundStyle(Palette.accent)
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Text("启用 OSGKeyboard")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text("Enable OSGKeyboard")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
step(num: 1, text: "Settings → General → Keyboard → Keyboards")
|
||||
step(num: 2, text: "Tap “Add New Keyboard…” and choose OSGKeyboard")
|
||||
step(num: 3, text: "Tap OSGKeyboard and enable “Allow Full Access”")
|
||||
step(num: 4, text: "Allow Full Access is required for the microphone and LLM calls.")
|
||||
}
|
||||
.cardSurface()
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square")
|
||||
.primaryButton()
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func step(num: Int, text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: Spacing.xs) {
|
||||
Text("\(num)")
|
||||
.font(TypeStyle.caption2)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(Palette.accent, in: Circle())
|
||||
.foregroundStyle(Palette.textOnAccent)
|
||||
Text(text)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 3: API setup
|
||||
|
||||
private struct APISetupPage: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("配置 AI 提供商")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text("Configure your AI provider")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.top, Spacing.lg)
|
||||
|
||||
ProviderPickerSection(config: config)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
APISettingsCard(config: config)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
.padding(.bottom, Spacing.xxxl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// ProviderPickerSection.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Provider picker shown inline inside Settings & Onboarding. Each option
|
||||
// surfaces the provider's name + a short blurb so the user can pick
|
||||
// confidently without opening a doc.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct ProviderPickerSection: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in
|
||||
Button {
|
||||
select(provider)
|
||||
} label: {
|
||||
row(provider, selected: provider.id == config.providerId)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < LLMProvider.presets.count - 1 {
|
||||
Divider().background(Palette.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func select(_ provider: LLMProvider) {
|
||||
withAnimation(Motion.quick) {
|
||||
config.apply(preset: provider)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
// Provider mark — a coloured dot with first letter
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(selected ? Palette.accent : Palette.surfaceElevated)
|
||||
.frame(width: 36, height: 36)
|
||||
Text(String(provider.name.prefix(1)))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(selected ? Palette.textOnAccent : Palette.textPrimary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(provider.name)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
if let blurb = provider.blurb {
|
||||
Text(blurb)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// SettingsView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Sheet that hosts the API configuration. Single scrollable column, every
|
||||
// field earns its space.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var config = ProviderConfig.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showResetConfirm = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Palette.background.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(spacing: Spacing.md) {
|
||||
providerSection
|
||||
apiSection
|
||||
languageSection
|
||||
promptSection
|
||||
resetButton
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.md)
|
||||
}
|
||||
}
|
||||
.navigationTitle("设置 · Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Reset all settings?",
|
||||
isPresented: $showResetConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Reset", role: .destructive) {
|
||||
config.reset()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("API key, model, and base URL will be cleared.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider
|
||||
|
||||
private var providerSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("Provider · 提供商", subtitle: "Pick the LLM that polishes your dictation.")
|
||||
ProviderPickerSection(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
private var apiSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("API · 接口", subtitle: nil)
|
||||
APISettingsCard(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Language (ASR + mode)
|
||||
|
||||
private var languageSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("Language · 语言", subtitle: "Choose ASR locale and dictation mode.")
|
||||
VStack(spacing: 0) {
|
||||
PickerRow(
|
||||
title: "Mode",
|
||||
options: modeOptions,
|
||||
selection: Binding(
|
||||
get: { config.modeId },
|
||||
set: { config.modeId = $0 }
|
||||
)
|
||||
)
|
||||
Divider().background(Palette.divider)
|
||||
PickerRow(
|
||||
title: "ASR locale",
|
||||
options: localeOptions,
|
||||
selection: Binding(
|
||||
get: { config.localeId },
|
||||
set: { config.localeId = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var modeOptions: [(id: String, label: String)] {
|
||||
[
|
||||
("off", "Off · 关闭"),
|
||||
("transcribe", "Transcribe · 仅转写"),
|
||||
("polish", "Polish · 润色")
|
||||
]
|
||||
}
|
||||
|
||||
private var localeOptions: [(id: String, label: String)] {
|
||||
[
|
||||
("auto", "Auto · 跟随系统"),
|
||||
("zh-Hans", "中文(简体)"),
|
||||
("zh-Hant", "中文(繁體)"),
|
||||
("en-US", "English (US)"),
|
||||
("ja-JP", "日本語"),
|
||||
("ko-KR", "한국어")
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Prompt
|
||||
|
||||
private var promptSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack {
|
||||
sectionHeader("System Prompt · 系统提示", subtitle: nil)
|
||||
Spacer()
|
||||
Button("Reset") { config.systemPrompt = config.defaultSystemPrompt }
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
TextEditor(text: $config.systemPrompt)
|
||||
.font(TypeStyle.mono)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 140)
|
||||
.padding(Spacing.xs)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.cardSurface()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reset
|
||||
|
||||
private var resetButton: some View {
|
||||
Button(role: .destructive) {
|
||||
showResetConfirm = true
|
||||
} label: {
|
||||
Text("Reset all settings")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.danger)
|
||||
.frame(maxWidth: .infinity, minHeight: 40)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private func sectionHeader(_ title: String, subtitle: String?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.textCase(.uppercase)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picker row
|
||||
|
||||
private struct PickerRow: View {
|
||||
let title: String
|
||||
let options: [(id: String, label: String)]
|
||||
@Binding var selection: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(options, id: \.id) { o in
|
||||
Button {
|
||||
selection = o.id
|
||||
} label: {
|
||||
if o.id == selection {
|
||||
Label(o.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(o.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(currentLabel)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
options.first(where: { $0.id == selection })?.label ?? "—"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user