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
@@ -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 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ark.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>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 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "groq.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>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 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "xai.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>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 |
@@ -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.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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. It’s 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 */
|
||||
|
||||
@@ -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: 润色强度 */
|
||||
|
||||