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 @@ +Apple \ 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 @@ +Custom 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 @@ +DeepSeek \ 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 @@ +MoonshotAI \ 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 @@ +OpenAI \ 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 @@ +Qwen \ 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 @@ +Zhipu \ 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