diff --git a/OSGKeyboard/AppIcon.icon/Assets/App Icon Template.svg b/OSGKeyboard/AppIcon.icon/Assets/App Icon Template.svg
index 92bd30d..afa301d 100644
--- a/OSGKeyboard/AppIcon.icon/Assets/App Icon Template.svg
+++ b/OSGKeyboard/AppIcon.icon/Assets/App Icon Template.svg
@@ -16,8 +16,7 @@
-
-
+
diff --git a/OSGKeyboard/AppIcon.icon/icon.json b/OSGKeyboard/AppIcon.icon/icon.json
index 8608d5d..a9635cd 100644
--- a/OSGKeyboard/AppIcon.icon/icon.json
+++ b/OSGKeyboard/AppIcon.icon/icon.json
@@ -1,9 +1,6 @@
{
"fill" : {
- "linear-gradient" : [
- "extended-gray:0.25000,1.00000",
- "display-p3:0.13763,0.15253,0.17418,1.00000"
- ]
+ "automatic-gradient" : "display-p3:0.13763,0.15253,0.17418,1.00000"
},
"groups" : [
{
diff --git a/OSGKeyboard/Assets.xcassets/apple.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/apple.imageset/Contents.json
new file mode 100644
index 0000000..bc991b6
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/apple.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "apple.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/apple.imageset/apple.svg b/OSGKeyboard/Assets.xcassets/apple.imageset/apple.svg
new file mode 100644
index 0000000..e41c537
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/apple.imageset/apple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/OSGKeyboard/Assets.xcassets/custom.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/custom.imageset/Contents.json
new file mode 100644
index 0000000..998fdfc
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/custom.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "custom.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/custom.imageset/custom.svg b/OSGKeyboard/Assets.xcassets/custom.imageset/custom.svg
new file mode 100644
index 0000000..b9ba5cb
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/custom.imageset/custom.svg
@@ -0,0 +1 @@
+
diff --git a/OSGKeyboard/Assets.xcassets/deepseek.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/deepseek.imageset/Contents.json
new file mode 100644
index 0000000..2076164
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/deepseek.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "deepseek.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/deepseek.imageset/deepseek.svg b/OSGKeyboard/Assets.xcassets/deepseek.imageset/deepseek.svg
new file mode 100644
index 0000000..dc224e4
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/deepseek.imageset/deepseek.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/OSGKeyboard/Assets.xcassets/moonshot.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/moonshot.imageset/Contents.json
new file mode 100644
index 0000000..3dc959a
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/moonshot.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "moonshot.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/moonshot.imageset/moonshot.svg b/OSGKeyboard/Assets.xcassets/moonshot.imageset/moonshot.svg
new file mode 100644
index 0000000..fb56ac1
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/moonshot.imageset/moonshot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/OSGKeyboard/Assets.xcassets/openai.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/openai.imageset/Contents.json
new file mode 100644
index 0000000..7f508a4
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/openai.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "openai.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/openai.imageset/openai.svg b/OSGKeyboard/Assets.xcassets/openai.imageset/openai.svg
new file mode 100644
index 0000000..78caf4f
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/openai.imageset/openai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/OSGKeyboard/Assets.xcassets/osglogo.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/osglogo.imageset/Contents.json
new file mode 100644
index 0000000..b487c65
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/osglogo.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "osglogo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/osglogo.imageset/osglogo.png b/OSGKeyboard/Assets.xcassets/osglogo.imageset/osglogo.png
new file mode 100644
index 0000000..cc92b61
Binary files /dev/null and b/OSGKeyboard/Assets.xcassets/osglogo.imageset/osglogo.png differ
diff --git a/OSGKeyboard/Assets.xcassets/qwen.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/qwen.imageset/Contents.json
new file mode 100644
index 0000000..4ea4e9b
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/qwen.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "qwen.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/qwen.imageset/qwen.svg b/OSGKeyboard/Assets.xcassets/qwen.imageset/qwen.svg
new file mode 100644
index 0000000..a4bb382
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/qwen.imageset/qwen.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/OSGKeyboard/Assets.xcassets/zhipu.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/zhipu.imageset/Contents.json
new file mode 100644
index 0000000..bcf670c
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/zhipu.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "zhipu.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/OSGKeyboard/Assets.xcassets/zhipu.imageset/zhipu.svg b/OSGKeyboard/Assets.xcassets/zhipu.imageset/zhipu.svg
new file mode 100644
index 0000000..f7b1252
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/zhipu.imageset/zhipu.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist
index 0583e58..26d3ea3 100644
--- a/OSGKeyboard/Info.plist
+++ b/OSGKeyboard/Info.plist
@@ -36,6 +36,8 @@
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
+ LSApplicationCategoryType
+ public.app-category.utilities
NSAppTransportSecurity
NSAllowsArbitraryLoads
diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift
index 97b78f6..3d1f6c6 100644
--- a/OSGKeyboard/OSGKeyboardApp.swift
+++ b/OSGKeyboard/OSGKeyboardApp.swift
@@ -12,13 +12,21 @@ struct OSGKeyboardApp: App {
@StateObject private var dictationCoordinator = DictationSessionCoordinator()
@StateObject private var flowManager = FlowSessionManager()
+ init() {
+ MaterialIconsFont.registerIfNeeded()
+ #if DEBUG
+ // Reset onboarding on each launch so guide screens can be reviewed while iterating UI.
+ ProviderConfig.shared.hasCompletedOnboarding = false
+ ProviderConfig.shared.onboardingPage = 0
+ #endif
+ }
+
var body: some Scene {
WindowGroup {
ThemedRoot {
if AppGroup.isAvailable {
if config.hasCompletedOnboarding {
- HomeView()
- .onAppear { flowManager.autoStartIfNeeded() }
+ MainTabView()
} else {
OnboardingView(config: config)
}
diff --git a/OSGKeyboard/Resources/Fonts/MaterialIcons-Regular.ttf b/OSGKeyboard/Resources/Fonts/MaterialIcons-Regular.ttf
new file mode 100644
index 0000000..9d09b0f
Binary files /dev/null and b/OSGKeyboard/Resources/Fonts/MaterialIcons-Regular.ttf differ
diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift
index e348c83..bf5ec74 100644
--- a/OSGKeyboard/Services/FlowSessionManager.swift
+++ b/OSGKeyboard/Services/FlowSessionManager.swift
@@ -29,6 +29,8 @@ final class FlowSessionManager: ObservableObject {
private var levelTask: Task?
private var startTask: Task?
private var isUtteranceRecording = false
+ /// True from `stopped` until the result/error is written back to App Group.
+ private var isUtteranceProcessing = false
private var finalizeTask: Task?
private var asrTask: Task?
private var currentPartial = ""
@@ -141,14 +143,15 @@ final class FlowSessionManager: ObservableObject {
levelTask = nil
finalizeTask?.cancel()
finalizeTask = nil
- asrTask?.cancel()
- asrTask = nil
- if isUtteranceRecording {
+ if isUtteranceRecording || isUtteranceProcessing {
capture.cancelUtterance()
+ asrTask?.cancel()
asr.cancel()
- isUtteranceRecording = false
}
+ asrTask = nil
+ isUtteranceRecording = false
+ isUtteranceProcessing = false
capture.stop()
FlowSessionBridge.markSessionInactive()
@@ -222,7 +225,7 @@ final class FlowSessionManager: ObservableObject {
private func handleKeyboardSignal() {
switch FlowSessionBridge.recordingState() {
case .recording:
- guard !isUtteranceRecording else { return }
+ guard !isUtteranceRecording, !isUtteranceProcessing else { return }
beginUtterance()
case .stopped:
guard isUtteranceRecording else { return }
@@ -239,10 +242,14 @@ final class FlowSessionManager: ObservableObject {
failUtterance(message: NSLocalizedString("flow.error.audioUnavailable", comment: ""))
return
}
+ // Mirror `LiveDictationController.start`: only begin when the previous
+ // utterance fully finished. Never cancel an in-flight analyzer here —
+ // that was the source of intermittent CancellationError / noSpeech.
+ guard !isUtteranceProcessing else {
+ debug("beginUtterance ignored — previous utterance still processing")
+ return
+ }
- finalizeTask?.cancel()
- asrTask?.cancel()
- asr.cancel()
currentPartial = ""
lastFinal = ""
@@ -271,6 +278,8 @@ final class FlowSessionManager: ObservableObject {
self.debug("asr error: \(message)")
if self.isUtteranceRecording {
self.failUtterance(message: message)
+ } else if self.isUtteranceProcessing {
+ self.finishProcessing(withError: message)
}
}
}
@@ -280,9 +289,18 @@ final class FlowSessionManager: ObservableObject {
}
private func endUtterance() {
- isUtteranceRecording = false
+ guard isUtteranceRecording else { return }
+
+ // Close the mic gate first, then mark processing before dropping the
+ // recording flag so the poll loop cannot start a second utterance.
capture.endUtterance()
FlowSessionBridge.setRecordingState(.processing)
+ isUtteranceRecording = false
+ isUtteranceProcessing = true
+
+ // Do NOT cancel `asrTask` or `asr` — the preview pipeline relies on
+ // the consumer staying alive until `.final` lands (see
+ // `PreviewASRControllerStateTests`).
finalizeTask?.cancel()
finalizeTask = Task { @MainActor [weak self] in
@@ -293,7 +311,9 @@ final class FlowSessionManager: ObservableObject {
private func abortUtterance() {
isUtteranceRecording = false
+ isUtteranceProcessing = false
finalizeTask?.cancel()
+ finalizeTask = nil
asrTask?.cancel()
asr.cancel()
capture.cancelUtterance()
@@ -305,19 +325,40 @@ final class FlowSessionManager: ObservableObject {
private func failUtterance(message: String) {
isUtteranceRecording = false
+ isUtteranceProcessing = false
+ finalizeTask?.cancel()
+ finalizeTask = nil
asrTask?.cancel()
asr.cancel()
capture.cancelUtterance()
currentPartial = ""
lastFinal = ""
FlowSessionBridge.storeTranscriptionError(message)
+ FlowSessionBridge.setRecordingState(.idle)
debug("utterance failed: \(message)")
}
+ private func finishProcessing(withError message: String) {
+ isUtteranceProcessing = false
+ finalizeTask?.cancel()
+ finalizeTask = nil
+ currentPartial = ""
+ lastFinal = ""
+ FlowSessionBridge.storeTranscriptionError(message)
+ FlowSessionBridge.setRecordingState(.idle)
+ debug("utterance processing failed: \(message)")
+ }
+
private func finalizeUtterance() async {
+ defer {
+ isUtteranceProcessing = false
+ FlowSessionBridge.setRecordingState(.idle)
+ }
+
let deadline = Date().addingTimeInterval(30)
while Date() < deadline {
if !lastFinal.isEmpty { break }
+ if asrTask?.isCancelled == true { break }
try? await Task.sleep(nanoseconds: 100_000_000)
}
@@ -326,8 +367,11 @@ final class FlowSessionManager: ObservableObject {
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !text.isEmpty else {
+ let key = (asrTask?.isCancelled == true)
+ ? "flow.error.recognitionInterrupted"
+ : "flow.error.noSpeech"
FlowSessionBridge.storeTranscriptionError(
- NSLocalizedString("flow.error.noSpeech", comment: "")
+ NSLocalizedString(key, comment: "")
)
return
}
@@ -336,9 +380,11 @@ final class FlowSessionManager: ObservableObject {
let modeId = store.modeId
let shouldPolish = engineMode != "local" && modeId == "polish"
+ var delivered = text
if shouldPolish {
do {
let polished = try await polisher.polish(text)
+ delivered = polished
FlowSessionBridge.storeTranscriptionResult(polished)
} catch {
FlowSessionBridge.storeTranscriptionResult(text)
@@ -347,6 +393,8 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionResult(text)
}
+ SpeechHistoryStore.shared.append(text: delivered, engineMode: engineMode)
+
currentPartial = ""
lastFinal = ""
debug("utterance finalized length=\(text.count)")
diff --git a/OSGKeyboard/Services/SpeechHistoryStore.swift b/OSGKeyboard/Services/SpeechHistoryStore.swift
new file mode 100644
index 0000000..eae0df0
--- /dev/null
+++ b/OSGKeyboard/Services/SpeechHistoryStore.swift
@@ -0,0 +1,81 @@
+// SpeechHistoryStore.swift
+// OSGKeyboard · Main App
+//
+// Local-only log of successful voice transcriptions (Flow + future paths).
+
+import Foundation
+import Combine
+
+struct SpeechHistoryEntry: Codable, Identifiable, Equatable {
+ let id: UUID
+ let text: String
+ let createdAt: Date
+ let engineMode: String
+
+ init(id: UUID = UUID(), text: String, createdAt: Date = Date(), engineMode: String) {
+ self.id = id
+ self.text = text
+ self.createdAt = createdAt
+ self.engineMode = engineMode
+ }
+}
+
+@MainActor
+final class SpeechHistoryStore: ObservableObject {
+ static let shared = SpeechHistoryStore()
+
+ @Published private(set) var entries: [SpeechHistoryEntry] = []
+
+ private let defaults: UserDefaults
+ private let storageKey = "speechHistory.entries.v1"
+ private let maxEntries = 500
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ load()
+ }
+
+ func append(text: String, engineMode: String) {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+
+ let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
+ entries.insert(entry, at: 0)
+ if entries.count > maxEntries {
+ entries = Array(entries.prefix(maxEntries))
+ }
+ persist()
+ }
+
+ func clearAll() {
+ entries.removeAll()
+ persist()
+ }
+
+ /// Entries grouped by calendar day (newest day first).
+ var groupedByDay: [(day: Date, items: [SpeechHistoryEntry])] {
+ let calendar = Calendar.current
+ var buckets: [Date: [SpeechHistoryEntry]] = [:]
+ for entry in entries {
+ let day = calendar.startOfDay(for: entry.createdAt)
+ buckets[day, default: []].append(entry)
+ }
+ return buckets.keys.sorted(by: >).map { day in
+ (day, buckets[day]!.sorted { $0.createdAt > $1.createdAt })
+ }
+ }
+
+ private func load() {
+ guard let data = defaults.data(forKey: storageKey) else { return }
+ do {
+ entries = try JSONDecoder().decode([SpeechHistoryEntry].self, from: data)
+ } catch {
+ entries = []
+ }
+ }
+
+ private func persist() {
+ guard let data = try? JSONEncoder().encode(entries) else { return }
+ defaults.set(data, forKey: storageKey)
+ }
+}
diff --git a/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift
index 8e04db1..9716894 100644
--- a/OSGKeyboard/Views/APISettingsCard.swift
+++ b/OSGKeyboard/Views/APISettingsCard.swift
@@ -17,7 +17,7 @@ struct APISettingsCard: View {
private enum TestStatus: Equatable {
case idle
case running
- case success(String)
+ case success
case failure(String)
}
@@ -48,16 +48,15 @@ struct APISettingsCard: View {
UIApplication.shared.open(url)
} label: {
HStack {
- Image(systemName: "key.fill")
- .foregroundStyle(palette.accent)
Text("api.getKey")
+ .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -104,7 +103,7 @@ struct APISettingsCard: View {
.foregroundStyle(palette.textPrimary)
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
}
// MARK: - Generic field
@@ -120,12 +119,6 @@ struct APISettingsCard: View {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
- // `.asciiCapable` is the *minimum* contract for these fields:
- // API keys, base URLs, and model names are all ASCII by spec,
- // and SwiftUI's `.URL` keyboard can still occasionally
- // hands control to the system keyboard which then auto-suggests
- // Chinese / emoji completions that corrupt the value. Forcing
- // `.asciiCapable` keeps the system out of the way.
TextField(placeholder, text: text)
.keyboardType(.asciiCapable)
.autocorrectionDisabled(true)
@@ -137,7 +130,7 @@ struct APISettingsCard: View {
and dismissing the sheet unexpectedly */ }
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
}
// MARK: - Test connection
@@ -146,20 +139,18 @@ struct APISettingsCard: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("api.connection")
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
Spacer()
Button(action: runTest) {
- HStack(spacing: 6) {
+ Group {
if testStatus == .running {
ProgressView().controlSize(.mini)
} else {
- Image(systemName: testIcon)
+ Text(testButtonLabel)
+ .font(TypeStyle.body)
.foregroundStyle(testTint)
}
- Text(testButtonLabel)
- .font(TypeStyle.caption)
- .foregroundStyle(testTint)
}
}
.buttonStyle(.plain)
@@ -173,7 +164,8 @@ struct APISettingsCard: View {
}
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .padding(.vertical, Spacing.xs)
+ .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
}
private var testButtonLabel: String {
@@ -185,27 +177,18 @@ struct APISettingsCard: View {
}
}
- private var testIcon: String {
- switch testStatus {
- case .idle, .running: return "bolt.horizontal.circle"
- case .success: return "checkmark.circle.fill"
- case .failure: return "exclamationmark.triangle.fill"
- }
- }
-
private var testTint: Color {
switch testStatus {
case .idle, .running: return palette.accent
- case .success: return palette.success
+ case .success: return palette.accent
case .failure: return palette.danger
}
}
private var testDetail: String? {
switch testStatus {
- case .idle, .running: return nil
- case .success(let s): return s
- case .failure(let s): return s
+ case .idle, .running, .success: return nil
+ case .failure(let message): return message
}
}
@@ -219,15 +202,8 @@ struct APISettingsCard: View {
)
Task {
do {
- let reply = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
- // Interpolated success message — LocalizedStringKey can't
- // take %@, so we use `String.localizedStringWithFormat`
- // with a key that has %@ in the value.
- let preview = String(reply.prefix(60))
- testStatus = .success(String.localizedStringWithFormat(
- NSLocalizedString("api.test.connectedWith", comment: "Connected test prefix"),
- preview
- ))
+ _ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
+ testStatus = .success
} catch LLMError.noAPIKey {
testStatus = .failure(NSLocalizedString("api.test.missing", comment: ""))
} catch let error as LLMError {
diff --git a/OSGKeyboard/Views/Components/MaterialIcon.swift b/OSGKeyboard/Views/Components/MaterialIcon.swift
new file mode 100644
index 0000000..58155cd
--- /dev/null
+++ b/OSGKeyboard/Views/Components/MaterialIcon.swift
@@ -0,0 +1,90 @@
+// MaterialIcon.swift
+// OSGKeyboard · Main App
+//
+// Google Material Icons (bundled MaterialIcons-Regular.ttf).
+// Resolves the PostScript name at runtime and falls back to SF Symbols.
+
+import SwiftUI
+import UIKit
+import CoreText
+
+enum MaterialIconName {
+ case keyboard
+ case menuBook
+ case settings
+ case chevronRight
+ case openInNew
+
+ var codepoint: UInt32 {
+ switch self {
+ case .keyboard: return 0xE312
+ case .menuBook: return 0xEA19
+ case .settings: return 0xE8B8
+ case .chevronRight: return 0xE5CC
+ case .openInNew: return 0xE89E
+ }
+ }
+
+ var sfSymbol: String {
+ switch self {
+ case .keyboard: return "keyboard"
+ case .menuBook: return "book"
+ case .settings: return "gearshape"
+ case .chevronRight: return "chevron.right"
+ case .openInNew: return "arrow.up.right.square"
+ }
+ }
+}
+
+enum MaterialIconsFont {
+ private nonisolated(unsafe) static var cachedName: String?
+
+ static var postScriptName: String? {
+ if let cachedName { return cachedName }
+ let candidates = [
+ "MaterialIcons-Regular",
+ "Material Icons Regular",
+ "Material Icons"
+ ]
+ for name in candidates where UIFont(name: name, size: 17) != nil {
+ cachedName = name
+ return name
+ }
+ for family in UIFont.familyNames where family.localizedCaseInsensitiveContains("material") {
+ for name in UIFont.fontNames(forFamilyName: family) {
+ cachedName = name
+ return name
+ }
+ }
+ return nil
+ }
+
+ static func registerIfNeeded() {
+ guard postScriptName == nil else { return }
+ guard let url = Bundle.main.url(forResource: "MaterialIcons-Regular", withExtension: "ttf") else {
+ return
+ }
+ var error: Unmanaged?
+ CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error)
+ cachedName = nil
+ _ = postScriptName
+ }
+}
+
+struct MaterialIcon: View {
+ let name: MaterialIconName
+ var size: CGFloat = 24
+
+ var body: some View {
+ Group {
+ if let fontName = MaterialIconsFont.postScriptName,
+ let scalar = UnicodeScalar(name.codepoint) {
+ Text(String(scalar))
+ .font(.custom(fontName, size: size))
+ } else {
+ Image(systemName: name.sfSymbol)
+ .font(.system(size: size * 0.92, weight: .regular))
+ }
+ }
+ }
+}
diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift
new file mode 100644
index 0000000..777b04a
--- /dev/null
+++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift
@@ -0,0 +1,64 @@
+// MinimalTabBar.swift
+// OSGKeyboard · Main App
+//
+// Bottom tab bar — three Material icons, no labels.
+// Capsule uses iOS 26 Liquid Glass (.regular.interactive) so content
+// behind the dock refracts through on scroll.
+
+import SwiftUI
+import OSGKeyboardShared
+
+enum AppTab: Int, CaseIterable {
+ case keyboard
+ case history
+ case settings
+
+ var icon: MaterialIconName {
+ switch self {
+ case .keyboard: return .keyboard
+ case .history: return .menuBook
+ case .settings: return .settings
+ }
+ }
+
+ var accessibilityKey: LocalizedStringKey {
+ switch self {
+ case .keyboard: return "tab.keyboard"
+ case .history: return "tab.history"
+ case .settings: return "tab.settings"
+ }
+ }
+}
+
+struct MinimalTabBar: View {
+ @Environment(\.themePalette) private var palette: ThemePalette
+ @Binding var selection: AppTab
+
+ var body: some View {
+ HStack(spacing: 0) {
+ ForEach(AppTab.allCases, id: \.rawValue) { tab in
+ Button {
+ withAnimation(Motion.quick) { selection = tab }
+ } label: {
+ MaterialIcon(
+ name: tab.icon,
+ size: 24
+ )
+ .foregroundStyle(selection == tab ? palette.accent : palette.textTertiary)
+ .frame(maxWidth: .infinity)
+ .frame(height: 48)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(tab.accessibilityKey)
+ .accessibilityAddTraits(selection == tab ? .isSelected : [])
+ }
+ }
+ .padding(.horizontal, Spacing.md)
+ .padding(.vertical, Spacing.sm)
+ .glassEffect(.regular.interactive(), in: .capsule)
+ .frame(maxWidth: 252)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.bottom, Spacing.xs)
+ }
+}
diff --git a/OSGKeyboard/Views/Components/SafariSheet.swift b/OSGKeyboard/Views/Components/SafariSheet.swift
new file mode 100644
index 0000000..f287d3b
--- /dev/null
+++ b/OSGKeyboard/Views/Components/SafariSheet.swift
@@ -0,0 +1,22 @@
+// SafariSheet.swift
+// OSGKeyboard · Main App
+
+import SafariServices
+import SwiftUI
+import OSGKeyboardShared
+
+extension URL: @retroactive Identifiable {
+ public var id: String { absoluteString }
+}
+
+struct SafariSheet: UIViewControllerRepresentable {
+ let url: URL
+
+ func makeUIViewController(context: Context) -> SFSafariViewController {
+ let controller = SFSafariViewController(url: url)
+ controller.preferredControlTintColor = UIColor(Palette.accent)
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
+}
diff --git a/OSGKeyboard/Views/EnginePickerSection.swift b/OSGKeyboard/Views/EnginePickerSection.swift
index 15ca6b5..ebd43a3 100644
--- a/OSGKeyboard/Views/EnginePickerSection.swift
+++ b/OSGKeyboard/Views/EnginePickerSection.swift
@@ -24,22 +24,19 @@ struct EnginePickerSection: View {
@ObservedObject var config: ProviderConfig
var body: some View {
- VStack(alignment: .leading, spacing: Spacing.xs) {
- sectionHeader(
- "settings.engine.title",
- subtitle: "settings.engine.subtitle"
- )
+ VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
+ sectionHeader("settings.engine.title")
VStack(spacing: 0) {
engineOptionRow(
id: "local",
- icon: "iphone.badge.checkmark",
+ assetName: "apple",
title: NSLocalizedString("settings.engine.local.title", comment: ""),
subtitle: localSubtitle
)
Divider().background(palette.divider)
engineOptionRow(
id: "cloud",
- icon: "wand.and.stars",
+ systemIcon: "wand.and.stars",
title: NSLocalizedString("settings.engine.cloud.title", comment: ""),
subtitle: NSLocalizedString("settings.engine.cloud.subtitle", comment: "")
)
@@ -58,7 +55,13 @@ struct EnginePickerSection: View {
NSLocalizedString("settings.engine.local.ios26", comment: "")
}
- private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View {
+ private func engineOptionRow(
+ id: String,
+ assetName: String? = nil,
+ systemIcon: String? = nil,
+ title: String,
+ subtitle: String
+ ) -> some View {
let isSelected = config.engineMode == id
return Button {
withAnimation(.easeInOut(duration: 0.2)) {
@@ -67,10 +70,11 @@ struct EnginePickerSection: View {
}
} label: {
HStack(spacing: Spacing.sm) {
- Image(systemName: icon)
- .font(.system(size: 18, weight: .medium))
- .foregroundStyle(isSelected ? palette.accent : palette.textSecondary)
- .frame(width: 28)
+ engineMark(
+ assetName: assetName,
+ systemIcon: systemIcon,
+ isSelected: isSelected
+ )
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.body)
@@ -87,23 +91,39 @@ struct EnginePickerSection: View {
}
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
- private func sectionHeader(_ title: LocalizedStringKey, subtitle: LocalizedStringKey) -> some View {
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textSecondary)
- .textCase(.uppercase)
- Text(subtitle)
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textTertiary)
+ private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
+ ZStack {
+ Circle()
+ .fill(isSelected ? palette.accentMuted : palette.surfaceElevated)
+ .frame(width: 32, height: 32)
+ if let assetName {
+ Image(assetName)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 18, height: 18)
+ .foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
+ } else if let systemIcon {
+ Image(systemName: systemIcon)
+ .font(.system(size: 16, weight: .medium))
+ .foregroundStyle(isSelected ? palette.accent : palette.textSecondary)
+ }
}
- .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(width: 32, height: 32)
+ }
+
+ @ViewBuilder
+ private func sectionHeader(_ title: LocalizedStringKey) -> some View {
+ Text(title)
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textSecondary)
+ .textCase(.uppercase)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}
diff --git a/OSGKeyboard/Views/HistoryView.swift b/OSGKeyboard/Views/HistoryView.swift
new file mode 100644
index 0000000..c6660b5
--- /dev/null
+++ b/OSGKeyboard/Views/HistoryView.swift
@@ -0,0 +1,133 @@
+// HistoryView.swift
+// OSGKeyboard · Main App
+
+import SwiftUI
+import OSGKeyboardShared
+
+struct HistoryView: View {
+ @Environment(\.themePalette) private var palette: ThemePalette
+ @ObservedObject private var store = SpeechHistoryStore.shared
+ @State private var showClearConfirm = false
+
+ private static let dayFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateStyle = .medium
+ f.timeStyle = .none
+ return f
+ }()
+
+ private static let timeFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateStyle = .none
+ f.timeStyle = .short
+ return f
+ }()
+
+ var body: some View {
+ NavigationStack {
+ ZStack {
+ palette.background.ignoresSafeArea()
+
+ if store.entries.isEmpty {
+ emptyState
+ } else {
+ VStack(spacing: 0) {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: Spacing.xl) {
+ ForEach(store.groupedByDay, id: \.day) { group in
+ daySection(day: group.day, items: group.items)
+ }
+ }
+ .padding(.horizontal, Spacing.md)
+ .padding(.vertical, Spacing.md)
+ .padding(.bottom, 100)
+ }
+
+ clearFooter
+ }
+ }
+ }
+ .navigationTitle(LocalizedStringKey("history.title"))
+ .navigationBarTitleDisplayMode(.inline)
+ }
+ .confirmationDialog(
+ LocalizedStringKey("history.clear.title"),
+ isPresented: $showClearConfirm,
+ titleVisibility: .visible
+ ) {
+ Button(LocalizedStringKey("history.clear.confirm"), role: .destructive) {
+ store.clearAll()
+ }
+ Button(LocalizedStringKey("common.cancel"), role: .cancel) {}
+ } message: {
+ Text("history.clear.message")
+ }
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: Spacing.sm) {
+ Spacer()
+ MaterialIcon(name: .menuBook, size: 36)
+ .foregroundStyle(palette.textTertiary.opacity(0.5))
+ Text("history.empty")
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textSecondary)
+ .multilineTextAlignment(.center)
+ Spacer()
+ }
+ .padding(.horizontal, Spacing.xl)
+ }
+
+ private func daySection(day: Date, items: [SpeechHistoryEntry]) -> some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ Text(Self.dayFormatter.string(from: day))
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ .textCase(.uppercase)
+ .tracking(0.5)
+
+ VStack(spacing: 0) {
+ ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
+ historyRow(entry)
+ if index < items.count - 1 {
+ Divider().background(palette.divider)
+ }
+ }
+ }
+ .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(palette.divider, lineWidth: 0.5)
+ )
+ }
+ }
+
+ private func historyRow(_ entry: SpeechHistoryEntry) -> some View {
+ VStack(alignment: .leading, spacing: Spacing.xxs) {
+ Text(Self.timeFormatter.string(from: entry.createdAt))
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ .monospacedDigit()
+ Text(entry.text)
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(Spacing.md)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var clearFooter: some View {
+ Button(role: .destructive) {
+ showClearConfirm = true
+ } label: {
+ Text("history.clear.button")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.danger)
+ .frame(maxWidth: .infinity, minHeight: 44)
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, Spacing.md)
+ .padding(.bottom, Spacing.sm)
+ }
+}
diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift
index 73c97a7..c109806 100644
--- a/OSGKeyboard/Views/HomeView.swift
+++ b/OSGKeyboard/Views/HomeView.swift
@@ -1,70 +1,182 @@
// HomeView.swift
// OSGKeyboard · Main App
//
-// Post-onboarding home. Two jobs: (1) tell the user we're ready, and
-// (2) give a clear path to the next setup step if anything is missing.
+// Minimal home: logo, status capsule, flow hints, inline preview field.
import SwiftUI
import OSGKeyboardShared
-import OSGKeyboardExt
struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette
- @ObservedObject var config = ProviderConfig.shared
+ @ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager
- @State private var showSettings = false
- @State private var showKeyboardPreview = false
+ @FocusState private var previewFocused: Bool
+ @State private var previewText = ""
+
+ private var sessionIsLive: Bool {
+ flowManager.isActive || flowManager.isStarting
+ }
var body: some View {
- ZStack {
- palette.background.ignoresSafeArea()
- VStack(spacing: 0) {
- statusHeader
- .padding(.top, Spacing.xl)
- flowSessionCard
- .padding(.horizontal, Spacing.md)
- .padding(.top, Spacing.md)
- Spacer()
- heroButton
- Spacer()
- actionStack
- .padding(.horizontal, Spacing.md)
- .padding(.bottom, Spacing.lg)
+ GeometryReader { geo in
+ let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
+
+ ZStack(alignment: .top) {
+ sessionHeaderGradient(height: gradientHeight)
+ .ignoresSafeArea(edges: .top)
+ .allowsHitTesting(false)
+
+ VStack(spacing: 0) {
+ logoHeader
+ .padding(.top, Spacing.xxxl)
+ .padding(.bottom, Spacing.xl)
+
+ flowSessionExtras
+ .padding(.horizontal, Spacing.lg)
+ .padding(.bottom, Spacing.lg)
+
+ previewField
+ .padding(.horizontal, Spacing.lg)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+
+ engineStatusLine
+ .padding(.horizontal, Spacing.lg)
+ .padding(.top, Spacing.md)
+ .padding(.bottom, Spacing.xs)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
- }
- .sheet(isPresented: $showSettings) {
- SettingsView()
- }
- .sheet(isPresented: $showKeyboardPreview) {
- KeyboardPreviewSheet()
+ .background(palette.background)
}
}
- // MARK: - Flow session
+ // MARK: - Top gradient
- private var flowSessionCard: some View {
- VStack(alignment: .leading, spacing: Spacing.xs) {
- HStack {
- Circle()
- .fill(flowStatusColor)
- .frame(width: 8, height: 8)
- Text(flowStatusTitle)
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- Spacer()
- if flowManager.isActive, let expires = flowManager.sessionExpiresAt {
- Text(expires, style: .timer)
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textTertiary)
- .monospacedDigit()
+ private func sessionHeaderGradient(height: CGFloat) -> some View {
+ LinearGradient(
+ colors: sessionIsLive
+ ? [
+ palette.accent.opacity(0.28),
+ palette.accent.opacity(0.10),
+ palette.background.opacity(0)
+ ]
+ : [
+ palette.textTertiary.opacity(0.14),
+ palette.textTertiary.opacity(0.05),
+ palette.background.opacity(0)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(maxWidth: .infinity)
+ .frame(height: height)
+ .animation(Motion.soft, value: sessionIsLive)
+ }
+
+ // MARK: - Header
+
+ private var logoHeader: some View {
+ VStack(spacing: Spacing.xxl) {
+ Image("osglogo")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 120, height: 34)
+ .accessibilityHidden(true)
+
+ statusCapsule
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, Spacing.lg)
+ }
+
+ private var statusCapsule: some View {
+ HStack(spacing: Spacing.sm) {
+ Text(statusLine)
+ .font(TypeStyle.status)
+ .foregroundStyle(palette.textSecondary)
+ .fixedSize()
+
+ capsuleDivider
+
+ flowCapsuleSegment
+
+ if flowManager.isActive {
+ Button {
+ flowManager.endSession()
+ } label: {
+ Text("home.flow.endShort")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textOnAccent)
+ .padding(.horizontal, Spacing.sm)
+ .padding(.vertical, 5)
+ .background(palette.accent, in: Capsule())
}
+ .buttonStyle(.plain)
}
- Text("home.flow.hint")
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textTertiary)
- .fixedSize(horizontal: false, vertical: true)
- if let warning = flowManager.sessionWarning {
+ }
+ .padding(.horizontal, Spacing.md)
+ .padding(.vertical, Spacing.sm)
+ .background(capsuleBackground, in: Capsule())
+ .overlay(
+ Capsule()
+ .stroke(palette.divider, lineWidth: 0.5)
+ )
+ .animation(Motion.soft, value: flowManager.isActive)
+ }
+
+ private var capsuleDivider: some View {
+ Circle()
+ .fill(palette.dividerStrong)
+ .frame(width: 3, height: 3)
+ }
+
+ @ViewBuilder
+ private var flowCapsuleSegment: some View {
+ HStack(spacing: Spacing.xs) {
+ Circle()
+ .fill(flowStatusColor)
+ .frame(width: 6, height: 6)
+
+ if flowManager.isActive, let expires = flowManager.sessionExpiresAt {
+ Text("home.flow.label")
+ .font(TypeStyle.status)
+ .foregroundStyle(palette.textPrimary)
+ Text(":")
+ .font(TypeStyle.status)
+ .foregroundStyle(palette.textTertiary)
+ Text(expires, style: .timer)
+ .font(TypeStyle.status)
+ .foregroundStyle(palette.textSecondary)
+ .monospacedDigit()
+ } else {
+ Text(flowStatusTitle)
+ .font(TypeStyle.status)
+ .foregroundStyle(palette.textPrimary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.85)
+ }
+ }
+ }
+
+ private var capsuleBackground: Color {
+ sessionIsLive
+ ? palette.accentMuted.opacity(0.55)
+ : palette.surface.opacity(0.88)
+ }
+
+ private var statusLine: String {
+ config.isConfigured
+ ? NSLocalizedString("home.status.ready", comment: "")
+ : NSLocalizedString("home.status.setupIncomplete", comment: "")
+ }
+
+ // MARK: - Flow extras (warnings / hint)
+
+ @ViewBuilder
+ private var flowSessionExtras: some View {
+ if let warning = flowManager.sessionWarning {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
Text(warning)
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
@@ -80,30 +192,28 @@ struct HomeView: View {
.buttonStyle(.plain)
}
}
- if flowManager.isActive {
- Button {
- flowManager.endSession()
- } label: {
- Text("home.flow.end")
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- }
- .buttonStyle(.plain)
- }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(Spacing.md)
+ .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(palette.divider, lineWidth: 0.5)
+ )
+ } else if !flowManager.isActive {
+ Text("home.flow.hint")
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, Spacing.sm)
}
- .padding(Spacing.sm)
- .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 flowStatusColor: Color {
- if flowManager.isActive { return palette.success }
+ if flowManager.isActive { return palette.accent }
if flowManager.isStarting { return palette.accent }
if flowManager.sessionWarning != nil { return palette.warning }
- return palette.warning
+ return palette.textTertiary
}
private var flowStatusTitle: LocalizedStringKey {
@@ -112,122 +222,35 @@ struct HomeView: View {
return "home.flow.inactive"
}
- // MARK: - Header
+ // MARK: - Preview field
- private var statusHeader: some View {
- VStack(spacing: Spacing.xs) {
- HStack(spacing: 6) {
- Circle()
- .fill(config.isConfigured ? palette.success : palette.warning)
- .frame(width: 8, height: 8)
- Text(config.isConfigured ? "home.status.ready" : "home.status.setupIncomplete")
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- }
- Text("OSGKeyboard")
- .font(TypeStyle.largeTitle)
- .foregroundStyle(palette.textPrimary)
- Text(providerLine)
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- .multilineTextAlignment(.center)
- }
+ private var previewField: some View {
+ TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ .tint(palette.accent)
+ .focused($previewFocused)
+ .lineLimit(1...100)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .padding(Spacing.md)
+ .background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
+ .stroke(previewFocused ? palette.dividerStrong : palette.divider, lineWidth: 0.5)
+ )
}
- private var providerLine: String {
- let p = LLMProvider.provider(id: config.providerId)
- let model = config.model.isEmpty ? "—" : config.model
- return "\(p.name) · \(model)"
- }
-
- // MARK: - Hero
-
- private var heroButton: some View {
- Button {
- showSettings = true
- } label: {
- ZStack {
- Circle()
- .fill(palette.accentMuted)
- .frame(width: 220, height: 220)
- .blur(radius: 40)
- Circle()
- .fill(LinearGradient(
- colors: [palette.surfaceElevated, palette.surface],
- startPoint: .top,
- endPoint: .bottom
- ))
- .frame(width: 160, height: 160)
- .overlay(
- Circle().stroke(palette.accent.opacity(0.35), lineWidth: 1.5)
- )
- .shadow(color: palette.accent.opacity(0.18), radius: 24, y: 8)
- VStack(spacing: 6) {
- Image(systemName: "waveform")
- .font(.system(size: 36, weight: .light))
- .foregroundStyle(palette.accent)
- Text("home.hero.label")
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- }
- }
- }
- .buttonStyle(.plain)
- .accessibilityLabel(Text("home.action.openSettingsA11y"))
- }
-
- // MARK: - Actions
-
- private var actionStack: some View {
- VStack(spacing: Spacing.xs) {
- Button {
- if let url = URL(string: UIApplication.openSettingsURLString) {
- UIApplication.shared.open(url)
- }
- } label: {
- Label("home.action.enableInSettings", systemImage: "keyboard")
- .primaryButton()
- }
- .buttonStyle(.plain)
-
- Button {
- showSettings = true
- } label: {
- Label("home.action.editApi", systemImage: "slider.horizontal.3")
- .secondaryButton()
- }
- .buttonStyle(.plain)
-
- #if DEBUG
- Button {
- showKeyboardPreview = true
- } label: {
- Label("home.action.keyboardPreview", systemImage: "eye")
- .secondaryButton()
- }
- .buttonStyle(.plain)
- #endif
-
- HStack(spacing: Spacing.xs) {
- Button {
- if let url = URL(string: "https://github.com/hkgood/OSGKeyboard") {
- UIApplication.shared.open(url)
- }
- } label: {
- Text("GitHub")
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textSecondary)
- .padding(.horizontal, Spacing.sm)
- .padding(.vertical, 6)
- .background(palette.surface, in: Capsule())
- .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
- }
- .buttonStyle(.plain)
- Spacer()
- Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")")
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textTertiary)
- }
- }
+ private var engineStatusLine: some View {
+ Text(
+ EngineServiceLabel.summary(
+ engineMode: config.engineMode,
+ providerId: config.providerId,
+ model: config.model
+ )
+ )
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textSecondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, alignment: .center)
}
}
diff --git a/OSGKeyboard/Views/MainTabView.swift b/OSGKeyboard/Views/MainTabView.swift
new file mode 100644
index 0000000..ec00de5
--- /dev/null
+++ b/OSGKeyboard/Views/MainTabView.swift
@@ -0,0 +1,36 @@
+// MainTabView.swift
+// OSGKeyboard · Main App
+
+import SwiftUI
+import OSGKeyboardShared
+
+struct MainTabView: View {
+ @Environment(\.themePalette) private var palette: ThemePalette
+ @EnvironmentObject private var flowManager: FlowSessionManager
+
+ @State private var tab: AppTab = .keyboard
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ palette.background.ignoresSafeArea()
+
+ Group {
+ switch tab {
+ case .keyboard:
+ HomeView()
+ case .history:
+ HistoryView()
+ case .settings:
+ SettingsView(presentation: .tab)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .safeAreaInset(edge: .bottom, spacing: 0) {
+ Color.clear.frame(height: 88)
+ }
+
+ MinimalTabBar(selection: $tab)
+ }
+ .onAppear { flowManager.autoStartIfNeeded() }
+ }
+}
diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift
index 97aad18..472ed0e 100644
--- a/OSGKeyboard/Views/OnboardingView.swift
+++ b/OSGKeyboard/Views/OnboardingView.swift
@@ -31,29 +31,38 @@ struct OnboardingView: View {
@State private var speechStatus = AppPermissions.speechStatus
var body: some View {
- ZStack {
- palette.background.ignoresSafeArea()
- VStack(spacing: 0) {
- Group {
- switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
- case .welcome: WelcomePage()
- case .microphone:
- MicPermissionPage(status: $micStatus)
- case .speech:
- SpeechPermissionPage(status: $speechStatus)
- case .keyboard: EnableKeyboardPage()
- case .api: APISetupPage(config: config)
+ GeometryReader { geo in
+ let gradientHeight = geo.size.height * 0.28 + geo.safeAreaInsets.top
+
+ ZStack(alignment: .top) {
+ palette.background.ignoresSafeArea()
+
+ VStack(spacing: 0) {
+ Group {
+ switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
+ case .welcome: WelcomePage()
+ case .microphone:
+ MicPermissionPage(status: $micStatus)
+ case .speech:
+ SpeechPermissionPage(status: $speechStatus)
+ case .keyboard: EnableKeyboardPage()
+ case .api: APISetupPage(config: config)
+ }
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .transition(.opacity.combined(with: .move(edge: .trailing)))
+
+ pageDots
+ .padding(.bottom, Spacing.md)
+
+ bottomBar
+ .padding(.horizontal, Spacing.lg)
+ .padding(.bottom, Spacing.lg)
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .transition(.opacity.combined(with: .move(edge: .trailing)))
- pageDots
- .padding(.bottom, Spacing.md)
-
- bottomBar
- .padding(.horizontal, Spacing.md)
- .padding(.bottom, Spacing.lg)
+ onboardingHeaderGradient(height: gradientHeight)
+ .ignoresSafeArea(edges: .top)
+ .allowsHitTesting(false)
}
}
.onAppear {
@@ -67,6 +76,20 @@ struct OnboardingView: View {
}
}
+ private func onboardingHeaderGradient(height: CGFloat) -> some View {
+ LinearGradient(
+ colors: [
+ palette.textTertiary.opacity(0.14),
+ palette.textTertiary.opacity(0.05),
+ palette.background.opacity(0)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(maxWidth: .infinity)
+ .frame(height: height)
+ }
+
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
@@ -76,7 +99,7 @@ struct OnboardingView: View {
HStack(spacing: 6) {
ForEach(0.. some View {
- HStack(alignment: .top, spacing: Spacing.xs) {
- Image(systemName: icon)
- .font(.system(size: 14, weight: .medium))
- .foregroundStyle(palette.accent)
- .frame(width: 24, height: 24)
- .background(palette.accentMuted, in: Circle())
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textPrimary)
- Text(body)
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textSecondary)
- }
+ private func footnoteBlock(title: LocalizedStringKey, body: LocalizedStringKey) -> some View {
+ VStack(alignment: .leading, spacing: Spacing.xs) {
+ Text(title)
+ .font(TypeStyle.bodyEmph)
+ .foregroundStyle(palette.textPrimary)
+ Text(body)
+ .font(TypeStyle.footnote)
+ .foregroundStyle(palette.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .lineSpacing(3)
}
}
}
@@ -256,7 +292,7 @@ private struct MicPermissionPage: View {
private var statusColor: Color {
switch status {
- case .granted: return palette.success
+ case .granted: return palette.accent
case .denied: return palette.warning
case .undetermined: return palette.textTertiary
}
@@ -316,7 +352,7 @@ private struct SpeechPermissionPage: View {
private var statusColor: Color {
switch status {
- case .granted: return palette.success
+ case .granted: return palette.accent
case .denied, .restricted: return palette.warning
case .undetermined: return palette.textTertiary
}
@@ -352,11 +388,11 @@ private struct PermissionPageLayout: View {
var deniedHint: LocalizedStringKey? = nil
var body: some View {
- VStack(spacing: Spacing.lg) {
+ VStack(spacing: Spacing.xl) {
Spacer()
- Image(systemName: icon)
- .font(.system(size: 64, weight: .light))
- .foregroundStyle(palette.accent)
+
+ OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40)
+
VStack(spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.title2)
@@ -365,28 +401,32 @@ private struct PermissionPageLayout: View {
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
- .padding(.horizontal, Spacing.lg)
+ .padding(.horizontal, Spacing.xl)
}
+
HStack(spacing: 6) {
Circle().fill(statusColor).frame(width: 8, height: 8)
Text(status)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
+
if let deniedHint {
Text(deniedHint)
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.multilineTextAlignment(.center)
- .padding(.horizontal, Spacing.lg)
+ .padding(.horizontal, Spacing.xl)
}
- VStack(spacing: Spacing.xs) {
+
+ VStack(spacing: Spacing.sm) {
Button(action: onPrimary) {
Text(primaryTitle)
.primaryButton()
}
.buttonStyle(.plain)
.disabled(primaryDisabled)
+
if let secondaryTitle, let onSecondary {
Button(action: onSecondary) {
Text(secondaryTitle)
@@ -395,7 +435,8 @@ private struct PermissionPageLayout: View {
.buttonStyle(.plain)
}
}
- .padding(.horizontal, Spacing.md)
+ .padding(.horizontal, Spacing.lg)
+
Spacer()
}
}
@@ -409,9 +450,9 @@ private struct EnableKeyboardPage: View {
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
- Image(systemName: "keyboard.fill")
- .font(.system(size: 64, weight: .light))
- .foregroundStyle(palette.accent)
+
+ OnboardingHeroIcon(systemName: "keyboard.fill", circleSize: 96, iconSize: 40)
+
VStack(spacing: Spacing.sm) {
Text("onboarding.enable.title")
.font(TypeStyle.title2)
@@ -422,14 +463,16 @@ private struct EnableKeyboardPage: View {
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg)
}
- VStack(alignment: .leading, spacing: Spacing.sm) {
+
+ VStack(alignment: .leading, spacing: Spacing.lg) {
step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: ""))
step(num: 2, text: NSLocalizedString("onboarding.enable.step2", comment: ""))
step(num: 3, text: NSLocalizedString("onboarding.enable.step3", comment: ""))
step(num: 4, text: NSLocalizedString("onboarding.enable.step4", comment: ""))
}
- .cardSurface()
- .padding(.horizontal, Spacing.md)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, Spacing.xl)
+ .padding(.top, Spacing.sm)
Button {
AppPermissions.openSystemSettings()
@@ -438,21 +481,25 @@ private struct EnableKeyboardPage: View {
.primaryButton()
}
.buttonStyle(.plain)
- .padding(.horizontal, Spacing.md)
+ .padding(.horizontal, Spacing.lg)
+ .padding(.top, Spacing.xxl)
+
Spacer()
}
}
private func step(num: Int, text: String) -> some View {
- HStack(alignment: .top, spacing: Spacing.xs) {
+ HStack(alignment: .top, spacing: Spacing.sm) {
Text("\(num)")
.font(TypeStyle.caption2)
- .frame(width: 22, height: 22)
+ .frame(width: 24, height: 24)
.background(palette.accent, in: Circle())
.foregroundStyle(palette.textOnAccent)
Text(text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -467,35 +514,38 @@ private struct APISetupPage: View {
var body: some View {
ScrollView {
- VStack(alignment: .leading, spacing: Spacing.md) {
- VStack(alignment: .leading, spacing: Spacing.xxs) {
+ VStack(alignment: .leading, spacing: Spacing.lg) {
+ VStack(alignment: .leading, spacing: Spacing.xs) {
Text("onboarding.api.title")
.font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary)
Text("onboarding.api.subtitle")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
- .padding(.top, Spacing.xxs)
}
- .padding(.horizontal, Spacing.md)
- .padding(.top, Spacing.lg)
+ .padding(.horizontal, Spacing.lg)
+ .padding(.top, Spacing.xxxl)
EnginePickerSection(config: config)
- .padding(.horizontal, Spacing.md)
+ .padding(.horizontal, Spacing.lg)
if config.engineMode == "cloud" {
ProviderPickerSection(config: config)
- .padding(.horizontal, Spacing.md)
+ .padding(.horizontal, Spacing.lg)
APISettingsCard(config: config)
- .padding(.horizontal, Spacing.md)
+ .padding(.horizontal, Spacing.lg)
} else {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: Spacing.sm) {
- Image(systemName: "checkmark.seal.fill")
- .font(.system(size: 18, weight: .medium))
- .foregroundStyle(palette.accent)
- .frame(width: 28)
- VStack(alignment: .leading, spacing: 2) {
+ ZStack {
+ Circle()
+ .fill(palette.accentMuted)
+ .frame(width: 32, height: 32)
+ Image(systemName: "checkmark.seal.fill")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundStyle(palette.accent)
+ }
+ VStack(alignment: .leading, spacing: 4) {
Text("onboarding.api.localReady.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
@@ -505,14 +555,14 @@ private struct APISetupPage: View {
}
Spacer()
}
- .padding(Spacing.md)
+ .padding(Spacing.lg)
}
- .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
+ .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
- RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
- .padding(.horizontal, Spacing.md)
+ .padding(.horizontal, Spacing.lg)
}
}
.padding(.bottom, Spacing.xxxl)
diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift
index 2d70460..4a0be00 100644
--- a/OSGKeyboard/Views/ProviderPickerSection.swift
+++ b/OSGKeyboard/Views/ProviderPickerSection.swift
@@ -1,9 +1,5 @@
// ProviderPickerSection.swift
// OSGKeyboard · Main App
-//
-// Provider picker shown inline inside Settings & Onboarding. Each option
-// surfaces the provider's name + a short blurb so the user can pick
-// confidently without opening a doc.
import SwiftUI
import OSGKeyboardShared
@@ -42,35 +38,57 @@ struct ProviderPickerSection: View {
@ViewBuilder
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
- HStack(spacing: Spacing.xs) {
- // Provider mark — a coloured dot with first letter
- ZStack {
- Circle()
- .fill(selected ? palette.accent : palette.surfaceElevated)
- .frame(width: 36, height: 36)
- Text(String(provider.name.prefix(1)))
- .font(TypeStyle.bodyEmph)
- .foregroundStyle(selected ? palette.textOnAccent : palette.textPrimary)
- }
- VStack(alignment: .leading, spacing: 2) {
- Text(provider.name)
- .font(TypeStyle.bodyEmph)
- .foregroundStyle(palette.textPrimary)
- if let blurb = provider.blurb {
- Text(blurb)
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textTertiary)
- }
- }
- Spacer()
+ HStack(spacing: Spacing.sm) {
+ providerMark(provider, selected: selected)
+
+ Text(ProviderDisplayName.name(for: provider.id))
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+
+ Spacer(minLength: Spacing.xs)
+
if selected {
Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 18, weight: .medium))
+ .font(.system(size: 17, weight: .medium))
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
+
+ @ViewBuilder
+ private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View {
+ ZStack {
+ Circle()
+ .fill(selected ? palette.accentMuted : palette.surfaceElevated)
+ .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)
+ } else {
+ Text(String(provider.name.prefix(1)))
+ .font(TypeStyle.caption)
+ .foregroundStyle(selected ? palette.accent : palette.textSecondary)
+ }
+ }
+ }
+}
+
+enum ProviderLogo {
+ static func assetName(for providerId: String) -> String? {
+ switch providerId {
+ case "openai": return "openai"
+ case "deepseek": return "deepseek"
+ case "qwen": return "qwen"
+ case "moonshot": return "moonshot"
+ case "zhipu": return "zhipu"
+ case "custom": return "custom"
+ default: return nil
+ }
+ }
}
diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift
index a5d475b..021822b 100644
--- a/OSGKeyboard/Views/SettingsView.swift
+++ b/OSGKeyboard/Views/SettingsView.swift
@@ -8,12 +8,24 @@ import SwiftUI
import Speech
import OSGKeyboardShared
+enum SettingsPresentation {
+ case tab
+ case sheet
+}
+
struct SettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config = ProviderConfig.shared
@Environment(\.dismiss) private var dismiss
@State private var showResetConfirm = false
+ @State private var safariURL: URL?
+
+ let presentation: SettingsPresentation
+
+ init(presentation: SettingsPresentation = .sheet) {
+ self.presentation = presentation
+ }
// Dynamic locale list loaded from SFSpeechRecognizer on first appear.
@State private var dynamicLocales: [(id: String, label: String, onDevice: Bool)] = []
@@ -33,23 +45,31 @@ struct SettingsView: View {
if config.engineMode == "cloud" {
promptSection
}
- privacySection
+ if presentation == .tab {
+ footerLinks
+ }
resetButton
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
+ .padding(.bottom, presentation == .tab ? 100 : Spacing.lg)
}
}
.navigationTitle(LocalizedStringKey("settings.title"))
.navigationBarTitleDisplayMode(.inline)
.task { await loadDynamicLocales() }
.toolbar {
- ToolbarItem(placement: .confirmationAction) {
- Button("common.done") { dismiss() }
- .font(TypeStyle.headline)
- .foregroundStyle(palette.accent)
+ if presentation == .sheet {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("common.done") { dismiss() }
+ .font(TypeStyle.headline)
+ .foregroundStyle(palette.accent)
+ }
}
}
+ .sheet(item: $safariURL) { url in
+ SafariSheet(url: url)
+ }
}
.confirmationDialog(
LocalizedStringKey("settings.reset.title"),
@@ -74,8 +94,8 @@ struct SettingsView: View {
// MARK: - Provider
private var providerSection: some View {
- VStack(alignment: .leading, spacing: Spacing.xs) {
- sectionHeader("settings.provider.title", subtitle: "settings.provider.subtitle")
+ VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
+ sectionHeader("settings.provider.title")
ProviderPickerSection(config: config)
}
}
@@ -83,8 +103,8 @@ struct SettingsView: View {
// MARK: - API
private var apiSection: some View {
- VStack(alignment: .leading, spacing: Spacing.xs) {
- sectionHeader("settings.api.title", subtitle: nil)
+ VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
+ sectionHeader("settings.api.title")
APISettingsCard(config: config)
}
}
@@ -92,11 +112,8 @@ struct SettingsView: View {
// MARK: - Language (ASR + mode)
private var languageSection: some View {
- let subtitleKey: LocalizedStringKey = config.engineMode == "cloud"
- ? "settings.language.subtitle.cloud"
- : "settings.language.subtitle.local"
- return VStack(alignment: .leading, spacing: Spacing.xs) {
- sectionHeader("settings.language.title", subtitle: subtitleKey)
+ VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
+ sectionHeader("settings.language.title")
VStack(spacing: 0) {
if config.engineMode == "cloud" {
PickerRow(
@@ -131,7 +148,7 @@ struct SettingsView: View {
HStack(spacing: Spacing.xs) {
Image(systemName: "iphone")
.font(TypeStyle.caption2)
- .foregroundStyle(palette.success)
+ .foregroundStyle(palette.accent)
Text("settings.legend.onDevice")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
@@ -161,17 +178,17 @@ struct SettingsView: View {
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
.font(.system(size: 12, weight: .semibold))
- .foregroundStyle(palette.success)
+ .foregroundStyle(palette.accent)
Text("settings.engineBadge.ios26")
.font(TypeStyle.caption)
- .foregroundStyle(palette.success)
+ .foregroundStyle(palette.accent)
}
.padding(.horizontal, Spacing.xs)
.padding(.vertical, 4)
- .background(palette.success.opacity(0.12), in: Capsule())
+ .background(palette.accent.opacity(0.12), in: Capsule())
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
/// Falls back to a static list while dynamic locales are loading.
@@ -230,9 +247,9 @@ struct SettingsView: View {
// MARK: - Prompt
private var promptSection: some View {
- VStack(alignment: .leading, spacing: Spacing.xs) {
+ VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
HStack {
- sectionHeader("settings.systemPrompt.title", subtitle: nil)
+ sectionHeader("settings.systemPrompt.title")
Spacer()
Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt }
.font(TypeStyle.caption2)
@@ -254,39 +271,48 @@ struct SettingsView: View {
}
}
- // MARK: - Privacy
+ // MARK: - Footer links (tab settings only)
- private var privacySection: some View {
- VStack(alignment: .leading, spacing: Spacing.xs) {
- sectionHeader("settings.privacy.title", subtitle: nil)
- VStack(alignment: .leading, spacing: Spacing.sm) {
+ private var footerLinks: some View {
+ VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
+ sectionHeader("settings.about.title")
+ VStack(spacing: 0) {
if let url = LegalLinks.privacyPolicyURL {
- Link(destination: url) {
- HStack {
- Text("settings.privacy.policy")
- .font(TypeStyle.body)
- .foregroundStyle(palette.accent)
- Spacer()
- Image(systemName: "arrow.up.right")
- .font(.system(size: 12, weight: .semibold))
- .foregroundStyle(palette.textTertiary)
- }
- }
- }
- VStack(alignment: .leading, spacing: 4) {
- Text("settings.privacy.fullAccess.title")
- .font(TypeStyle.caption)
- .foregroundStyle(palette.textPrimary)
- Text("settings.privacy.fullAccess.body")
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textSecondary)
- .fixedSize(horizontal: false, vertical: true)
+ footerLinkRow(title: "settings.privacy.policy", url: url)
+ Divider().background(palette.divider)
}
+ footerLinkRow(
+ title: "settings.link.github",
+ url: URL(string: "https://github.com/hkgood/OSGKeyboard")!
+ )
}
- .cardSurface()
+ .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(palette.divider, lineWidth: 0.5)
+ )
}
}
+ private func footerLinkRow(title: LocalizedStringKey, url: URL) -> some View {
+ Button {
+ safariURL = url
+ } label: {
+ HStack(spacing: Spacing.sm) {
+ Text(title)
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ Spacer()
+ MaterialIcon(name: .openInNew, size: 18)
+ .foregroundStyle(palette.textTertiary)
+ }
+ .padding(.horizontal, Spacing.md)
+ .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+
// MARK: - Reset
private var resetButton: some View {
@@ -296,26 +322,20 @@ struct SettingsView: View {
Text("settings.reset.confirm")
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
- .frame(maxWidth: .infinity, minHeight: 40)
+ .frame(maxWidth: .infinity, minHeight: 36)
}
.buttonStyle(.plain)
+ .padding(.top, presentation == .tab ? Spacing.xs : Spacing.sm)
}
// MARK: - Header
- private func sectionHeader(_ title: LocalizedStringKey, subtitle: LocalizedStringKey?) -> some View {
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textSecondary)
- .textCase(.uppercase)
- if let subtitle {
- Text(subtitle)
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textTertiary)
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
+ private func sectionHeader(_ title: LocalizedStringKey) -> some View {
+ Text(title)
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textSecondary)
+ .textCase(.uppercase)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -358,7 +378,7 @@ private struct PickerRow: View {
}
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
private var currentLabel: String {
@@ -402,7 +422,7 @@ private struct LocalePickerRow: View {
if let current = locales.first(where: { $0.id == selection }), current.onDevice {
Image(systemName: "iphone")
.font(.system(size: 11, weight: .medium))
- .foregroundStyle(palette.success)
+ .foregroundStyle(palette.accent)
}
Text(currentLabel)
.font(TypeStyle.body)
@@ -414,7 +434,7 @@ private struct LocalePickerRow: View {
}
}
.padding(.horizontal, Spacing.md)
- .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
private var currentLabel: String {
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index cf532fd..897ab89 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -3,7 +3,8 @@
"OSGKeyboard" = "OSGKeyboard";
/* Onboarding */
-"onboarding.welcome.subtitle" = "Tap to talk. Tap again to finish — polished text in any app.";
+"onboarding.welcome.tagline" = "Speak instead of type";
+"onboarding.welcome.subtitle" = "Your open-source voice keyboard";
"onboarding.enable.fullAccessNote" = "Full Access lets the keyboard reach the microphone and your LLM API. We never log what you type.";
"onboarding.permission.mic.title" = "Microphone access";
"onboarding.permission.mic.body" = "Required for voice input. Audio is processed on your device for transcription.";
@@ -36,6 +37,7 @@
"common.continue" = "Continue";
"common.reset" = "Reset";
"common.cancel" = "Cancel";
+"common.clear" = "Clear";
"common.delete" = "Delete";
"common.space" = "Space";
"common.newline" = "Return";
@@ -92,6 +94,12 @@
"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required.";
"settings.provider.title" = "Provider";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
+"provider.openai" = "OpenAI";
+"provider.deepseek" = "DeepSeek";
+"provider.qwen" = "Qwen (DashScope)";
+"provider.zhipu" = "Zhipu GLM";
+"provider.moonshot" = "Moonshot";
+"provider.custom" = "Custom";
"settings.api.title" = "API";
"settings.language.title" = "Language";
"settings.language.subtitle.cloud" = "Recognition language and text processing mode.";
@@ -101,6 +109,7 @@
"settings.mode.transcribe" = "Transcribe";
"settings.mode.polish" = "Polish";
"settings.systemPrompt.title" = "System Prompt";
+"settings.about.title" = "About";
"settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale";
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
@@ -113,6 +122,7 @@
"settings.privacy.policy" = "Privacy Policy";
"settings.privacy.fullAccess.title" = "About Full Access";
"settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard.";
+"settings.link.github" = "GitHub";
/* App group error */
"appGroup.error.title" = "App Group not configured";
@@ -168,17 +178,36 @@
/* Flow session */
"flow.error.noSpeech" = "No speech detected. Please try again.";
+"flow.error.recognitionInterrupted" = "Recognition did not finish. Please try again.";
"keyboard.denied.mic" = "Microphone access denied";
"keyboard.denied.speech" = "Speech recognition denied";
/* Home · Flow session */
"home.flow.active" = "Voice session active";
+"home.flow.label" = "Voice session";
"home.flow.inactive" = "Voice session inactive";
"home.flow.hint" = "Voice session starts automatically. Switch to any app and tap the keyboard mic to dictate.";
"home.flow.starting" = "Starting voice session…";
"home.flow.openSettings" = "Open Settings to grant permissions";
"home.flow.start" = "Start voice session";
"home.flow.end" = "End voice session";
+"home.flow.endShort" = "End";
+"home.preview.label" = "Try typing";
+"home.preview.placeholder" = "Tap to type and test…";
+
+/* Tabs */
+"tab.keyboard" = "Keyboard";
+"tab.history" = "History";
+"tab.settings" = "Settings";
+
+/* History */
+"history.title" = "History";
+"history.subtitle" = "Saved on this device only.";
+"history.empty" = "No voice transcripts yet.";
+"history.clear.title" = "Clear all history?";
+"history.clear.message" = "This cannot be undone.";
+"history.clear.confirm" = "Clear all";
+"history.clear.button" = "Clear all history";
"flow.error.speechRequired" = "Speech recognition access is required for voice sessions.";
"flow.error.micRequired" = "Microphone access is required for background voice sessions.";
"flow.error.micUnavailable" = "Microphone is unavailable on this device.";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index b7efb02..9db93fb 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -3,7 +3,8 @@
"OSGKeyboard" = "OSGKeyboard";
/* Onboarding */
-"onboarding.welcome.subtitle" = "点按说话,再点结束 — 任意 App 里都能获得润色文字。";
+"onboarding.welcome.tagline" = "能说就不打字";
+"onboarding.welcome.subtitle" = "你的开源语音输入法";
"onboarding.enable.fullAccessNote" = "完全访问用于麦克风与 API 配置读取。我们不会记录或上传你的击键内容。";
"onboarding.permission.mic.title" = "麦克风权限";
"onboarding.permission.mic.body" = "语音输入需要麦克风。音频在设备端转录,不会上传原始录音。";
@@ -36,6 +37,7 @@
"common.continue" = "继续";
"common.reset" = "重置";
"common.cancel" = "取消";
+"common.clear" = "清空";
"common.delete" = "删除";
"common.space" = "空格";
"common.newline" = "换行";
@@ -92,6 +94,12 @@
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
"settings.provider.title" = "提供商";
"settings.provider.subtitle" = "选择 LLM 提供商。";
+"provider.openai" = "OpenAI";
+"provider.deepseek" = "DeepSeek";
+"provider.qwen" = "通义千问";
+"provider.zhipu" = "智谱 GLM";
+"provider.moonshot" = "月之暗面";
+"provider.custom" = "自定义";
"settings.api.title" = "接口";
"settings.language.title" = "语言";
"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。";
@@ -101,6 +109,7 @@
"settings.mode.transcribe" = "转写";
"settings.mode.polish" = "润色";
"settings.systemPrompt.title" = "系统提示";
+"settings.about.title" = "关于";
"settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言";
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
@@ -113,6 +122,7 @@
"settings.privacy.policy" = "隐私政策";
"settings.privacy.fullAccess.title" = "关于完全访问";
"settings.privacy.fullAccess.body" = "完全访问用于麦克风与读取 API Key。OSGKeyboard 不会记录或上传你在键盘上的击键内容。";
+"settings.link.github" = "GitHub";
/* App group error */
"appGroup.error.title" = "App Group 未配置";
@@ -167,17 +177,36 @@
/* Flow session */
"flow.error.noSpeech" = "未检测到语音,请重试。";
+"flow.error.recognitionInterrupted" = "识别未完成,请再试一次。";
"keyboard.denied.mic" = "麦克风权限被拒绝";
"keyboard.denied.speech" = "语音识别权限被拒绝";
/* Home · Flow session */
"home.flow.active" = "语音会话进行中";
+"home.flow.label" = "语音会话";
"home.flow.inactive" = "语音会话未启动";
"home.flow.hint" = "语音会话会自动启动。切到任意 App,点按键盘麦克风即可说话。";
"home.flow.starting" = "正在启动语音会话…";
"home.flow.openSettings" = "前往设置授予权限";
"home.flow.start" = "启动语音会话";
"home.flow.end" = "结束语音会话";
+"home.flow.endShort" = "结束";
+"home.preview.label" = "输入测试";
+"home.preview.placeholder" = "点击输入,测试键盘效果…";
+
+/* Tabs */
+"tab.keyboard" = "键盘";
+"tab.history" = "历史";
+"tab.settings" = "设置";
+
+/* History */
+"history.title" = "历史";
+"history.subtitle" = "仅保存在本机。";
+"history.empty" = "还没有语音识别记录。";
+"history.clear.title" = "清空全部历史?";
+"history.clear.message" = "此操作无法撤销。";
+"history.clear.confirm" = "全部清空";
+"history.clear.button" = "清空全部历史";
"flow.error.speechRequired" = "需要语音识别权限才能使用语音会话。";
"flow.error.micRequired" = "需要麦克风权限才能维持后台语音会话。";
"flow.error.micUnavailable" = "当前设备无法使用麦克风。";
diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift
index c5dacb6..2b521c9 100644
--- a/OSGKeyboardExt/Views/KeyboardRootView.swift
+++ b/OSGKeyboardExt/Views/KeyboardRootView.swift
@@ -8,19 +8,20 @@
// the system Spotlight / home-indicator chrome never clips our controls.
//
// ┌───────────────────────────────────────────┐
-// │ [polish] [中] ● ⚙ │ ← top: 32 pt (incl. safe top)
-// │ (transcript preview) │ ← 24 pt
+// │ [polish] [中] ● ⚙ │ ← top: ~38 pt (+20%)
+// │ (transcript preview) │
// │ │
-// │ ◯ ▲▲▲▲▲ │ ← centre: 96 pt disc +
-// │ │ breathing ring
-// │ │
-// ├───────────────────────────────────────────┤
-// │ 🌐 ⌫ [ space ] ↩ │ ← bottom: 60 pt (incl. safe bottom)
+// │ (⌫) ◯ mic (↩) │ ← action row: circular
+// │ (space) │ flanking buttons
// └───────────────────────────────────────────┘
import SwiftUI
import OSGKeyboardShared
+private enum KeyboardLayoutMetrics {
+ static let sideActionButtonSize: CGFloat = 44
+}
+
public struct KeyboardRootView: View {
@Environment(\.colorScheme) private var colorScheme
@@ -34,6 +35,8 @@ public struct KeyboardRootView: View {
/// constraint in the view controller so the host UIInputView picks
/// it up.
static let totalHeight: CGFloat = 280
+ private static let topBarHeight: CGFloat = 38
+ private static let sideActionStackSpacing: CGFloat = 10
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
@@ -42,13 +45,10 @@ public struct KeyboardRootView: View {
public var body: some View {
VStack(spacing: 0) {
topBar
- .frame(height: 32)
+ .frame(height: Self.topBarHeight)
centreArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
-
- bottomBar
- .frame(height: 56)
}
.padding(.top, 4)
.padding(.bottom, 6)
@@ -79,9 +79,9 @@ public struct KeyboardRootView: View {
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
Button(action: state.openSettings) {
Image(systemName: "gearshape.fill")
- .font(.system(size: 13, weight: .medium))
+ .font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
- .frame(width: 28, height: 28)
+ .frame(width: 34, height: 34)
.background(palette.surface, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
}
@@ -94,58 +94,52 @@ public struct KeyboardRootView: View {
// MARK: - Centre area
private var centreArea: some View {
- ZStack {
- VStack(spacing: Spacing.xxs) {
- TranscriptLine(
- phase: state.phase,
- transcript: state.lastTranscript,
- flowSessionActive: state.flowSessionActive,
- openSettings: state.openSettings
- )
- .frame(height: 22)
- RecordButton(
- phase: buttonPhase,
- level: state.level,
- remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
- onToggle: state.tapMic
- )
- .frame(width: 140, height: 140)
- }
+ VStack(spacing: Spacing.xxs) {
+ TranscriptLine(
+ phase: state.phase,
+ transcript: state.lastTranscript,
+ flowSessionActive: state.flowSessionActive,
+ openSettings: state.openSettings
+ )
+ .frame(height: 22)
+
+ Spacer(minLength: 0)
+
+ micActionRow
+ .padding(.bottom, Spacing.xs)
+
+ Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
}
- // MARK: - Bottom bar
-
- private var bottomBar: some View {
- // The globe / "next keyboard" button used to live here so the
- // user could long-press to call up the system keyboard picker.
- // In practice the user already has a "globe" key in the iOS
- // system keyboard strip (every iOS keyboard does), so an
- // in-extension globe is redundant and crowds the bar.
- // Bottom bar: ⌫ (delete) [ space ] ↩ (return)
- HStack(spacing: Spacing.xxs) {
- ToolbarIconButton(systemName: "delete.left", label: "delete") {
+ /// Delete (left), mic (centre), return + space stacked on the right.
+ /// HStack vertical alignment keeps delete, mic centre, and the gap
+ /// between return/space on one horizontal axis.
+ private var micActionRow: some View {
+ HStack(alignment: .center, spacing: 20) {
+ CircularToolbarButton(systemName: "delete.left", label: "delete") {
state.deleteBackward()
}
- Button(action: state.insertSpace) {
- ExtL10n.text("keyboard.space")
- .font(TypeStyle.body)
- .foregroundStyle(palette.textPrimary)
- .frame(maxWidth: .infinity, minHeight: 42)
- .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
- .stroke(palette.divider, lineWidth: 0.5)
- )
- }
- .buttonStyle(.plain)
- .accessibilityLabel(ExtL10n.text("keyboard.space"))
- ToolbarIconButton(systemName: "return", label: "newline") {
- state.insertNewline()
+
+ RecordButton(
+ phase: buttonPhase,
+ level: state.level,
+ remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
+ onToggle: state.tapMic
+ )
+ .frame(width: 132, height: 132)
+
+ VStack(spacing: Self.sideActionStackSpacing) {
+ CircularToolbarButton(systemName: "return", label: "newline") {
+ state.insertNewline()
+ }
+ CircularToolbarButton(spaceStyle: true, label: "space") {
+ state.insertSpace()
+ }
}
}
- .padding(.horizontal, Spacing.sm)
+ .frame(maxWidth: .infinity)
}
private var buttonPhase: RecordButton.Phase {
@@ -270,26 +264,46 @@ private struct TranscriptLine: View {
}
}
-// MARK: - Toolbar icon button
+// MARK: - Circular toolbar button
-private struct ToolbarIconButton: View {
+private struct CircularToolbarButton: View {
@Environment(\.themePalette) private var palette: ThemePalette
- let systemName: String
+ let systemName: String?
+ let spaceStyle: Bool
let label: String
let action: () -> Void
+ init(systemName: String, label: String, action: @escaping () -> Void) {
+ self.systemName = systemName
+ self.spaceStyle = false
+ self.label = label
+ self.action = action
+ }
+
+ init(spaceStyle: Bool, label: String, action: @escaping () -> Void) {
+ self.systemName = nil
+ self.spaceStyle = spaceStyle
+ self.label = label
+ self.action = action
+ }
+
var body: some View {
Button(action: action) {
- Image(systemName: systemName)
- .font(.system(size: 16, weight: .medium))
- .foregroundStyle(palette.textPrimary)
- .frame(width: 40, height: 40)
- .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
- .stroke(palette.divider, lineWidth: 0.5)
- )
+ Group {
+ if spaceStyle {
+ Capsule()
+ .fill(palette.textPrimary)
+ .frame(width: 16, height: 3)
+ } else if let systemName {
+ Image(systemName: systemName)
+ .font(.system(size: 16, weight: .medium))
+ .foregroundStyle(palette.textPrimary)
+ }
+ }
+ .frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize)
+ .background(palette.surfaceElevated, in: Circle())
+ .overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
@@ -365,7 +379,8 @@ private struct LocalEngineChip: View {
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2)
- .padding(.vertical, 4)
+ .padding(.vertical, 5)
+ .frame(minHeight: 26)
.background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
}
@@ -402,7 +417,8 @@ private struct ModeChip: View {
.font(TypeStyle.caption2)
.foregroundStyle(mode == .off ? palette.textTertiary : palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
- .padding(.vertical, 4)
+ .padding(.vertical, 5)
+ .frame(minHeight: 26)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
@@ -462,7 +478,8 @@ private struct LocaleChip: View {
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
- .padding(.vertical, 4)
+ .padding(.vertical, 5)
+ .frame(minHeight: 26)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift
index f07726b..d46cd7b 100644
--- a/OSGKeyboardExt/Views/RecordButton.swift
+++ b/OSGKeyboardExt/Views/RecordButton.swift
@@ -85,17 +85,21 @@ struct RecordButton: View {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 38, weight: .medium))
- .foregroundStyle(palette.textPrimary)
+ .foregroundStyle(.white)
case .recording:
VStack(spacing: 4) {
if let remainingSeconds {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded))
- .foregroundStyle(isUrgent ? .white : palette.textPrimary)
+ .foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
}
- WaveformView(level: level, active: true)
+ WaveformView(
+ level: level,
+ color: Color(red: 1.0, green: 0.78, blue: 0.78),
+ active: true
+ )
.frame(width: 72, height: 32)
}
.transition(.opacity)
diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift
index 5ed7d6e..bf19881 100644
--- a/OSGKeyboardShared/DesignSystem/Theme.swift
+++ b/OSGKeyboardShared/DesignSystem/Theme.swift
@@ -55,7 +55,7 @@ public enum Palette {
// Semantic
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
- public static let success = Color(red: 0.157, green: 0.812, blue: 0.412) // #28CF69
+ public static let success = accent // unified brand green in UI
public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18
// Text
@@ -95,25 +95,17 @@ public enum Palette {
recordRed: recordRed
)
- /// Light palette — iOS system light mode defaults. Used by the main app
- /// when the user is in light mode; the keyboard extension stays dark.
- ///
- /// Accent is the same brand green (#3AA05A) used in the dark palette —
- /// "one accent" is core to the design system, and the recording-state
- /// CTA needs to read as the same brand colour in both modes. The
- /// keyboard's record disc also picks up `palette.accent` (see
- /// `KeyboardPreviewStub.recordDisc`), so this single token drives
- /// every interactive surface.
+ /// Light palette — warm gray backgrounds for daytime use.
public static let light = ThemePalette(
- background: Color(red: 0.980, green: 0.980, blue: 0.988), // #FAFAFC
- surface: Color(red: 1.000, green: 1.000, blue: 1.000), // #FFFFFF
- surfaceElevated: Color(red: 0.941, green: 0.941, blue: 0.961), // #F0F0F5
- surfaceMuted: Color(red: 0.953, green: 0.953, blue: 0.965), // #F3F3F6
+ background: Color(red: 0.949, green: 0.945, blue: 0.933), // #F2F1EE warm gray
+ surface: Color(red: 0.988, green: 0.984, blue: 0.976), // #FCFBF9
+ surfaceElevated: Color(red: 0.922, green: 0.918, blue: 0.906), // #EBEAE7
+ surfaceMuted: Color(red: 0.933, green: 0.929, blue: 0.918), // #EEEDE9
accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A
accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14),
accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32),
danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
- success: Color(red: 0.157, green: 0.812, blue: 0.412), // #28CF69
+ success: Color(red: 0.227, green: 0.627, blue: 0.353), // same as accent
warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18
textPrimary: Color(red: 0.067, green: 0.067, blue: 0.094), // #111118
textSecondary: Color(red: 0.392, green: 0.392, blue: 0.435), // #64646F
@@ -169,6 +161,15 @@ public enum Radius {
// MARK: - Typography
+public enum SettingsListMetrics {
+ /// Single-line list rows (provider, footer link, picker).
+ public static let singleLineMinHeight: CGFloat = 52
+ /// Two-line rows (engine option, labeled API field).
+ public static let doubleLineMinHeight: CGFloat = 72
+ /// Space between a section label and its card.
+ public static let sectionLabelSpacing: CGFloat = Spacing.sm
+}
+
public enum TypeStyle {
public static let caption2 = Font.system(size: 11, weight: .medium)
public static let caption = Font.system(size: 12, weight: .medium)
@@ -180,6 +181,8 @@ public enum TypeStyle {
public static let title2 = Font.system(size: 22, weight: .bold)
public static let title = Font.system(size: 28, weight: .bold)
public static let largeTitle = Font.system(size: 34, weight: .bold)
+ /// Subtle status line under the brand mark (home header).
+ public static let status = Font.system(size: 13, weight: .regular)
public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced)
public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced)
}
diff --git a/OSGKeyboardShared/Models/EngineServiceLabel.swift b/OSGKeyboardShared/Models/EngineServiceLabel.swift
index bc2cf1f..5840ec2 100644
--- a/OSGKeyboardShared/Models/EngineServiceLabel.swift
+++ b/OSGKeyboardShared/Models/EngineServiceLabel.swift
@@ -18,9 +18,9 @@ public enum EngineServiceLabel {
? "\(prefix)本地引擎 · Apple SpeechAnalyzer"
: "\(prefix)On-device · Apple SpeechAnalyzer"
}
- let provider = LLMProvider.provider(id: providerId)
+ let providerName = ProviderDisplayName.name(for: providerId)
let trimmedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmedModel.isEmpty { return "\(prefix)\(provider.name)" }
- return "\(prefix)\(provider.name) · \(trimmedModel)"
+ if trimmedModel.isEmpty { return "\(prefix)\(providerName)" }
+ return "\(prefix)\(providerName) · \(trimmedModel)"
}
}
diff --git a/OSGKeyboardShared/Utilities/ProviderDisplayName.swift b/OSGKeyboardShared/Utilities/ProviderDisplayName.swift
new file mode 100644
index 0000000..64d824c
--- /dev/null
+++ b/OSGKeyboardShared/Utilities/ProviderDisplayName.swift
@@ -0,0 +1,15 @@
+// ProviderDisplayName.swift
+// OSGKeyboard · Shared
+//
+// Locale-aware provider labels for settings and status hints.
+
+import Foundation
+
+public enum ProviderDisplayName {
+ public static func name(for providerId: String) -> String {
+ let key = "provider.\(providerId)"
+ let localized = NSLocalizedString(key, comment: "")
+ if localized != key { return localized }
+ return LLMProvider.provider(id: providerId).name
+ }
+}
diff --git a/project.yml b/project.yml
index 2bb16df..527acda 100644
--- a/project.yml
+++ b/project.yml
@@ -64,12 +64,14 @@ targets:
- path: OSGKeyboard/Assets.xcassets
- path: OSGKeyboard/en.lproj
- path: OSGKeyboard/zh-Hans.lproj
+ - path: OSGKeyboard/Resources/Fonts/MaterialIcons-Regular.ttf
info:
path: OSGKeyboard/Info.plist
properties:
CFBundleDisplayName: OSGKeyboard
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
+ LSApplicationCategoryType: public.app-category.utilities
# English is the development language; zh-Hans is the only
# translation right now. The list also tells iOS to surface
# the keyboard in the per-app language picker for both.
@@ -98,6 +100,10 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios
TARGETED_DEVICE_FAMILY: "1"
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
+ INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities
+ SUPPORTS_MACCATALYST: NO
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
# Use AppIcon.appiconset only. AppIcon.icon (Icon Composer) must not
# be passed to actool alongside the asset catalog — it crashes actool.
dependencies:
@@ -161,6 +167,9 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.keyboard
TARGETED_DEVICE_FAMILY: "1"
+ SUPPORTS_MACCATALYST: NO
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
dependencies:
- target: OSGKeyboardShared
embed: false