fix: onboarding permission refresh, keyboard l10n, and flow recording
Refresh mic/speech status when returning from TCC dialogs, persist onboarding page across Settings trips, load extension strings from .lproj bundles via ExtL10n, and keep flow result polling alive across host-app jumps.
This commit is contained in:
@@ -44,6 +44,9 @@ struct OSGKeyboardApp: App {
|
|||||||
coordinator: dictationCoordinator
|
coordinator: dictationCoordinator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
||||||
|
if done { flowManager.autoStartIfNeeded() }
|
||||||
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
guard phase == .active, AppGroup.isAvailable, config.hasCompletedOnboarding else { return }
|
guard phase == .active, AppGroup.isAvailable, config.hasCompletedOnboarding else { return }
|
||||||
if flowManager.isActive {
|
if flowManager.isActive {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
private enum OnboardingPage: Int, CaseIterable {
|
private enum OnboardingPage: Int, CaseIterable {
|
||||||
case welcome = 0
|
case welcome = 0
|
||||||
@@ -23,19 +24,23 @@ private enum OnboardingPage: Int, CaseIterable {
|
|||||||
|
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@ObservedObject var config: ProviderConfig
|
@ObservedObject var config: ProviderConfig
|
||||||
@State private var page: Int = 0
|
@State private var micStatus = AppPermissions.micStatus
|
||||||
|
@State private var speechStatus = AppPermissions.speechStatus
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
palette.background.ignoresSafeArea()
|
palette.background.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Group {
|
Group {
|
||||||
switch OnboardingPage(rawValue: page) ?? .welcome {
|
switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
|
||||||
case .welcome: WelcomePage()
|
case .welcome: WelcomePage()
|
||||||
case .microphone: MicPermissionPage()
|
case .microphone:
|
||||||
case .speech: SpeechPermissionPage()
|
MicPermissionPage(status: $micStatus)
|
||||||
|
case .speech:
|
||||||
|
SpeechPermissionPage(status: $speechStatus)
|
||||||
case .keyboard: EnableKeyboardPage()
|
case .keyboard: EnableKeyboardPage()
|
||||||
case .api: APISetupPage(config: config)
|
case .api: APISetupPage(config: config)
|
||||||
}
|
}
|
||||||
@@ -51,37 +56,51 @@ struct OnboardingView: View {
|
|||||||
.padding(.bottom, Spacing.lg)
|
.padding(.bottom, Spacing.lg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
if config.onboardingPage < 0 || config.onboardingPage >= OnboardingPage.count {
|
||||||
|
config.onboardingPage = 0
|
||||||
|
}
|
||||||
|
refreshPermissionStatuses()
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
if phase == .active { refreshPermissionStatuses() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshPermissionStatuses() {
|
||||||
|
micStatus = AppPermissions.micStatus
|
||||||
|
speechStatus = AppPermissions.speechStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pageDots: some View {
|
private var pageDots: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(0..<OnboardingPage.count, id: \.self) { i in
|
ForEach(0..<OnboardingPage.count, id: \.self) { i in
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(i == page ? palette.accent : Color.white.opacity(0.18))
|
.fill(i == config.onboardingPage ? palette.accent : Color.white.opacity(0.18))
|
||||||
.frame(width: i == page ? 18 : 6, height: 6)
|
.frame(width: i == config.onboardingPage ? 18 : 6, height: 6)
|
||||||
.animation(Motion.quick, value: page)
|
.animation(Motion.quick, value: config.onboardingPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isLastPage: Bool { page == OnboardingPage.api.rawValue }
|
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue }
|
||||||
|
|
||||||
private var canAdvance: Bool {
|
private var canAdvance: Bool {
|
||||||
switch OnboardingPage(rawValue: page) ?? .welcome {
|
switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
|
||||||
case .welcome, .keyboard, .api:
|
case .welcome, .keyboard, .api:
|
||||||
return page != OnboardingPage.api.rawValue || config.isConfigured
|
return !isLastPage || config.isConfigured
|
||||||
case .microphone:
|
case .microphone:
|
||||||
return AppPermissions.micStatus != .undetermined
|
return micStatus != .undetermined
|
||||||
case .speech:
|
case .speech:
|
||||||
return AppPermissions.speechStatus != .undetermined
|
return speechStatus != .undetermined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var bottomBar: some View {
|
private var bottomBar: some View {
|
||||||
HStack(spacing: Spacing.sm) {
|
HStack(spacing: Spacing.sm) {
|
||||||
if page > 0 {
|
if config.onboardingPage > 0 {
|
||||||
Button { withAnimation(Motion.soft) { page -= 1 } } label: {
|
Button { withAnimation(Motion.soft) { config.onboardingPage -= 1 } } label: {
|
||||||
Text("common.back")
|
Text("common.back")
|
||||||
.font(TypeStyle.headline)
|
.font(TypeStyle.headline)
|
||||||
.frame(maxWidth: .infinity, minHeight: 50)
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
@@ -100,7 +119,7 @@ struct OnboardingView: View {
|
|||||||
if isLastPage {
|
if isLastPage {
|
||||||
config.hasCompletedOnboarding = true
|
config.hasCompletedOnboarding = true
|
||||||
} else {
|
} else {
|
||||||
page += 1
|
config.onboardingPage += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -204,7 +223,7 @@ private struct PrivacyFootnote: View {
|
|||||||
|
|
||||||
private struct MicPermissionPage: View {
|
private struct MicPermissionPage: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
@State private var status = AppPermissions.micStatus
|
@Binding var status: AppPermissions.MicStatus
|
||||||
@State private var isRequesting = false
|
@State private var isRequesting = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -222,6 +241,9 @@ private struct MicPermissionPage: View {
|
|||||||
deniedHint: status == .denied ? "onboarding.permission.mic.deniedHint" : nil
|
deniedHint: status == .denied ? "onboarding.permission.mic.deniedHint" : nil
|
||||||
)
|
)
|
||||||
.onAppear { status = AppPermissions.micStatus }
|
.onAppear { status = AppPermissions.micStatus }
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
|
status = AppPermissions.micStatus
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusLabel: LocalizedStringKey {
|
private var statusLabel: LocalizedStringKey {
|
||||||
@@ -254,6 +276,7 @@ private struct MicPermissionPage: View {
|
|||||||
|
|
||||||
private struct SpeechPermissionPage: View {
|
private struct SpeechPermissionPage: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
@Binding var status: AppPermissions.SpeechStatus
|
||||||
@State private var isRequesting = false
|
@State private var isRequesting = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -264,23 +287,27 @@ private struct SpeechPermissionPage: View {
|
|||||||
status: statusLabel,
|
status: statusLabel,
|
||||||
statusColor: statusColor,
|
statusColor: statusColor,
|
||||||
primaryTitle: primaryButtonTitle,
|
primaryTitle: primaryButtonTitle,
|
||||||
primaryDisabled: isRequesting || AppPermissions.speechStatus == .granted,
|
primaryDisabled: isRequesting || status == .granted,
|
||||||
onPrimary: { Task { await request() } },
|
onPrimary: { Task { await request() } },
|
||||||
secondaryTitle: speechDenied ? "onboarding.permission.openSettings" : nil,
|
secondaryTitle: speechDenied ? "onboarding.permission.openSettings" : nil,
|
||||||
onSecondary: speechDenied ? { AppPermissions.openSystemSettings() } : nil,
|
onSecondary: speechDenied ? { AppPermissions.openSystemSettings() } : nil,
|
||||||
deniedHint: speechDenied ? "onboarding.permission.speech.deniedHint" : nil
|
deniedHint: speechDenied ? "onboarding.permission.speech.deniedHint" : nil
|
||||||
)
|
)
|
||||||
|
.onAppear { status = AppPermissions.speechStatus }
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
|
status = AppPermissions.speechStatus
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var speechDenied: Bool {
|
private var speechDenied: Bool {
|
||||||
switch AppPermissions.speechStatus {
|
switch status {
|
||||||
case .denied, .restricted: return true
|
case .denied, .restricted: return true
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusLabel: LocalizedStringKey {
|
private var statusLabel: LocalizedStringKey {
|
||||||
switch AppPermissions.speechStatus {
|
switch status {
|
||||||
case .undetermined: return "onboarding.permission.status.undetermined"
|
case .undetermined: return "onboarding.permission.status.undetermined"
|
||||||
case .granted: return "onboarding.permission.status.granted"
|
case .granted: return "onboarding.permission.status.granted"
|
||||||
case .denied, .restricted: return "onboarding.permission.status.denied"
|
case .denied, .restricted: return "onboarding.permission.status.denied"
|
||||||
@@ -288,7 +315,7 @@ private struct SpeechPermissionPage: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var statusColor: Color {
|
private var statusColor: Color {
|
||||||
switch AppPermissions.speechStatus {
|
switch status {
|
||||||
case .granted: return palette.success
|
case .granted: return palette.success
|
||||||
case .denied, .restricted: return palette.warning
|
case .denied, .restricted: return palette.warning
|
||||||
case .undetermined: return palette.textTertiary
|
case .undetermined: return palette.textTertiary
|
||||||
@@ -296,7 +323,7 @@ private struct SpeechPermissionPage: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var primaryButtonTitle: LocalizedStringKey {
|
private var primaryButtonTitle: LocalizedStringKey {
|
||||||
AppPermissions.speechStatus == .granted
|
status == .granted
|
||||||
? "onboarding.permission.status.granted"
|
? "onboarding.permission.status.granted"
|
||||||
: "onboarding.permission.speech.allow"
|
: "onboarding.permission.speech.allow"
|
||||||
}
|
}
|
||||||
@@ -304,6 +331,7 @@ private struct SpeechPermissionPage: View {
|
|||||||
private func request() async {
|
private func request() async {
|
||||||
isRequesting = true
|
isRequesting = true
|
||||||
_ = await AppPermissions.requestSpeechRecognition()
|
_ = await AppPermissions.requestSpeechRecognition()
|
||||||
|
status = AppPermissions.speechStatus
|
||||||
isRequesting = false
|
isRequesting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>OSGKeyboard</string>
|
<string>OSGKeyboard</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
@@ -12,6 +12,11 @@
|
|||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>en</string>
|
||||||
|
<string>zh-Hans</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private var wasFlowSessionActive = false
|
private var wasFlowSessionActive = false
|
||||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||||
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
||||||
|
private var isAwaitingFlowResult = false
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
@@ -85,8 +86,13 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
cancelPipeline()
|
|
||||||
stopFlowSessionMonitor()
|
stopFlowSessionMonitor()
|
||||||
|
// Preserve flow handoff / recording / result polling across the
|
||||||
|
// intentional jump to the host app (keyboard extension pauses here).
|
||||||
|
if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancelPipeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewWillAppear(_ animated: Bool) {
|
public override func viewWillAppear(_ animated: Bool) {
|
||||||
@@ -320,7 +326,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||||
while let self, !Task.isCancelled, self.isPendingFlowStart {
|
while let self, !Task.isCancelled, self.isPendingFlowStart {
|
||||||
if FlowSessionBridge.isSessionActive() {
|
if FlowSessionBridge.isSessionActive() {
|
||||||
self.startFlowRecording()
|
self.completeFlowStartHandoff()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let now = Date().timeIntervalSince1970
|
let now = Date().timeIntervalSince1970
|
||||||
@@ -335,6 +341,17 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Session is live — return to idle so the user can tap again to record.
|
||||||
|
private func completeFlowStartHandoff() {
|
||||||
|
isPendingFlowStart = false
|
||||||
|
flowStartDeadline = 0
|
||||||
|
stopFlowWatchdog()
|
||||||
|
state.lastTranscript = ""
|
||||||
|
state.phase = .idle
|
||||||
|
refreshFlowSessionState()
|
||||||
|
debug("completeFlowStartHandoff")
|
||||||
|
}
|
||||||
|
|
||||||
private func startFlowLevelWatchdog() {
|
private func startFlowLevelWatchdog() {
|
||||||
stopFlowWatchdog()
|
stopFlowWatchdog()
|
||||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||||
@@ -350,15 +367,18 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
private func startFlowResultWatchdog() {
|
private func startFlowResultWatchdog() {
|
||||||
stopFlowWatchdog()
|
stopFlowWatchdog()
|
||||||
|
isAwaitingFlowResult = true
|
||||||
let startedAt = Date().timeIntervalSince1970
|
let startedAt = Date().timeIntervalSince1970
|
||||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||||
while let self, !Task.isCancelled {
|
while let self, !Task.isCancelled {
|
||||||
if let result = FlowSessionBridge.consumeTranscriptionResult() {
|
if let result = FlowSessionBridge.consumeTranscriptionResult() {
|
||||||
|
self.isAwaitingFlowResult = false
|
||||||
self.stopFlowWatchdog()
|
self.stopFlowWatchdog()
|
||||||
self.handleFlowTranscript(result)
|
self.handleFlowTranscript(result)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||||||
|
self.isAwaitingFlowResult = false
|
||||||
self.stopFlowWatchdog()
|
self.stopFlowWatchdog()
|
||||||
self.state.phase = .error(.unknown(error), message: error)
|
self.state.phase = .error(.unknown(error), message: error)
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
@@ -366,6 +386,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
}
|
}
|
||||||
let now = Date().timeIntervalSince1970
|
let now = Date().timeIntervalSince1970
|
||||||
if now - startedAt > FlowWatchdog.resultTimeout {
|
if now - startedAt > FlowWatchdog.resultTimeout {
|
||||||
|
self.isAwaitingFlowResult = false
|
||||||
self.stopFlowWatchdog()
|
self.stopFlowWatchdog()
|
||||||
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
|
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
|
||||||
self.state.phase = .error(.unknown(msg), message: msg)
|
self.state.phase = .error(.unknown(msg), message: msg)
|
||||||
@@ -398,6 +419,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cancelPipeline() {
|
private func cancelPipeline() {
|
||||||
|
if isAwaitingFlowResult || awaitingDictationResult {
|
||||||
|
return
|
||||||
|
}
|
||||||
if isFlowRecording || isPendingFlowStart {
|
if isFlowRecording || isPendingFlowStart {
|
||||||
if isFlowRecording {
|
if isFlowRecording {
|
||||||
FlowSessionBridge.setRecordingState(.aborted)
|
FlowSessionBridge.setRecordingState(.aborted)
|
||||||
@@ -408,13 +432,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
stopFlowWatchdog()
|
stopFlowWatchdog()
|
||||||
state.level = 0
|
state.level = 0
|
||||||
}
|
}
|
||||||
if awaitingDictationResult {
|
|
||||||
debug("cancelPipeline ignored while awaiting legacy handoff result")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if state.phase == .processing {
|
|
||||||
state.phase = .idle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFinalTranscript(_ transcript: String) {
|
private func handleFinalTranscript(_ transcript: String) {
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
// ExtL10n.swift
|
// ExtL10n.swift
|
||||||
// OSGKeyboard · Keyboard Extension
|
// OSGKeyboard · Keyboard Extension
|
||||||
//
|
//
|
||||||
// Loads strings from the extension bundle. Replaces the old KeyboardL10n
|
// Loads strings from the keyboard extension bundle (not SwiftUI's default
|
||||||
// hard-coded fallback map — keys live in Localizable.strings.
|
// bundle, which may not see our Localizable.strings).
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
enum ExtL10n {
|
enum ExtL10n {
|
||||||
|
private static let bundle = Bundle(for: KeyboardViewController.self)
|
||||||
|
|
||||||
static func string(_ key: String) -> String {
|
static func string(_ key: String) -> String {
|
||||||
NSLocalizedString(key, bundle: .main, comment: "")
|
NSLocalizedString(key, bundle: bundle, comment: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func text(_ key: String) -> Text {
|
||||||
|
Text(string(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func format(_ key: String, _ args: CVarArg...) -> String {
|
static func format(_ key: String, _ args: CVarArg...) -> String {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ public struct KeyboardRootView: View {
|
|||||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(Text("keyboard.openSettingsA11y"))
|
.accessibilityLabel(ExtL10n.text("keyboard.openSettingsA11y"))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Spacing.md)
|
.padding(.horizontal, Spacing.md)
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ public struct KeyboardRootView: View {
|
|||||||
state.deleteBackward()
|
state.deleteBackward()
|
||||||
}
|
}
|
||||||
Button(action: state.insertSpace) {
|
Button(action: state.insertSpace) {
|
||||||
Text("keyboard.space")
|
ExtL10n.text("keyboard.space")
|
||||||
.font(TypeStyle.body)
|
.font(TypeStyle.body)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.frame(maxWidth: .infinity, minHeight: 42)
|
.frame(maxWidth: .infinity, minHeight: 42)
|
||||||
@@ -140,7 +140,7 @@ public struct KeyboardRootView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(Text("keyboard.space"))
|
.accessibilityLabel(ExtL10n.text("keyboard.space"))
|
||||||
ToolbarIconButton(systemName: "return", label: "newline") {
|
ToolbarIconButton(systemName: "return", label: "newline") {
|
||||||
state.insertNewline()
|
state.insertNewline()
|
||||||
}
|
}
|
||||||
@@ -203,18 +203,18 @@ private struct TranscriptLine: View {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .idle:
|
case .idle:
|
||||||
if flowSessionActive {
|
if flowSessionActive {
|
||||||
Text("keyboard.placeholder.idle")
|
ExtL10n.text("keyboard.placeholder.idle")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
} else {
|
} else {
|
||||||
Text("keyboard.flow.sessionInactive")
|
ExtL10n.text("keyboard.flow.sessionInactive")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
}
|
}
|
||||||
case .requestingPermissions:
|
case .requestingPermissions:
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
||||||
Text("keyboard.placeholder.preparing")
|
ExtL10n.text("keyboard.placeholder.preparing")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
}
|
}
|
||||||
@@ -228,7 +228,7 @@ private struct TranscriptLine: View {
|
|||||||
case .processing:
|
case .processing:
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ProgressView().controlSize(.mini).tint(palette.accent)
|
ProgressView().controlSize(.mini).tint(palette.accent)
|
||||||
Text(transcript.isEmpty ? String(localized: "keyboard.placeholder.processing") : transcript)
|
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -255,17 +255,17 @@ private struct TranscriptLine: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityHint(Text("keyboard.deniedHint"))
|
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.horizontal, Spacing.md)
|
.padding(.horizontal, Spacing.md)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> LocalizedStringKey {
|
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||||
switch reason {
|
switch reason {
|
||||||
case .mic: return "keyboard.denied.mic"
|
case .mic: return ExtL10n.string("keyboard.denied.mic")
|
||||||
case .speech: return "keyboard.denied.speech"
|
case .speech: return ExtL10n.string("keyboard.denied.speech")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ private struct StatusBadge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dot(color: Color, labelKey: LocalizedStringKey, showWarning: Bool = false) -> some View {
|
private func dot(color: Color, labelKey: String, showWarning: Bool = false) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(color)
|
.fill(color)
|
||||||
@@ -341,7 +341,7 @@ private struct StatusBadge: View {
|
|||||||
.font(.system(size: 9, weight: .bold))
|
.font(.system(size: 9, weight: .bold))
|
||||||
.foregroundStyle(palette.warning)
|
.foregroundStyle(palette.warning)
|
||||||
}
|
}
|
||||||
Text(labelKey)
|
Text(ExtL10n.string(labelKey))
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
}
|
}
|
||||||
@@ -360,7 +360,7 @@ private struct LocalEngineChip: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "iphone.badge.checkmark")
|
Image(systemName: "iphone.badge.checkmark")
|
||||||
Text("keyboard.placeholder.localBadge")
|
ExtL10n.text("keyboard.placeholder.localBadge")
|
||||||
}
|
}
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.accent)
|
.foregroundStyle(palette.accent)
|
||||||
@@ -409,8 +409,8 @@ private struct ModeChip: View {
|
|||||||
.menuStyle(.button)
|
.menuStyle(.button)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func label(for m: KeyboardViewController.State.InputMode) -> LocalizedStringKey {
|
private func label(for m: KeyboardViewController.State.InputMode) -> String {
|
||||||
LocalizedStringKey(m.labelKey)
|
ExtL10n.string(m.labelKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func icon(for m: KeyboardViewController.State.InputMode) -> String {
|
private func icon(for m: KeyboardViewController.State.InputMode) -> String {
|
||||||
@@ -446,9 +446,9 @@ private struct LocaleChip: View {
|
|||||||
onChange(o.id)
|
onChange(o.id)
|
||||||
} label: {
|
} label: {
|
||||||
if o.id == localeId {
|
if o.id == localeId {
|
||||||
Label(LocalizedStringKey(o.labelKey), systemImage: "checkmark")
|
Label(ExtL10n.string(o.labelKey), systemImage: "checkmark")
|
||||||
} else {
|
} else {
|
||||||
Text(LocalizedStringKey(o.labelKey))
|
Text(ExtL10n.string(o.labelKey))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,10 +469,8 @@ private struct LocaleChip: View {
|
|||||||
.menuStyle(.button)
|
.menuStyle(.button)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentLabel: LocalizedStringKey {
|
private var currentLabel: String {
|
||||||
if let key = options.first(where: { $0.id == localeId })?.labelKey {
|
options.first(where: { $0.id == localeId }).map { ExtL10n.string($0.labelKey) }
|
||||||
return LocalizedStringKey(key)
|
?? ExtL10n.string("locale.chip.auto")
|
||||||
}
|
|
||||||
return "locale.chip.auto"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ struct RecordButton: View {
|
|||||||
.onChange(of: phase) { _, new in
|
.onChange(of: phase) { _, new in
|
||||||
breath = (new == .recording)
|
breath = (new == .recording)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("keyboard.tapToTalkA11y"))
|
.accessibilityLabel(ExtL10n.text("keyboard.tapToTalkA11y"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatRemaining(_ seconds: Int) -> String {
|
private func formatRemaining(_ seconds: Int) -> String {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
static let localeId = "config.localeId"
|
static let localeId = "config.localeId"
|
||||||
static let engineMode = "config.engineMode"
|
static let engineMode = "config.engineMode"
|
||||||
static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
||||||
|
static let onboardingPage = "config.onboardingPage"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published public var providerId: String {
|
@Published public var providerId: String {
|
||||||
@@ -68,7 +69,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
didSet { defaults.set(engineMode, forKey: Key.engineMode) }
|
didSet { defaults.set(engineMode, forKey: Key.engineMode) }
|
||||||
}
|
}
|
||||||
@Published public var hasCompletedOnboarding: Bool {
|
@Published public var hasCompletedOnboarding: Bool {
|
||||||
didSet { defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding) }
|
didSet {
|
||||||
|
defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding)
|
||||||
|
if hasCompletedOnboarding {
|
||||||
|
onboardingPage = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Persisted onboarding step so returning from Settings does not reset progress.
|
||||||
|
@Published public var onboardingPage: Int {
|
||||||
|
didSet { defaults.set(onboardingPage, forKey: Key.onboardingPage) }
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isConfigured: Bool {
|
public var isConfigured: Bool {
|
||||||
@@ -113,6 +123,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto"
|
self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto"
|
||||||
self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud"
|
self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud"
|
||||||
self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding)
|
self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding)
|
||||||
|
let savedPage = resolvedDefaults.integer(forKey: Key.onboardingPage)
|
||||||
|
self.onboardingPage = savedPage > 0 ? savedPage : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the API key from the Keychain, falling back to a one-time
|
/// Read the API key from the Keychain, falling back to a one-time
|
||||||
|
|||||||
+17
-2
@@ -5,6 +5,10 @@
|
|||||||
name: OSGKeyboard
|
name: OSGKeyboard
|
||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.osgkeyboard
|
bundleIdPrefix: com.osgkeyboard
|
||||||
|
# Icon Composer bundles must stay intact; do not explode into icon.json + SVG.
|
||||||
|
fileTypes:
|
||||||
|
icon:
|
||||||
|
file: true
|
||||||
# Minimum OS: iOS 26. We dropped older iOS support so the
|
# Minimum OS: iOS 26. We dropped older iOS support so the
|
||||||
# legacy speech + AVAudioSession branching could be removed in
|
# legacy speech + AVAudioSession branching could be removed in
|
||||||
# favour of iOS 26's `SpeechAnalyzer` (always on-device) and
|
# favour of iOS 26's `SpeechAnalyzer` (always on-device) and
|
||||||
@@ -44,6 +48,10 @@ targets:
|
|||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
- path: OSGKeyboard
|
- path: OSGKeyboard
|
||||||
|
excludes:
|
||||||
|
- "AppIcon.icon"
|
||||||
|
- path: OSGKeyboard/AppIcon.icon
|
||||||
|
buildPhase: resources
|
||||||
entitlements:
|
entitlements:
|
||||||
path: OSGKeyboard/OSGKeyboard.entitlements
|
path: OSGKeyboard/OSGKeyboard.entitlements
|
||||||
properties:
|
properties:
|
||||||
@@ -95,6 +103,9 @@ targets:
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios
|
||||||
TARGETED_DEVICE_FAMILY: "1"
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
# AppIcon.icon (Liquid Glass) + AppIcon.appiconset (fallback) share the name "AppIcon".
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: OSGKeyboardShared
|
- target: OSGKeyboardShared
|
||||||
embed: true
|
embed: true
|
||||||
@@ -114,8 +125,8 @@ targets:
|
|||||||
- "en.lproj"
|
- "en.lproj"
|
||||||
- "zh-Hans.lproj"
|
- "zh-Hans.lproj"
|
||||||
resources:
|
resources:
|
||||||
- path: OSGKeyboardExt/en.lproj/Localizable.strings
|
- path: OSGKeyboardExt/en.lproj
|
||||||
- path: OSGKeyboardExt/zh-Hans.lproj/Localizable.strings
|
- path: OSGKeyboardExt/zh-Hans.lproj
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
|
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
|
||||||
@@ -135,6 +146,10 @@ targets:
|
|||||||
CFBundleDisplayName: OSGKeyboard
|
CFBundleDisplayName: OSGKeyboard
|
||||||
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
||||||
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
||||||
|
CFBundleDevelopmentRegion: en
|
||||||
|
CFBundleLocalizations:
|
||||||
|
- en
|
||||||
|
- zh-Hans
|
||||||
NSMicrophoneUsageDescription: "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running."
|
NSMicrophoneUsageDescription: "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running."
|
||||||
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription."
|
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription."
|
||||||
NSExtension:
|
NSExtension:
|
||||||
|
|||||||
Reference in New Issue
Block a user