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:
Rocky
2026-07-11 19:10:20 +08:00
parent cdf833935a
commit cc8dd1070a
116 changed files with 6659 additions and 2634 deletions
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "anthropic.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "ark.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Volcengine</title><path d="M7.29 5.36L3.148 21.737a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.359a.214.214 0 00-.41 0z" fill-opacity=".5"></path><path clip-rule="evenodd" d="M4.553 16.18l-1.406 5.558a.214.214 0 00.203.261h2.42-4.551a.214.214 0 01-.214-.26l2.275-8.961a.214.214 0 01.409 0l.864 3.402z"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.214.214 0 00.215-.261L14.44.151z" fill-opacity=".5"></path><path clip-rule="evenodd" d="M16.694 22h3.207a.215.215 0 00.214-.262l-1.839-6.993 1.164-4.592a.214.214 0 01.411 0l2.951 11.586a.214.214 0 01-.214.261h-5.894z"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.216.216 0 00.214-.166.216.216 0 000-.098L10.687 7.742a.214.214 0 00-.409 0z"></path></svg>

After

Width:  |  Height:  |  Size: 944 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "codingplanx.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 6.5 3 12l5.5 5.5 1.4-1.4L5.8 12l4.1-4.1-1.4-1.4Zm7 0-1.4 1.4L18.2 12l-4.1 4.1 1.4 1.4L21 12l-5.5-5.5Z"/></svg>

After

Width:  |  Height:  |  Size: 205 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "cometapi.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>CometAPI</title><path d="M7.754 3.248C9.483.97 12.144-.223 14.99.035c4.67.422 8.023 4.694 7.27 9.384-.266 1.667-1 3.125-2.203 4.374-.468.487-1.025.9-1.662 1.422-2.554 2.09-6.026 4.854-10.413 8.294-.224.176-.669.495-.94.49a.19.19 0 01-.137-.06.192.192 0 01-.05-.14c.01-.207.077-.473.202-.8.04-.108.44-.956 1.197-2.545a1.99 1.99 0 00.179-.577.143.143 0 00-.007-.068.142.142 0 00-.098-.09.144.144 0 00-.07 0 1.479 1.479 0 00-.505.237c-.414.288-.86.648-1.337 1.078-.506.453-1.137 1.025-1.895 1.716a8.873 8.873 0 01-1.252.977.155.155 0 01-.064.021.152.152 0 01-.123-.04.154.154 0 01-.037-.055c-.027-.067-.024-.165.01-.292.113-.423.283-.902.511-1.437.17-.396.52-1.206 1.051-2.428.17-.39.697-1.592.61-1.897a.167.167 0 00-.102-.111.166.166 0 00-.15.018c-.284.194-.593.485-.93.87-.782.895-1.569 1.78-2.358 2.657-.248.274-.477.388-.687.343v-.238c.058-.215.104-.438.178-.642C4.075 12.378 5.938 7.2 6.764 4.964c.198-.537.529-1.11.99-1.716zm6.49-1.771a6.641 6.641 0 100 13.283 6.641 6.641 0 000-13.283z"></path><path d="M14.244 3.104a5.017 5.017 0 11-.002 10.033 5.017 5.017 0 01.002-10.033zm2.049 1.695a3.087 3.087 0 00-4.363 1.187 1.583 1.583 0 00-.165.442c-.015.067-.027.13-.033.194a1.308 1.308 0 00.078.56c.025.07.056.137.091.203.287.529.884.944 1.43 1.288.135.086.269.167.393.245l.392.246c.343.212.72.43 1.102.568.305.112.613.173.908.142a1.34 1.34 0 00.535-.178c.103-.061.202-.14.298-.237.064-.065.127-.137.186-.22a3.133 3.133 0 00.445-.868 3.08 3.08 0 00.056-1.71A3.063 3.063 0 0016.293 4.8z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "gemini.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"></path></svg>

After

Width:  |  Height:  |  Size: 651 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "groq.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Groq</title><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>

After

Width:  |  Height:  |  Size: 568 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "minimax.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mistral.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path clip-rule="evenodd" d="M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z"></path></svg>

After

Width:  |  Height:  |  Size: 418 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "openrouter.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z"/></svg>

After

Width:  |  Height:  |  Size: 704 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "siliconflow.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z"></path></svg>

After

Width:  |  Height:  |  Size: 525 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "xai.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"></path></svg>

After

Width:  |  Height:  |  Size: 372 B

+48 -9
View File
@@ -138,21 +138,43 @@ final class FlowSessionManager: ObservableObject {
return
}
reconcilePersistedFlowStateBeforeStart()
// Healthy / busy sessions must not flash the cold-start overlay when a
// spurious `startflow` arrives (e.g. keyboard finalize race).
if coldStart, isActive {
extendSession(duration: duration)
refreshHostReady()
let decision = FlowHandoffPolicy.coldStartOverlayDecision(
sessionIsActive: true,
hostIsReady: FlowSessionBridge.isHostReady(),
isUtteranceBusy: isUtteranceRecording || isUtteranceProcessing
)
switch decision {
case .silence:
traceState(
"startSession.coldStart.silenced",
extra: FlowSessionBridge.isHostReady() ? "reason=alreadyReady" : "reason=busy"
)
return
case .present:
isColdStartHandoff = true
showColdStartPreparing()
Task { @MainActor [weak self] in
await self?.prepareExistingSessionForColdStartReturn()
}
return
}
}
if coldStart {
isColdStartHandoff = true
showColdStartPreparing()
}
reconcilePersistedFlowStateBeforeStart()
if isActive {
extendSession(duration: duration)
refreshHostReady()
if coldStart {
Task { @MainActor [weak self] in
await self?.prepareExistingSessionForColdStartReturn()
}
}
return
}
@@ -1354,8 +1376,9 @@ final class FlowSessionManager: ObservableObject {
if Task.isCancelled {
throw CancellationError()
}
let polished = try await polisher.polish(
text,
let polished = try await Self.polishWithHostTimeout(
polisher: polisher,
text: text,
mode: polishMode,
providerIdOverride: pipelineStore.polishProviderIdOverride
)
@@ -1542,6 +1565,22 @@ final class FlowSessionManager: ObservableObject {
return FlowSessionKeys.cloudASRWaitTimeout
}
/// Host-level polish cap does not wait for a cancelled LLM task to unwind.
private static func polishWithHostTimeout(
polisher: PolishingService,
text: String,
mode: PolishingService.PolishMode,
providerIdOverride: String?
) async throws -> String {
try await HardTimeout.run(seconds: FlowSessionKeys.maxPolishTimeout) {
try await polisher.polish(
text,
mode: mode,
providerIdOverride: providerIdOverride
)
}
}
// MARK: - Level publishing (main thread only)
private func startLevelPublishing() {
@@ -4,12 +4,10 @@
// Single source of truth for third-party open-source components shipped
// with OSGKeyboard. Consumed by Settings About Third-Party Licenses.
//
// Keep this list aligned with `project.yml` package dependencies.
// Keep this list aligned with bundled resources and runtime downloads.
//
// v0.2.0: dropped the `Qwen3Speech` SPM fork (Qwen3 CoreML ASR is gone)
// and the `aufklarer/Qwen3-ASR-CoreML` runtime artefact. The local
// engine now ships with iOS 26 `SpeechAnalyzer` + `DictationTranscriber`
// and has no on-device ML dependencies of our own.
// iOS targets remain zero-SPM. macOS local ASR downloads the sherpa-onnx
// runtime binary on demand; model weights are cached under Application Support.
import Foundation
@@ -26,19 +24,24 @@ enum OpenSourceLicenseCatalog {
let licenseText: String
}
/// Bundled libraries referenced by the app. v0.2.0 no longer pulls in
/// `soniqo/speech-swift` (we use iOS 26 `SpeechAnalyzer` instead) and
/// no longer downloads `Qwen3-ASR-CoreML` weights both entries are
/// intentionally absent.
/// Bundled libraries and runtime components referenced by the app.
static let entries: [Entry] = [
.init(
id: "material-icons",
name: "Google Material Icons",
licenseName: "Apache-2.0",
purpose: "MaterialIcons-Regular.ttf bundled for Settings and navigation iconography.",
purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.",
url: URL(string: "https://github.com/google/material-design-icons"),
licenseText: apache2Text
),
.init(
id: "sherpa-onnx",
name: "sherpa-onnx",
licenseName: "Apache-2.0",
purpose: "macOS local ASR runtime (`sherpa-onnx-offline`) downloaded at install time and cached on device.",
url: URL(string: "https://github.com/k2-fsa/sherpa-onnx"),
licenseText: apache2Text
),
]
// MARK: - License bodies
@@ -60,30 +63,4 @@ enum OpenSourceLicenseCatalog {
implied. See the License for the specific language governing
permissions and limitations under the License.
"""
static let mitText = """
MIT License
Copyright (c) 2023 Apple Inc.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
}
}
+60 -189
View File
@@ -11,215 +11,86 @@ struct APISettingsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var showKey: Bool = false
@State private var testStatus: TestStatus = .idle
private enum TestStatus: Equatable {
case idle
case running
case success
case failure(String)
}
/// `false`
var showsSurface: Bool = true
var body: some View {
VStack(spacing: 0) {
field(
SettingsCredentialRow(
title: AppL10n.string("api.key"),
placeholder: "sk-…",
text: $config.apiKey,
isSecret: true,
isMonospaced: true
)
rowDivider
SettingsCredentialRow(
title: AppL10n.string("api.baseUrl"),
placeholder: "https://api.openai.com/v1",
placeholder: LLMProvider.provider(id: config.providerId).defaultBaseURL,
text: $config.baseURL,
autocap: false
isMonospaced: true
)
Divider().background(palette.divider)
keyField
Divider().background(palette.divider)
field(
rowDivider
SettingsModelPickerRow(
title: AppL10n.string("api.model"),
placeholder: "gpt-4o-mini",
text: $config.model,
autocap: false
placeholder: LLMProvider.provider(id: config.providerId).defaultModel,
model: $config.model,
fetchModels: fetchModels
)
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
Divider().background(palette.divider)
// Use a Button + UIApplication.open instead of SwiftUI
// `Link`. SwiftUI `Link` can have hit-test quirks on some
// 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 {
Text("api.getKey")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Divider().background(palette.divider)
testConnectionRow
.id(config.providerId)
rowDivider
thinkingRow
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
// 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
? AppL10n.string("api.key.hide")
: AppL10n.string("api.key.show")))
}
Group {
if showKey {
TextField("sk-…", text: $config.apiKey)
} else {
SecureField("sk-…", text: $config.apiKey)
}
}
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
private var rowDivider: some View {
Divider().background(palette.divider)
}
// MARK: - Generic field
@ViewBuilder
private func field(
title: String,
placeholder: String,
text: Binding<String>,
autocap: Bool
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
TextField(placeholder, text: text)
.keyboardType(.asciiCapable)
.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 unexpectedly */ }
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
}
// MARK: - Test connection
private var testConnectionRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("api.connection")
/// Two-line thinking toggle opt-in for slower, higher-quality polish.
private var thinkingRow: some View {
HStack(alignment: .center, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(AppL10n.string("settings.provider.thinking"))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Button(action: runTest) {
Group {
if testStatus == .running {
ProgressView().controlSize(.mini)
} else {
Text(testButtonLabel)
.font(TypeStyle.body)
.foregroundStyle(testTint)
}
}
}
.buttonStyle(.plain)
.disabled(testStatus == .running)
}
if let detail = testDetail {
Text(detail)
.fixedSize(horizontal: false, vertical: true)
Text(AppL10n.string("settings.provider.thinkingSubtitle"))
.font(TypeStyle.caption2)
.foregroundStyle(testTint)
.lineLimit(3)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
Toggle("", isOn: $config.llmThinkingEnabled)
.labelsHidden()
.tint(palette.accent)
.accessibilityLabel(AppL10n.string("settings.provider.thinking"))
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
.settingsListRow()
}
private var testButtonLabel: String {
switch testStatus {
case .idle: return AppL10n.string("api.test.idle")
case .running: return AppL10n.string("api.test.running")
case .success: return AppL10n.string("api.test.success")
case .failure: return AppL10n.string("api.test.failure")
}
}
private var testTint: Color {
switch testStatus {
case .idle, .running: return palette.accent
case .success: return palette.accent
case .failure: return palette.danger
}
}
private var testDetail: String? {
switch testStatus {
case .idle, .running, .success: return nil
case .failure(let message): return message
}
}
private func runTest() {
testStatus = .running
let store = AppGroupStore()
let client = OpenAICompatibleClient(
baseURL: store.baseURL,
apiKey: store.apiKey,
model: store.model
private func validateConnection() async throws {
// Use on-screen config not a fresh AppGroupStore so a just-typed
// key is visible even if Keychain write is still settling.
let client = LLMClientFactory.make(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
thinkingEnabled: config.llmThinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
private func fetchModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
currentModel: config.model
)
Task {
do {
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
testStatus = .success
} catch LLMError.noAPIKey {
testStatus = .failure(AppL10n.string("api.test.missing"))
} catch let error as LLMError {
switch error {
case .http(let status):
testStatus = .failure(AppL10n.format("api.test.http", status))
case .rateLimited:
testStatus = .failure(AppL10n.string("api.test.rateLimited"))
case .transport(let msg):
testStatus = .failure(AppL10n.format("api.test.transportWith", msg))
default:
testStatus = .failure(error.errorDescription ?? "\(error)")
}
} catch {
testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)")
}
}
}
}
+104 -168
View File
@@ -10,195 +10,131 @@ struct ASRSettingsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var showKey: Bool = false
@State private var testStatus: TestStatus = .idle
private enum TestStatus: Equatable {
case idle
case running
case success
case failure(String)
}
/// `false`
var showsSurface: Bool = true
var body: some View {
VStack(spacing: 0) {
if CloudASRModelCatalog.strategy(for: config.asrProviderId) == .prompt {
field(
title: AppL10n.string("api.baseUrl"),
placeholder: "https://api.openai.com/v1",
text: $config.asrBaseURL,
autocap: false
)
Divider().background(palette.divider)
if config.asrProviderId == "volcengine" {
volcengineRows
} else {
genericRows
}
keyField
Divider().background(palette.divider)
field(
title: AppL10n.string("settings.asr.model"),
placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId),
text: $config.asrModel,
autocap: false
)
if let url = LLMProvider.provider(id: config.asrProviderId).apiKeyURL {
Divider().background(palette.divider)
Button {
UIApplication.shared.open(url)
} label: {
HStack {
Text("api.getKey")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Divider().background(palette.divider)
testConnectionRow
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
}
.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 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)
}
Group {
if showKey {
TextField("sk-…", text: $config.asrApiKey)
} else {
SecureField("sk-…", text: $config.asrApiKey)
}
}
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
@ViewBuilder
private func field(
title: String,
placeholder: String,
text: Binding<String>,
autocap: Bool
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
TextField(placeholder, text: text)
.keyboardType(.asciiCapable)
.autocorrectionDisabled(true)
.textInputAutocapitalization(autocap ? .sentences : .never)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
private var genericRows: some View {
if CloudASRModelCatalog.showsASREndpointField(for: config.asrProviderId) {
SettingsCredentialRow(
title: AppL10n.string("api.baseUrl"),
placeholder: LLMProvider.provider(id: config.asrProviderId).defaultBaseURL,
text: $config.asrBaseURL,
isMonospaced: true
)
rowDivider
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
SettingsCredentialRow(
title: AppL10n.string("api.key"),
placeholder: "sk-…",
text: $config.asrApiKey,
isSecret: true,
isMonospaced: true
)
rowDivider
SettingsModelPickerRow(
title: AppL10n.string("settings.asr.model"),
placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId),
model: $config.asrModel,
fetchModels: fetchModels
)
.id(config.asrProviderId)
}
private var testConnectionRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("settings.asr.testConnection")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Button(action: runTest) {
Group {
if testStatus == .running {
ProgressView().controlSize(.mini)
} else {
Text(testButtonLabel)
.font(TypeStyle.body)
.foregroundStyle(testTint)
}
}
}
.buttonStyle(.plain)
.disabled(testStatus == .running)
}
if let detail = testDetail {
Text(detail)
.font(TypeStyle.caption2)
.foregroundStyle(testTint)
.lineLimit(3)
private var volcengineRows: some View {
Group {
SettingsCredentialRow(
title: AppL10n.string("settings.asr.volcengine.appId"),
placeholder: "APP ID",
text: Binding(
get: { volcengineFields.appID },
set: { updateVolcengine(appID: $0) }
),
isSecret: true,
isMonospaced: true
)
rowDivider
SettingsCredentialRow(
title: AppL10n.string("settings.asr.volcengine.accessToken"),
placeholder: "Access Token",
text: Binding(
get: { volcengineFields.accessToken },
set: { updateVolcengine(accessToken: $0) }
),
isSecret: true,
isMonospaced: true
)
rowDivider
SettingsCredentialRow(
title: AppL10n.string("settings.asr.volcengine.resourceId"),
placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
text: Binding(
get: { volcengineFields.resourceID },
set: { updateVolcengine(resourceID: $0) }
),
isMonospaced: true,
defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
)
rowDivider
SettingsProviderRow(title: AppL10n.string("settings.provider.note")) {
Text("settings.asr.volcengine.note")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
}
private var testButtonLabel: String {
switch testStatus {
case .idle: return AppL10n.string("api.test.idle")
case .running: return AppL10n.string("api.test.running")
case .success: return AppL10n.string("api.test.success")
case .failure: return AppL10n.string("api.test.failure")
}
private var rowDivider: some View {
Divider().background(palette.divider)
}
private var testTint: Color {
switch testStatus {
case .idle, .running: return palette.accent
case .success: return palette.accent
case .failure: return palette.danger
}
private var volcengineFields: VolcengineASRFields {
VolcengineASRFields.parse(
apiKey: config.asrApiKey,
resourceFallback: config.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: "volcengine")
: config.asrModel
)
}
private var testDetail: String? {
switch testStatus {
case .idle, .running, .success: return nil
case .failure(let message): return message
private func updateVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
var fields = volcengineFields
if let appID { fields.appID = appID }
if let accessToken { fields.accessToken = accessToken }
if let resourceID {
fields.resourceID = resourceID
config.asrModel = resourceID
}
config.asrApiKey = fields.encodedAPIKey
}
private func runTest() {
testStatus = .running
let store = AppGroupStore()
let client = CloudASRClientFactory.make(store: store)
Task {
do {
try await client.prepare(dictionary: store.personalDictionary)
let samples = [Float](repeating: 0.01, count: 16_000)
_ = try await client.transcribe(
samples: samples,
sampleRate: 16_000,
locale: Locale(identifier: store.localeId == "auto" ? "zh-CN" : store.localeId),
dictionary: store.personalDictionary
)
testStatus = .success
} catch CloudASRError.noAPIKey {
testStatus = .failure(AppL10n.string("api.test.missing"))
} catch let error as CloudASRError {
testStatus = .failure(error.localizedDescription ?? "\(error)")
} catch {
testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)")
}
}
private func validateConnection() async throws {
let persisted = AppGroupStore()
let live = LiveConfigurationStore(config: config, fallback: persisted)
try await CloudASRConnectionCheck.validate(store: live)
}
private func fetchModels() async throws -> [String] {
try await ProviderModelService.listASRModels(
providerId: config.asrProviderId,
baseURL: config.asrBaseURL,
apiKey: config.asrApiKey,
currentModel: config.asrModel
)
}
}
@@ -1,138 +0,0 @@
// HomeStatsCard.swift
// OSGKeyboard · Main App
//
// Home screen summary: dictation time, dictation characters,
// translation characters, and personal-dictionary entry count.
import SwiftUI
import OSGKeyboardShared
struct HomeStatsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@State private var dictionaryCount = 0
private enum Layout {
static let fixedHeight: CGFloat = 166
static let valueFontSize: CGFloat = 24
static let iconSize: CGFloat = 18
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
statCell(
systemImage: "waveform",
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: config.uiLanguage
),
label: "home.stats.dictationDuration"
)
divider
statCell(
systemImage: "text.alignleft",
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount,
language: config.uiLanguage
),
label: "home.stats.dictationCharacters"
)
}
horizontalDivider
HStack(spacing: 0) {
statCell(
systemImage: "character.bubble",
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: config.uiLanguage
),
label: "home.stats.translationCharacters"
)
divider
statCell(
systemImage: "square.stack.3d.down.right.fill",
value: UsageStatisticsStore.formatCount(
dictionaryCount,
language: config.uiLanguage
),
label: "home.stats.dictionaryEntries"
)
}
}
.frame(height: Layout.fixedHeight)
.background(cardBackground)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.onAppear(perform: refreshDictionaryCount)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(value)
.font(.system(size: Layout.valueFontSize, weight: .semibold, design: .rounded))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
Text(label)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Spacer(minLength: Spacing.xs)
Image(systemName: systemImage)
.font(.system(size: Layout.iconSize, weight: .semibold))
.foregroundStyle(palette.accent)
.padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
}
private var divider: some View {
Rectangle()
.fill(palette.divider)
.frame(width: 0.5)
}
private var horizontalDivider: some View {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
}
private func refreshDictionaryCount() {
dictionaryCount = AppGroupStore().personalDictionary.entries.count
}
private var cardBackground: Color {
colorScheme == .dark ? palette.surface : .white
}
}
#if DEBUG
#Preview {
ThemedRoot {
HomeStatsCard()
.padding()
}
}
#endif
@@ -0,0 +1,61 @@
// HomeUsageStatsSection.swift
// OSGKeyboard · Main App
//
// Observes usage + dictionary counts and feeds the shared
// `UsageStatsCluster` (phone stacked / iPad split).
import SwiftUI
import OSGKeyboardShared
struct HomeUsageStatsSection: View {
let layout: UsageStatsCluster.Layout
var compact: Bool = false
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@State private var dictionaryCount = 0
var body: some View {
UsageStatsCluster(
layout: layout,
language: config.uiLanguage,
points: stats.last7Days,
dictationCharacterCount: stats.dictationCharacterCount,
dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount,
dictionaryTermCount: dictionaryCount,
compact: compact
)
.onAppear(perform: refreshDictionaryCount)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
private func refreshDictionaryCount() {
dictionaryCount = AppGroupStore().personalDictionary.entries.count
}
}
#if DEBUG
#Preview("Phone stacked") {
ThemedRoot {
HomeUsageStatsSection(layout: .stacked)
.padding()
}
}
#Preview("Wide split") {
ThemedRoot {
HomeUsageStatsSection(layout: .split)
.padding()
}
}
#endif
@@ -23,7 +23,7 @@ enum AppTab: Int, CaseIterable {
}
}
/// Matches `HomeStatsCard` dictionary stat cell (filled variant).
/// Filled SF Symbol override for the dictionary tab.
var sfSymbol: String? {
switch self {
case .dictionary: return "square.stack.3d.down.right.fill"
@@ -32,62 +32,3 @@ struct PrivacyInfoCard: View {
)
}
}
/// Footnote when Cloud polish is active text goes to the user's configured API.
struct CloudPolishDisclosureBanner: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
Image(systemName: "info.circle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.accent)
Text("settings.privacy.cloud.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.accentMuted.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
}
}
/// First-time confirmation before enabling Cloud polish (user-configured third-party API).
struct CloudSharingAcknowledgmentModifier: ViewModifier {
@ObservedObject var config: ProviderConfig
@Binding var isPresented: Bool
let onConfirm: () -> Void
let onCancel: () -> Void
func body(content: Content) -> some View {
content
.alert("settings.privacy.cloud.alert.title", isPresented: $isPresented) {
Button("common.continue") {
config.hasAcknowledgedCloudSharing = true
onConfirm()
}
Button("common.cancel", role: .cancel) {
onCancel()
}
} message: {
Text("settings.privacy.cloud.alert.message")
}
}
}
extension View {
func cloudSharingAcknowledgment(
config: ProviderConfig,
isPresented: Binding<Bool>,
onConfirm: @escaping () -> Void,
onCancel: @escaping () -> Void
) -> some View {
modifier(CloudSharingAcknowledgmentModifier(
config: config,
isPresented: isPresented,
onConfirm: onConfirm,
onCancel: onCancel
))
}
}
@@ -4,6 +4,8 @@
// Reusable layout pieces for iPad / regular-width surfaces. Styled with the
// shared design tokens so the wide Home dashboard can mirror the macOS shell
// without pulling in AppKit-only types from OSGKeyboardMac.
//
// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`.
import SwiftUI
import OSGKeyboardShared
@@ -21,7 +23,7 @@ enum WideLayoutMetrics {
// MARK: - Card container
/// Elevated surface used for stat tiles and the dictation canvas.
/// Elevated surface used for the dictation canvas on wide Home.
struct WideCard<Content: View>: View {
@Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md
@@ -39,160 +41,3 @@ struct WideCard<Content: View>: View {
)
}
}
// MARK: - Stat tile
struct WideStatCard: View {
@Environment(\.themePalette) private var palette
let title: String
let value: String
let caption: String
var systemImage: String?
var accent: Bool = false
/// Hero metric: wide horizontal layout for the primary word count.
var prominent: Bool = false
var body: some View {
WideCard(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)
}
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)
}
}
}
// MARK: - Home stats cluster
/// Dashboard-style stat cluster for the wide Home layout.
struct WideHomeStatsCluster: View {
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@State private var dictionaryCount = 0
private var language: AppUILanguage { config.uiLanguage }
var body: some View {
VStack(spacing: Spacing.md) {
WideStatCard(
title: AppL10n.string("home.stats.dictationCharacters", language: language),
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount,
language: language
),
caption: AppL10n.string("home.wide.stat.transcribed", language: language),
systemImage: "text.alignleft",
accent: true,
prominent: true
)
HStack(spacing: Spacing.md) {
WideStatCard(
title: AppL10n.string("home.stats.dictationDuration", language: language),
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: language
),
caption: AppL10n.string("home.wide.stat.cumulativeDuration", language: language),
systemImage: "waveform"
)
WideStatCard(
title: AppL10n.string("home.stats.translationCharacters", language: language),
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: language
),
caption: AppL10n.string("home.wide.stat.cumulativeTranslation", language: language),
systemImage: "character.bubble"
)
WideStatCard(
title: AppL10n.string("home.stats.dictionaryEntries", language: language),
value: UsageStatisticsStore.formatCount(
dictionaryCount,
language: language
),
caption: AppL10n.string("home.wide.stat.customTerms", language: language),
systemImage: "character.book.closed"
)
}
}
.onAppear(perform: refreshDictionaryCount)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
private func refreshDictionaryCount() {
dictionaryCount = AppGroupStore().personalDictionary.entries.count
}
}
+1 -28
View File
@@ -11,8 +11,6 @@ struct EnginePickerSection: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var showCloudAcknowledgment = false
@State private var pendingEngineSelection: String?
var body: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
@@ -37,17 +35,7 @@ struct EnginePickerSection: View {
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
if config.engineMode == "cloud" {
CloudPolishDisclosureBanner()
}
}
.cloudSharingAcknowledgment(
config: config,
isPresented: $showCloudAcknowledgment,
onConfirm: { applyPendingEngineSelection() },
onCancel: { pendingEngineSelection = nil }
)
}
private var localSubtitle: String {
@@ -64,11 +52,6 @@ struct EnginePickerSection: View {
let isSelected = config.engineMode == id
return Button {
guard config.engineMode != id else { return }
if id == "cloud", !config.hasAcknowledgedCloudSharing {
pendingEngineSelection = id
showCloudAcknowledgment = true
return
}
selectEngine(id)
} label: {
HStack(spacing: Spacing.sm) {
@@ -92,8 +75,7 @@ struct EnginePickerSection: View {
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -104,19 +86,10 @@ struct EnginePickerSection: View {
config.engineMode = id
if id == "cloud" {
config.modeId = "polish"
if config.providerId == "deepseek" {
config.apply(preset: LLMProvider.provider(id: "openai"))
}
}
}
}
private func applyPendingEngineSelection() {
guard let id = pendingEngineSelection else { return }
pendingEngineSelection = nil
selectEngine(id)
}
@ViewBuilder
private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
ZStack {
+28 -13
View File
@@ -85,6 +85,16 @@ struct HomeView: View {
private var phoneBody: some View {
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
// iPhone SE
// tab
let isCompact = geo.size.height < 700
// logo
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg
let statusTopPadding = isCompact ? Spacing.sm : Spacing.xl
// tab
let previewMinHeight: CGFloat = isCompact ? 72 : 160
ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight)
@@ -92,23 +102,25 @@ struct HomeView: View {
.allowsHitTesting(false)
VStack(spacing: 0) {
logoHeader
.padding(.top, Spacing.xxxl)
.padding(.bottom, Spacing.xxl)
logoHeader(compact: isCompact)
.padding(.top, logoTopPadding)
.padding(.bottom, logoBottomPadding)
if showsFlowSessionExtras {
flowSessionExtras
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.lg)
.padding(.bottom, extrasBottomPadding)
}
HomeStatsCard()
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
previewField
//
previewField(minHeight: previewMinHeight)
.padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.layoutPriority(-1)
HStack(spacing: Spacing.sm) {
engineStatusLine
@@ -116,7 +128,7 @@ struct HomeView: View {
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xl)
.padding(.top, statusTopPadding)
.padding(.bottom, Spacing.sm)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -138,7 +150,7 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: Spacing.lg) {
wideHeroHeader
WideHomeStatsCluster()
HomeUsageStatsSection(layout: .split)
if showsFlowSessionExtras {
flowSessionExtras
@@ -235,12 +247,15 @@ struct HomeView: View {
// MARK: - Header
private var logoHeader: some View {
VStack(spacing: Spacing.xxl) {
// logo 144:41
private func logoHeader(compact: Bool) -> some View {
let logoWidth: CGFloat = compact ? 104 : 124
let logoHeight = logoWidth * (41.0 / 144.0)
return VStack(spacing: Spacing.xxl) {
Image("osglogo")
.resizable()
.scaledToFit()
.frame(width: 144, height: 41)
.frame(width: logoWidth, height: logoHeight)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity)
@@ -444,9 +459,9 @@ struct HomeView: View {
// MARK: - Preview field
private var previewField: some View {
private func previewField(minHeight: CGFloat) -> some View {
previewFieldContent
.frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
@@ -25,8 +25,10 @@ struct LocalModelsGroup: View {
speechRow
Divider().background(palette.divider)
polishRow
#if DEBUG
Divider().background(palette.divider)
customLanguageModelDiagnosticRow
#endif
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
@@ -45,8 +47,7 @@ struct LocalModelsGroup: View {
Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.speechEngine")
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
// MARK: Polish row
@@ -61,12 +62,12 @@ struct LocalModelsGroup: View {
Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.polishEngine")
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
// MARK: Custom language model diagnostic row
// MARK: Custom language model diagnostic row (DEBUG builds only)
#if DEBUG
private var customLanguageModelDiagnosticRow: some View {
Toggle(isOn: $config.localASRCustomLanguageModelEnabled) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
@@ -79,10 +80,9 @@ struct LocalModelsGroup: View {
}
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
#endif
// MARK: Helpers
+67 -34
View File
@@ -1,12 +1,13 @@
// OnboardingView.swift
// OSGKeyboard · Main App
//
// Five-step onboarding:
// Six-step onboarding:
// 1) Welcome
// 2) Microphone permission
// 3) Speech recognition permission
// 4) Enable keyboard + Allow Full Access
// 5) Engine / API setup
// 5) Engine / ASR setup
// 6) Polish LLM setup
import SwiftUI
import OSGKeyboardShared
@@ -18,8 +19,9 @@ private enum OnboardingPage: Int, CaseIterable {
case speech
case keyboard
case api
case polish
static let count = 5
static let count = 6
}
struct OnboardingView: View {
@@ -61,6 +63,8 @@ struct OnboardingView: View {
EnableKeyboardPage()
case .api:
APISetupPage(config: config)
case .polish:
PolishSetupPage(config: config)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -234,14 +238,14 @@ struct OnboardingView: View {
}
}
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue }
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.polish.rawValue }
private var canAdvance: Bool {
switch currentPage {
case .welcome, .keyboard:
return !isLastPage || onboardingCompleteReady
case .api:
case .welcome, .keyboard, .api:
return !isLastPage || onboardingCompleteReady
case .polish:
return onboardingCompleteReady
case .microphone:
return micStatus != .undetermined
case .speech:
@@ -250,9 +254,8 @@ struct OnboardingView: View {
}
private var onboardingCompleteReady: Bool {
// v0.2.0: the local engine uses iOS `SpeechAnalyzer`, which is
// always available no model download gate. The cloud engine
// still requires base URL + API key + model.
// Local engine: built-in polish satisfies `isPolishConfigured`.
// Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready.
if config.isLocalEngine { return true }
return config.isConfigured
}
@@ -364,7 +367,6 @@ private struct OnboardingTitleBlock: View {
// MARK: - Welcome
private struct WelcomePage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var logoAppeared = false
var body: some View {
@@ -386,26 +388,13 @@ private struct WelcomePage: View {
}
OnboardingTitleBlock(
title: "onboarding.welcome.tagline",
subtitle: "onboarding.welcome.subtitle",
secondarySubtitle: "onboarding.welcome.subtitle2"
title: "onboarding.welcome.tagline"
)
.opacity(logoAppeared ? 1 : 0)
.offset(y: logoAppeared ? 0 : 10)
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
.animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared)
if let url = LegalLinks.privacyPolicyURL {
Link(destination: url) {
Text("legal.privacyPolicy")
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
}
.padding(.top, Spacing.xxxl)
.opacity(logoAppeared ? 1 : 0)
.animation(.easeOut(duration: 0.45).delay(0.28), value: logoAppeared)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -708,7 +697,7 @@ private struct EnableKeyboardPage: View {
}
}
// MARK: - API setup
// MARK: - Engine / ASR setup
private struct APISetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@@ -728,16 +717,60 @@ private struct APISetupPage: View {
.padding(.horizontal, Spacing.lg)
if config.engineMode == "cloud" {
ProviderPickerSection(config: config)
.padding(.horizontal, Spacing.lg)
APISettingsCard(config: config)
.padding(.horizontal, Spacing.lg)
VStack(spacing: 0) {
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
.padding(.horizontal, Spacing.lg)
} else {
// Local engine: built-in ASR + built-in polish (no API card).
LocalModelsGroup(config: config)
.padding(.horizontal, Spacing.lg)
Text("onboarding.api.localModels.hint")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
}
}
.padding(.bottom, Spacing.xxxl)
}
}
}
// MARK: - Polish LLM setup
private struct PolishSetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
VStack(spacing: Spacing.lg) {
OnboardingHeroIcon(systemName: "wand.and.stars", circleSize: 72, iconSize: 30)
.padding(.top, Spacing.xl)
OnboardingTitleBlock(
title: "onboarding.polish.title",
subtitle: "onboarding.polish.subtitle"
)
.padding(.horizontal, Spacing.lg)
if config.isLocalEngine {
Text("onboarding.polish.localHint")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
}
VStack(spacing: 0) {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
.padding(.horizontal, Spacing.lg)
}
.padding(.bottom, Spacing.xxxl)
}
@@ -64,8 +64,7 @@ struct OpenSourceLicensesView: View {
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, 10)
.settingsListRow()
.contentShape(Rectangle())
}
}
@@ -30,8 +30,7 @@ struct PersonalDictionaryEntrySheet: View {
.focused($termFocused)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
@@ -1,99 +0,0 @@
// PersonalDictionaryICloudSyncRow.swift
// OSGKeyboard · Main App
//
// Settings-row toggle for mirroring the personal dictionary through
// iCloud Key-Value Store. Lives in Settings .
import SwiftUI
import OSGKeyboardShared
@MainActor
struct PersonalDictionaryICloudSyncRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var isEnabled: Bool = AppGroupStore().personalDictionaryICloudSyncEnabled
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
private let store = AppGroupStore()
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Toggle(isOn: toggleBinding) {
Text("settings.personalDictionary.iCloudSync.settingsTitle")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.disabled(isApplyingToggle)
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption2)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Spacing.xxs)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.onAppear { reloadFromStore() }
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
if newValue {
enableSync()
} else {
disableSync()
}
}
)
}
private func reloadFromStore() {
isEnabled = store.personalDictionaryICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await PersonalDictionaryCloudSync.shared.enableSync()
reloadFromStore()
} catch let error as PersonalDictionaryCloudSyncError {
isEnabled = false
syncErrorMessage = localizedSyncError(error)
} catch {
isEnabled = false
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
}
}
private func disableSync() {
PersonalDictionaryCloudSync.shared.disableSync()
isEnabled = false
syncErrorMessage = nil
}
private func localizedSyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
switch error {
case .payloadTooLarge:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
}
}
}
@@ -170,8 +170,7 @@ struct PersonalDictionaryView: View {
.font(.caption.weight(.semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+44 -33
View File
@@ -9,33 +9,47 @@ struct ProviderPickerSection: View {
@ObservedObject var config: ProviderConfig
var role: CloudProviderRole = .polish
/// `false`
var showsSurface: Bool = true
private var selectedProviderId: String {
role == .asr ? config.asrProviderId : config.providerId
}
var body: some View {
let visiblePresets = role == .asr
private var visiblePresets: [LLMProvider] {
role == .asr
? LLMProvider.asrSelectablePresets
: LLMProvider.userSelectablePresets
VStack(spacing: 0) {
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in
Button {
select(provider)
} label: {
row(provider, selected: provider.id == selectedProviderId)
}
.buttonStyle(.plain)
if index < visiblePresets.count - 1 {
Divider().background(palette.divider)
}
private var selectedProvider: LLMProvider {
visiblePresets.first(where: { $0.id == selectedProviderId })
?? LLMProvider.provider(id: selectedProviderId)
}
var body: some View {
// Menu label
// label
SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) {
Menu {
ForEach(visiblePresets, id: \.id) { provider in
Button {
select(provider)
} label: {
let name = ProviderDisplayName.name(for: provider.id)
if provider.id == selectedProviderId {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
providerChip
}
.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)
)
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
private func select(_ provider: LLMProvider) {
@@ -49,48 +63,45 @@ struct ProviderPickerSection: View {
}
}
@ViewBuilder
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
/// logo + +
private var providerChip: some View {
HStack(spacing: Spacing.sm) {
providerMark(provider, selected: selected)
providerMark(selectedProvider)
Text(ProviderDisplayName.name(for: provider.id))
Text(ProviderDisplayName.name(for: selectedProvider.id))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
if provider.supportsPersonalDictionaryCloudASR {
if selectedProvider.supportsPersonalDictionaryCloudASR {
personalDictionaryBadge
}
Spacer(minLength: Spacing.xs)
if selected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(palette.accent)
}
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
@ViewBuilder
private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View {
private func providerMark(_ provider: LLMProvider) -> some View {
ZStack {
Circle()
.fill(selected ? palette.accentMuted : palette.surfaceElevated)
.fill(palette.accentMuted)
.frame(width: 32, height: 32)
if let asset = ProviderLogo.assetName(for: provider.id) {
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundStyle(selected ? palette.accent : palette.textPrimary)
.foregroundStyle(palette.accent)
} else {
Text(String(provider.name.prefix(1)))
.font(TypeStyle.caption)
.foregroundStyle(selected ? palette.accent : palette.textSecondary)
.foregroundStyle(palette.accent)
}
}
}
@@ -0,0 +1,26 @@
// SettingsCardChrome.swift
// OSGKeyboard · Main App
//
// Shared rounded surface chrome for settings list cards.
import SwiftUI
import OSGKeyboardShared
struct SettingsSurfaceCardModifier: ViewModifier {
@Environment(\.themePalette) private var palette: ThemePalette
let enabled: Bool
func body(content: Content) -> some View {
if enabled {
content
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} else {
content
}
}
}
+57 -7
View File
@@ -14,6 +14,9 @@ struct SettingsICloudSyncRow: View {
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
@State private var isSyncingNow = false
/// Transient success flag: shows a brief "" confirmation so a fast
/// sync gives visible feedback instead of a spinner that flashes once.
@State private var showSyncedConfirmation = false
private let store = AppGroupStore()
@@ -38,13 +41,21 @@ struct SettingsICloudSyncRow: View {
syncNow()
} label: {
HStack(spacing: Spacing.xs) {
Text(syncButtonTitleKey)
.font(TypeStyle.caption)
Spacer(minLength: 0)
if isSyncingNow {
ProgressView()
.controlSize(.small)
.controlSize(.mini)
} else if showSyncedConfirmation {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .semibold))
}
Text("settings.appSettings.iCloudSync.syncNow")
.font(TypeStyle.caption)
}
// Fill the row so the whole strip is tappable, not just
// the caption glyphs (previously easy to miss).
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(palette.accent)
@@ -60,15 +71,18 @@ struct SettingsICloudSyncRow: View {
.padding(.top, Spacing.xxs)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.settingsListRow(alignment: .leading)
.onAppear { reloadFromStore() }
.onReceive(
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
) { _ in
reloadFromStore()
}
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
}
private var toggleBinding: Binding<Bool> {
@@ -85,6 +99,16 @@ struct SettingsICloudSyncRow: View {
)
}
private var syncButtonTitleKey: LocalizedStringKey {
if isSyncingNow {
return "settings.appSettings.iCloudSync.syncing"
}
if showSyncedConfirmation {
return "settings.appSettings.iCloudSync.synced"
}
return "settings.appSettings.iCloudSync.syncNow"
}
private func reloadFromStore() {
isEnabled = store.settingsICloudSyncEnabled
}
@@ -95,6 +119,15 @@ struct SettingsICloudSyncRow: View {
Task {
do {
try await CloudSyncContext.shared.settingsSyncService.enableSync()
do {
try await CloudSyncContext.shared.dictionarySyncService.enableSync()
} catch let error as PersonalDictionaryCloudSyncError {
CloudSyncContext.shared.settingsSyncService.disableSync()
isEnabled = false
syncErrorMessage = localizedDictionarySyncError(error)
isApplyingToggle = false
return
}
reloadFromStore()
} catch let error as SettingsCloudSyncError {
isEnabled = false
@@ -114,15 +147,23 @@ struct SettingsICloudSyncRow: View {
}
private func syncNow() {
guard !isSyncingNow else { return }
isSyncingNow = true
showSyncedConfirmation = false
syncErrorMessage = nil
Task {
do {
try await CloudSyncContext.shared.syncNow()
isSyncingNow = false
withAnimation { showSyncedConfirmation = true }
// Auto-dismiss the confirmation so the label returns to
// its default "" state.
try? await Task.sleep(nanoseconds: 1_800_000_000)
withAnimation { showSyncedConfirmation = false }
} catch {
isSyncingNow = false
syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic")
}
isSyncingNow = false
}
}
@@ -132,4 +173,13 @@ struct SettingsICloudSyncRow: View {
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
}
}
private func localizedDictionarySyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
switch error {
case .payloadTooLarge:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
}
}
}
@@ -0,0 +1,316 @@
// SettingsProviderControls.swift
// OSGKeyboard · Main App
//
// OpenLess-style setting rows for provider credentials and tools. Compact iOS
// stacks label above control; regular-width iPad keeps label/control in one row.
import SwiftUI
import OSGKeyboardShared
struct SettingsProviderRow<Content: View>: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let title: String
@ViewBuilder var content: () -> Content
var body: some View {
if horizontalSizeClass == .compact {
VStack(alignment: .leading, spacing: 8) {
label
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
.settingsListRow()
} else {
HStack(alignment: .center, spacing: Spacing.lg) {
label
.frame(width: 150, alignment: .leading)
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
.settingsListRow()
}
}
private var label: some View {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
}
struct SettingsCredentialRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
let placeholder: String
@Binding var text: String
var isSecret: Bool = false
var isMonospaced: Bool = false
var defaultValue: String?
var trailing: AnyView?
@State private var revealed = false
var body: some View {
SettingsProviderRow(title: title) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: Spacing.xs) {
input
if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
iconButton(systemName: "checkmark", label: "settings.provider.fillDefault") {
text = defaultValue
}
}
if let trailing {
trailing
}
if isSecret {
iconButton(systemName: revealed ? "eye.slash" : "eye", label: revealed ? "api.key.hide" : "api.key.show") {
revealed.toggle()
}
}
}
}
}
}
@ViewBuilder
private var input: some View {
Group {
if isSecret && !revealed {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
}
}
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(isMonospaced ? TypeStyle.mono : TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 38)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 38, height: 38)
.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)
.accessibilityLabel(label)
}
}
// MARK: - Editable model field + fetch icon
/// Model id: type freely, or fetch then pick from the trailing dropdown.
struct SettingsModelPickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
let placeholder: String
@Binding var model: String
let fetchModels: () async throws -> [String]
@State private var models: [String] = []
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
private let controlHeight: CGFloat = 38
var body: some View {
SettingsProviderRow(title: title) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: Spacing.xs) {
comboField
refreshButton
}
if let message {
Text(message)
.font(TypeStyle.caption2)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
}
}
}
}
/// Editable model id + trailing menu chevron in one well (same chrome as
/// Mac `MacPickerFieldBox`). Chevron is overlaid so it stays inside the border.
private var comboField: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let chevronWidth: CGFloat = 28
return ZStack(alignment: .trailing) {
TextField(placeholder, text: $model)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.mono)
.foregroundStyle(palette.textPrimary)
.padding(.leading, Spacing.sm)
.padding(.trailing, chevronWidth + Spacing.sm)
.frame(maxWidth: .infinity, minHeight: controlHeight, alignment: .leading)
.background(palette.surfaceElevated, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
Menu {
if models.isEmpty {
Button(AppL10n.string("settings.provider.modelsEmptyHint")) {}
.disabled(true)
} else {
ForEach(models, id: \.self) { modelId in
Button {
model = modelId
message = AppL10n.format("settings.provider.modelSelected", modelId)
failed = false
} label: {
if modelId == model {
Label(modelId, systemImage: "checkmark")
} else {
Text(modelId)
}
}
}
}
} label: {
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(width: chevronWidth, height: controlHeight)
.contentShape(Rectangle())
}
.padding(.trailing, Spacing.sm)
.accessibilityLabel(AppL10n.string("settings.provider.selectModel"))
}
}
private var refreshButton: some View {
Button {
Task { await runFetchModels() }
} label: {
Group {
if isRunning {
ProgressView()
.controlSize(.mini)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
}
}
.frame(width: controlHeight, height: controlHeight)
.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)
.disabled(isRunning)
.accessibilityLabel(AppL10n.string("settings.provider.fetchModels"))
}
@MainActor
private func runFetchModels() async {
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: AppL10n.string("settings.provider.loadingModels"),
loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty"),
currentModel: model,
fetchModels: fetchModels
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
}
}
}
// MARK: - Connection validate only
struct SettingsProviderToolsRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let validate: () async throws -> Void
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
var body: some View {
HStack(alignment: .center, spacing: Spacing.sm) {
Text(AppL10n.string("settings.provider.tools"))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
if isRunning {
ProgressView()
.controlSize(.mini)
} else if let message {
Text(message)
.font(TypeStyle.caption2)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
.truncationMode(.tail)
}
Spacer(minLength: 0)
Button {
Task { await runValidate() }
} label: {
Text(AppL10n.string("settings.provider.validate"))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.md)
.frame(minHeight: 34)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.disabled(isRunning)
}
.settingsListRow()
}
@MainActor
private func runValidate() async {
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runValidate(
runningMessage: AppL10n.string("api.test.running"),
successMessage: AppL10n.string("api.test.success"),
validate: validate
)
message = outcome.message
failed = outcome.failed
}
}
+28 -46
View File
@@ -43,15 +43,13 @@ struct SettingsView: View {
dictionaryAndPolishSection
flowSessionSection
engineSection
polishProviderSection
polishApiSection
if config.engineMode == "cloud" {
asrProviderSection
asrApiSection
asrSettingsSection
}
if config.engineMode == "local" {
localEngineSettingsSection
}
polishSettingsSection
if presentation == .tab {
footerLinks
}
@@ -115,9 +113,7 @@ struct SettingsView: View {
}
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
Divider().background(palette.divider)
@@ -222,10 +218,6 @@ struct SettingsView: View {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
Divider().background(palette.divider)
PersonalDictionaryICloudSyncRow()
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
@@ -248,40 +240,36 @@ struct SettingsView: View {
}
}
private var polishProviderSection: some View {
private var polishSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.polishProvider.title")
Text("settings.polishProvider.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
ProviderPickerSection(config: config, role: .polish)
cloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
}
}
private var asrProviderSection: some View {
private var asrSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.asrProvider.title")
Text("settings.asrProvider.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
ProviderPickerSection(config: config, role: .asr)
cloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
}
}
private var polishApiSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.polishApi.title")
APISettingsCard(config: config)
}
}
private var asrApiSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.asrApi.title")
ASRSettingsCard(config: config)
@ViewBuilder
private func cloudProviderSettingsCard<Content: View>(
@ViewBuilder content: () -> Content
) -> some View {
VStack(spacing: 0) {
content()
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
}
// MARK: - Language helpers
@@ -352,8 +340,7 @@ struct SettingsView: View {
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
// MARK: - Footer links (tab settings only)
@@ -375,8 +362,7 @@ struct SettingsView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -432,8 +418,7 @@ struct SettingsView: View {
MaterialIcon(name: .openInNew, size: 18)
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -453,8 +438,7 @@ struct SettingsView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
@@ -596,8 +580,7 @@ private struct PickerRow: View {
}
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
private var currentLabel: String {
@@ -654,8 +637,7 @@ private struct LocalePickerRow: View {
}
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
private func label(for localeId: String) -> String {
+1 -4
View File
@@ -69,13 +69,10 @@ struct TranslationPickerRow: View {
}
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
}
// MARK: - Selection plumbing
/// Currently selected id the picker always reads
/// `translationTargetLocaleId` directly (the previous
/// `translationEnabled` boolean is now derived from it).
+37 -21
View File
@@ -4,9 +4,7 @@
/* Onboarding */
"onboarding.progress" = "Step %1$d of %2$d";
"onboarding.welcome.tagline" = "Speak instead of type";
"onboarding.welcome.subtitle" = "Open-source voice keyboard";
"onboarding.welcome.subtitle2" = "We never read or upload anything you type.";
"onboarding.welcome.tagline" = "Speak it. Its typed.";
"onboarding.permission.preface" = "iOS will ask for two permissions — please allow both.";
"onboarding.permission.mic.title" = "Microphone";
"onboarding.permission.mic.body" = "Records your voice.";
@@ -29,7 +27,10 @@
"onboarding.enable.step3.suffix" = "and select OSGKeyboard";
"onboarding.enable.openSettings" = "Open Settings";
"onboarding.api.title" = "Choose Engine";
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition and built-in polish — no API key needed.";
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition — no API key needed.";
"onboarding.polish.title" = "Text polish (LLM)";
"onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider.";
"onboarding.polish.localHint" = "Built-in polish is included. Add your own API key here to override.";
"settings.onboarding.replay" = "Restart permission setup";
/* Common navigation */
@@ -44,6 +45,7 @@
"common.delete" = "Delete";
"common.space" = "Space";
"common.newline" = "Return";
"common.send" = "Send";
/* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "On-device transcription";
@@ -92,13 +94,13 @@
"settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Recognition method";
"settings.engine.subtitle" = "Local: on-device ASR only. Cloud: ASR + polish via your API.";
"settings.engine.local.title" = "On-device recognition";
"settings.engine.title" = "Speech transcription method";
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
"settings.engine.local.title" = "On-device transcription";
"settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
"settings.engine.cloud.title" = "Cloud recognition & polish";
"settings.engine.cloud.subtitle" = "Cloud ASR and polish LLM are configured separately. Audio goes to your ASR provider.";
"settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
"settings.engine.cloud.title" = "Cloud transcription";
"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
"settings.engine.cloud.badge" = "Cloud engine";
"settings.provider.title" = "Provider";
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
@@ -111,6 +113,26 @@
"settings.asrApi.title" = "ASR API";
"settings.asr.model" = "ASR model";
"settings.asr.testConnection" = "Test ASR";
"settings.provider.supplier" = "Provider";
"settings.provider.tools" = "Connection check";
"settings.provider.validate" = "Validate";
"settings.provider.fetchModels" = "Fetch models";
"settings.provider.loadingModels" = "Loading models…";
"settings.provider.modelsLoaded" = "%lld models loaded";
"settings.provider.selectModel" = "Select model";
"settings.provider.modelSelected" = "Selected %@";
"settings.provider.modelsEmptyHint" = "Tap refresh to load models";
"settings.provider.fillDefault" = "Fill default";
"settings.provider.thinking" = "Thinking";
"settings.provider.thinkingSubtitle" = "Slower, higher quality — recommended off";
"settings.provider.thinkingOn" = "On";
"settings.provider.thinkingOff" = "Off";
"settings.provider.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning.";
"settings.provider.note" = "Note";
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
"settings.asr.volcengine.resourceId" = "Resource ID";
"settings.asr.volcengine.note" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "Qwen (DashScope)";
@@ -313,16 +335,8 @@
"home.flow.startShort" = "Start";
"home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…";
"home.stats.dictationDuration" = "Dictation time";
"home.stats.dictationCharacters" = "Dictation chars";
"home.stats.translationCharacters" = "Translation chars";
"home.stats.dictionaryEntries" = "Dictionary";
"home.wide.tagline" = "Voice dictation, anywhere.";
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
"home.wide.stat.transcribed" = "Total dictated";
"home.wide.stat.cumulativeDuration" = "Cumulative duration";
"home.wide.stat.cumulativeTranslation" = "Cumulative translation";
"home.wide.stat.customTerms" = "Custom terms";
"home.wide.mode.cloud" = "Cloud";
"home.wide.mode.local" = "On-device";
"home.wide.devices" = "iPhone & iPad";
@@ -387,10 +401,12 @@
"settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again.";
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
"settings.iCloudSync.title" = "iCloud Sync";
"settings.appSettings.iCloudSync.title" = "iCloud Sync";
"settings.appSettings.iCloudSync.subtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud. API keys use your private iCloud Keychain.";
"settings.iCloudSync.title" = "Cross-Device iCloud Sync";
"settings.appSettings.iCloudSync.title" = "Cross-Device iCloud Sync";
"settings.appSettings.iCloudSync.subtitle" = "Sync settings, history, and API keys across devices via iCloud.";
"settings.appSettings.iCloudSync.syncNow" = "Sync Now";
"settings.appSettings.iCloudSync.syncing" = "Syncing…";
"settings.appSettings.iCloudSync.synced" = "Synced";
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
/* v0.3.0: Polish intensity */
+38 -22
View File
@@ -4,9 +4,7 @@
/* Onboarding */
"onboarding.progress" = "第 %1$d / %2$d 步";
"onboarding.welcome.tagline" = "能说就不打字";
"onboarding.welcome.subtitle" = "开源语音输入法";
"onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。";
"onboarding.welcome.tagline" = "开口即文字。";
"onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。";
"onboarding.permission.mic.title" = "麦克风";
"onboarding.permission.mic.body" = "用于录制语音。";
@@ -29,7 +27,10 @@
"onboarding.enable.step3.suffix" = ",选中 OSGKeyboard";
"onboarding.enable.openSettings" = "去设置";
"onboarding.api.title" = "选择语音转文字 AI 引擎";
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别与内置润色,无需填写 API Key。";
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别,无需填写 API Key。";
"onboarding.polish.title" = "文本润色(LLM";
"onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
"onboarding.polish.localHint" = "已包含内置润色。在此填入 API Key 可替换为自定义模型。";
"settings.onboarding.replay" = "重新开始权限引导";
/* Common navigation */
@@ -43,7 +44,8 @@
"common.clear" = "清空";
"common.delete" = "删除";
"common.space" = "空格";
"common.newline" = "换行";
"common.newline" = "回车";
"common.send" = "发送";
/* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "支持本地转写";
@@ -92,13 +94,13 @@
"settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "识别方式";
"settings.engine.subtitle" = "本地:端侧识别,仅转录;云端:通过 API 润色。";
"settings.engine.local.title" = "本地识别";
"settings.engine.title" = "语音转写方式";
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
"settings.engine.local.title" = "本地转写";
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
"settings.engine.cloud.title" = "云端识别与润色";
"settings.engine.cloud.subtitle" = "云端 ASR 与润色 LLM 分开配置;音频发送至转写服务商。";
"settings.engine.local.legacy" = "通过系统内置服务语音转写";
"settings.engine.cloud.title" = "云端转写";
"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
"settings.engine.cloud.badge" = "云端引擎";
"settings.provider.title" = "云端引擎";
"settings.provider.personalDictionaryBadge" = "个性词库";
@@ -111,6 +113,26 @@
"settings.asrApi.title" = "转写接口";
"settings.asr.model" = "ASR 模型";
"settings.asr.testConnection" = "测试转写";
"settings.provider.supplier" = "供应商";
"settings.provider.tools" = "连接检查";
"settings.provider.validate" = "验证";
"settings.provider.fetchModels" = "拉取模型";
"settings.provider.loadingModels" = "正在拉取模型…";
"settings.provider.modelsLoaded" = "已拉取 %lld 个模型";
"settings.provider.selectModel" = "选择模型";
"settings.provider.modelSelected" = "已选择 %@";
"settings.provider.modelsEmptyHint" = "先点右侧刷新拉取模型";
"settings.provider.fillDefault" = "填入默认值";
"settings.provider.thinking" = "思考";
"settings.provider.thinkingSubtitle" = "速度更慢、质量更高,建议关闭";
"settings.provider.thinkingOn" = "开启";
"settings.provider.thinkingOff" = "关闭";
"settings.provider.thinkingHint" = "默认关闭。仅在需要更慢、更深的推理时开启。";
"settings.provider.note" = "提示";
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
"settings.asr.volcengine.resourceId" = "Resource ID";
"settings.asr.volcengine.note" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "通义千问";
@@ -312,16 +334,8 @@
"home.flow.startShort" = "开启";
"home.preview.label" = "输入测试";
"home.preview.placeholder" = "点这里试试键盘";
"home.stats.dictationDuration" = "听写时长";
"home.stats.dictationCharacters" = "听写字数";
"home.stats.translationCharacters" = "翻译字数";
"home.stats.dictionaryEntries" = "个性词库";
"home.wide.tagline" = "随处语音听写";
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
"home.wide.stat.transcribed" = "累计听写字数";
"home.wide.stat.cumulativeDuration" = "累计听写时长";
"home.wide.stat.cumulativeTranslation" = "累计翻译字数";
"home.wide.stat.customTerms" = "自定义词条";
"home.wide.mode.cloud" = "云端";
"home.wide.mode.local" = "本机";
"home.wide.devices" = "iPhone 与 iPad";
@@ -386,10 +400,12 @@
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
"settings.iCloudSync.title" = "iCloud 同步";
"settings.appSettings.iCloudSync.title" = "iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。";
"settings.iCloudSync.title" = "跨设备iCloud 同步";
"settings.appSettings.iCloudSync.title" = "跨设备iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 设备同步设置、历史记录、API Key";
"settings.appSettings.iCloudSync.syncNow" = "立即同步";
"settings.appSettings.iCloudSync.syncing" = "同步中…";
"settings.appSettings.iCloudSync.synced" = "已同步";
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
/* v0.3.0: 润色强度 */