From 993784d223d059d3fd7a9b11cdea213586b588b0 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:14:13 +0800 Subject: [PATCH] feat: polish first-launch onboarding and post-setup permission guidance Streamline the five-step flow with smarter skip logic, a centered welcome intro, clearer zh copy, keyboard setup detection via the extension, and home tips when permissions are still missing after onboarding completes. --- OSGKeyboard/OSGKeyboardApp.swift | 5 - OSGKeyboard/Services/AppPermissions.swift | 28 + OSGKeyboard/Views/HomeView.swift | 141 ++++- OSGKeyboard/Views/OnboardingView.swift | 522 +++++++++++------- OSGKeyboard/Views/SettingsView.swift | 21 + OSGKeyboard/en.lproj/Localizable.strings | 52 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 64 ++- OSGKeyboardExt/KeyboardViewController.swift | 1 + OSGKeyboardExt/en.lproj/Keyboard.strings | 2 +- OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 2 +- .../Services/KeyboardSetupBridge.swift | 29 + 11 files changed, 591 insertions(+), 276 deletions(-) create mode 100644 OSGKeyboardShared/Services/KeyboardSetupBridge.swift diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift index 7ea719c..181df28 100644 --- a/OSGKeyboard/OSGKeyboardApp.swift +++ b/OSGKeyboard/OSGKeyboardApp.swift @@ -14,11 +14,6 @@ struct OSGKeyboardApp: App { 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 { diff --git a/OSGKeyboard/Services/AppPermissions.swift b/OSGKeyboard/Services/AppPermissions.swift index 5e73946..cf46743 100644 --- a/OSGKeyboard/Services/AppPermissions.swift +++ b/OSGKeyboard/Services/AppPermissions.swift @@ -76,4 +76,32 @@ enum AppPermissions { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } + + /// Home-screen guidance when Flow permissions are missing after onboarding. + static var homePermissionGuidanceMessage: String { + let micMissing = micStatus != .granted + let speechMissing = speechStatus != .granted + if micMissing && speechMissing { + return NSLocalizedString("home.setup.permission.both", comment: "") + } + if micMissing { + return NSLocalizedString("home.setup.permission.mic", comment: "") + } + return NSLocalizedString("home.setup.permission.speech", comment: "") + } + + /// True when at least one permission can still be requested in-app. + static var canRequestPermissionsInApp: Bool { + micStatus == .undetermined || speechStatus == .undetermined + } + + /// Requests any still-undetermined Flow permissions in order. + static func requestFlowPermissionsIfNeeded() async { + if micStatus == .undetermined { + _ = await requestMicrophone() + } + if speechStatus == .undetermined { + _ = await requestSpeechRecognition() + } + } } diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index d5cddfe..14380ab 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -5,19 +5,40 @@ import SwiftUI import OSGKeyboardShared +import UIKit struct HomeView: View { @Environment(\.themePalette) private var palette: ThemePalette + @Environment(\.scenePhase) private var scenePhase @ObservedObject private var config = ProviderConfig.shared @EnvironmentObject private var flowManager: FlowSessionManager @FocusState private var previewFocused: Bool @State private var previewText = "" + @State private var keyboardHintDismissed = HomeGuideState.isKeyboardHintDismissed + @State private var micStatus = AppPermissions.micStatus + @State private var speechStatus = AppPermissions.speechStatus private var sessionIsLive: Bool { flowManager.isActive || flowManager.isStarting } + private var needsCloudSetup: Bool { + !config.isLocalEngine && !config.isConfigured + } + + private var needsPermissionSetup: Bool { + micStatus != .granted || speechStatus != .granted + } + + private var shouldShowKeyboardHint: Bool { + !keyboardHintDismissed + && !KeyboardSetupBridge.isReadyForOnboardingSkip + && !needsPermissionSetup + && flowManager.sessionWarning == nil + && !needsCloudSetup + } + var body: some View { GeometryReader { geo in let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top @@ -49,6 +70,33 @@ struct HomeView: View { } .background(palette.background) } + .onAppear { refreshPermissionStatuses() } + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + refreshPermissionStatuses() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + refreshPermissionStatuses() + } + } + + private func refreshPermissionStatuses() { + micStatus = AppPermissions.micStatus + speechStatus = AppPermissions.speechStatus + if AppPermissions.flowRequirementsMet { + flowManager.autoStartIfNeeded() + } + } + + private func handlePermissionGuidanceAction() { + if AppPermissions.canRequestPermissionsInApp { + Task { + await AppPermissions.requestFlowPermissionsIfNeeded() + refreshPermissionStatuses() + } + } else { + AppPermissions.openSystemSettings() + } } // MARK: - Top gradient @@ -152,34 +200,57 @@ struct HomeView: View { : palette.surface.opacity(0.88) } - // MARK: - Flow extras (warnings / hint) + // MARK: - Flow extras (warnings / hints) @ViewBuilder private var flowSessionExtras: some View { - if let warning = flowManager.sessionWarning { - VStack(alignment: .leading, spacing: Spacing.sm) { + if needsPermissionSetup { + setupGuidanceCard { + Text(AppPermissions.homePermissionGuidanceMessage) + .font(TypeStyle.caption2) + .foregroundStyle(palette.warning) + .fixedSize(horizontal: false, vertical: true) + Button(action: handlePermissionGuidanceAction) { + Text( + AppPermissions.canRequestPermissionsInApp + ? "home.setup.permission.request" + : "home.flow.openSettings" + ) + .font(TypeStyle.caption) + .foregroundStyle(palette.accent) + } + .buttonStyle(.plain) + } + } else if let warning = flowManager.sessionWarning { + setupGuidanceCard { Text(warning) .font(TypeStyle.caption2) .foregroundStyle(palette.warning) .fixedSize(horizontal: false, vertical: true) - if !AppPermissions.flowRequirementsMet { - Button { - AppPermissions.openSystemSettings() - } label: { - Text("home.flow.openSettings") - .font(TypeStyle.caption) - .foregroundStyle(palette.accent) - } - .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 needsCloudSetup { + setupGuidanceCard { + Text("home.setup.cloudIncomplete") + .font(TypeStyle.caption2) + .foregroundStyle(palette.warning) + .fixedSize(horizontal: false, vertical: true) + } + } else if shouldShowKeyboardHint { + setupGuidanceCard { + Text("home.setup.keyboardHint") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Button { + keyboardHintDismissed = true + HomeGuideState.dismissKeyboardHint() + } label: { + Text("home.setup.keyboardHint.dismiss") + .font(TypeStyle.caption) + .foregroundStyle(palette.accent) + } + .buttonStyle(.plain) + } } else if !flowManager.isActive { Text("home.flow.hint") .font(TypeStyle.caption2) @@ -190,9 +261,23 @@ struct HomeView: View { } } + private func setupGuidanceCard(@ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + content() + } + .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) + ) + } + private var flowStatusColor: Color { if flowManager.isActive { return palette.accent } if flowManager.isStarting { return palette.accent } + if needsPermissionSetup { return palette.warning } if flowManager.sessionWarning != nil { return palette.warning } return palette.textTertiary } @@ -235,3 +320,19 @@ struct HomeView: View { .frame(maxWidth: .infinity, alignment: .center) } } + +// MARK: - Home guidance persistence + +private enum HomeGuideState { + private static let keyboardHintDismissedKey = "home.keyboardHintDismissed" + + static var isKeyboardHintDismissed: Bool { + guard AppGroup.isAvailable else { return false } + return AppGroup.defaults.bool(forKey: keyboardHintDismissedKey) + } + + static func dismissKeyboardHint() { + guard AppGroup.isAvailable else { return } + AppGroup.defaults.set(true, forKey: keyboardHintDismissedKey) + } +} diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 5f485fd..2cc69c7 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -29,6 +29,11 @@ struct OnboardingView: View { @ObservedObject var config: ProviderConfig @State private var micStatus = AppPermissions.micStatus @State private var speechStatus = AppPermissions.speechStatus + @State private var keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip + + private var currentPage: OnboardingPage { + OnboardingPage(rawValue: config.onboardingPage) ?? .welcome + } var body: some View { GeometryReader { geo in @@ -38,15 +43,22 @@ struct OnboardingView: View { palette.background.ignoresSafeArea() VStack(spacing: 0) { + progressHeader + .padding(.top, Spacing.md) + .padding(.bottom, Spacing.sm) + Group { - switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome { - case .welcome: WelcomePage() + switch currentPage { + case .welcome: + WelcomePage() case .microphone: - MicPermissionPage(status: $micStatus) + MicPermissionPage(status: $micStatus, showsPreface: speechStatus != .granted) case .speech: SpeechPermissionPage(status: $speechStatus) - case .keyboard: EnableKeyboardPage() - case .api: APISetupPage(config: config) + case .keyboard: + EnableKeyboardPage() + case .api: + APISetupPage(config: config) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -69,11 +81,95 @@ struct OnboardingView: View { if config.onboardingPage < 0 || config.onboardingPage >= OnboardingPage.count { config.onboardingPage = 0 } + applyOnboardingDefaultsIfNeeded() refreshPermissionStatuses() + snapToVisiblePageIfNeeded() } .onChange(of: scenePhase) { _, phase in if phase == .active { refreshPermissionStatuses() } } + .onChange(of: micStatus) { previous, current in + guard currentPage == .microphone, current == .granted, previous != .granted else { return } + advanceAfterPermissionGrant() + } + .onChange(of: speechStatus) { previous, current in + guard currentPage == .speech, current == .granted, previous != .granted else { return } + advanceAfterPermissionGrant() + } + } + + // MARK: - Navigation helpers + + private func applyOnboardingDefaultsIfNeeded() { + guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return } + // First-time users with no API key: default to local for a faster path. + if config.apiKey.isEmpty, config.engineMode == "cloud" { + config.engineMode = "local" + config.modeId = "transcribe" + } + } + + private func shouldShowPage(_ page: OnboardingPage) -> Bool { + switch page { + case .microphone: return micStatus != .granted + case .speech: return speechStatus != .granted + case .keyboard: return !keyboardReady + default: return true + } + } + + private func nextVisiblePage(after page: Int) -> Int? { + guard page + 1 < OnboardingPage.count else { return nil } + for index in (page + 1).. Int? { + guard page > 0 else { return nil } + for index in stride(from: page - 1, through: 0, by: -1) { + guard let candidate = OnboardingPage(rawValue: index) else { continue } + if shouldShowPage(candidate) { return index } + } + return nil + } + + private func snapToVisiblePageIfNeeded() { + guard let page = OnboardingPage(rawValue: config.onboardingPage) else { return } + guard !shouldShowPage(page) else { return } + if let next = nextVisiblePage(after: config.onboardingPage) { + config.onboardingPage = next + } else if let previous = previousVisiblePage(before: config.onboardingPage) { + config.onboardingPage = previous + } + } + + private func advanceAfterPermissionGrant() { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(400)) + guard currentPage == .microphone || currentPage == .speech else { return } + refreshPermissionStatuses() + withAnimation(Motion.soft) { + if let next = nextVisiblePage(after: config.onboardingPage) { + config.onboardingPage = next + } + } + } + } + + private func advancePage() { + refreshPermissionStatuses() + withAnimation(Motion.soft) { + if isLastPage { + config.hasCompletedOnboarding = true + } else if let next = nextVisiblePage(after: config.onboardingPage) { + config.onboardingPage = next + } else { + config.hasCompletedOnboarding = true + } + } } private func onboardingHeaderGradient(height: CGFloat) -> some View { @@ -93,6 +189,20 @@ struct OnboardingView: View { private func refreshPermissionStatuses() { micStatus = AppPermissions.micStatus speechStatus = AppPermissions.speechStatus + keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip + } + + private var progressHeader: some View { + Text( + String( + format: NSLocalizedString("onboarding.progress", comment: ""), + config.onboardingPage + 1, + OnboardingPage.count + ) + ) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity) } private var pageDots: some View { @@ -109,7 +219,7 @@ struct OnboardingView: View { private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue } private var canAdvance: Bool { - switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome { + switch currentPage { case .welcome, .keyboard, .api: return !isLastPage || config.isConfigured case .microphone: @@ -119,11 +229,26 @@ struct OnboardingView: View { } } + private var primaryActionTitle: String { + if isLastPage { + return NSLocalizedString("common.done", comment: "") + } + switch currentPage { + case .microphone where micStatus == .granted, + .speech where speechStatus == .granted: + return NSLocalizedString("common.continue", comment: "") + default: + return NSLocalizedString("common.next", comment: "") + } + } + @ViewBuilder private var bottomBar: some View { HStack(spacing: Spacing.sm) { - if config.onboardingPage > 0 { - Button { withAnimation(Motion.soft) { config.onboardingPage -= 1 } } label: { + if let previous = previousVisiblePage(before: config.onboardingPage) { + Button { + withAnimation(Motion.soft) { config.onboardingPage = previous } + } label: { Text("common.back") .font(TypeStyle.headline) .frame(maxWidth: .infinity, minHeight: 50) @@ -137,18 +262,8 @@ struct OnboardingView: View { .buttonStyle(.plain) } - Button { - withAnimation(Motion.soft) { - if isLastPage { - config.hasCompletedOnboarding = true - } else { - config.onboardingPage += 1 - } - } - } label: { - Text(isLastPage - ? NSLocalizedString("common.done", comment: "") - : NSLocalizedString("common.next", comment: "")) + Button { advancePage() } label: { + Text(primaryActionTitle) .font(TypeStyle.headline) .frame(maxWidth: .infinity, minHeight: 50) .background( @@ -185,40 +300,72 @@ private struct OnboardingHeroIcon: View { } private enum OnboardingLayoutMetrics { - /// Matches welcome page: logo → tagline, and subtitle → next block. + /// Matches welcome page: hero → title block, and title block → next block. static let heroTextGap: CGFloat = Spacing.hero } +/// Title + subtitle styling aligned with the welcome page. +private struct OnboardingTitleBlock: View { + @Environment(\.themePalette) private var palette: ThemePalette + + let title: LocalizedStringKey + var subtitle: LocalizedStringKey? = nil + var secondarySubtitle: LocalizedStringKey? = nil + + var body: some View { + VStack(spacing: Spacing.xs) { + Text(title) + .font(TypeStyle.title3) + .foregroundStyle(palette.textPrimary) + if let subtitle { + Text(subtitle) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + } + if let secondarySubtitle { + Text(secondarySubtitle) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + } + } + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.xl) + } +} + // MARK: - Welcome private struct WelcomePage: View { @Environment(\.themePalette) private var palette: ThemePalette + @State private var logoAppeared = false var body: some View { VStack(spacing: 0) { - Spacer(minLength: Spacing.xl) + Spacer() Image("osglogo") .resizable() .scaledToFit() .frame(maxWidth: 168, maxHeight: 48) + .opacity(logoAppeared ? 1 : 0) + .offset(y: logoAppeared ? 0 : 14) + .scaleEffect(logoAppeared ? 1 : 0.9) .accessibilityHidden(true) + .onAppear { + withAnimation(.spring(response: 0.75, dampingFraction: 0.82)) { + logoAppeared = true + } + } - VStack(spacing: Spacing.xs) { - Text("onboarding.welcome.tagline") - .font(TypeStyle.title3) - .foregroundStyle(palette.textPrimary) - Text("onboarding.welcome.subtitle") - .font(TypeStyle.body) - .foregroundStyle(palette.textSecondary) - } - .multilineTextAlignment(.center) - .padding(.horizontal, Spacing.xl) + OnboardingTitleBlock( + title: "onboarding.welcome.tagline", + subtitle: "onboarding.welcome.subtitle", + secondarySubtitle: "onboarding.welcome.subtitle2" + ) + .opacity(logoAppeared ? 1 : 0) + .offset(y: logoAppeared ? 0 : 10) .padding(.top, OnboardingLayoutMetrics.heroTextGap) - - PrivacyFootnote() - .padding(.horizontal, Spacing.xl) - .padding(.top, OnboardingLayoutMetrics.heroTextGap) + .animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared) if let url = LegalLinks.privacyPolicyURL { Link(destination: url) { @@ -227,36 +374,13 @@ private struct WelcomePage: View { .foregroundStyle(palette.accent) } .padding(.top, Spacing.xxxl) + .opacity(logoAppeared ? 1 : 0) + .animation(.easeOut(duration: 0.45).delay(0.28), value: logoAppeared) } Spacer() } - } -} - -private struct PrivacyFootnote: View { - @Environment(\.themePalette) private var palette: ThemePalette - - var body: some View { - VStack(alignment: .leading, spacing: Spacing.xl) { - footnoteBlock(title: "privacy.audio.title", body: "privacy.audio.body") - footnoteBlock(title: "privacy.network.title", body: "privacy.network.body") - footnoteBlock(title: "privacy.universal.title", body: "privacy.universal.body") - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - 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) - } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -265,6 +389,7 @@ private struct PrivacyFootnote: View { private struct MicPermissionPage: View { @Environment(\.themePalette) private var palette: ThemePalette @Binding var status: AppPermissions.MicStatus + var showsPreface: Bool = false @State private var isRequesting = false var body: some View { @@ -272,13 +397,12 @@ private struct MicPermissionPage: View { icon: "mic.fill", title: "onboarding.permission.mic.title", detail: "onboarding.permission.mic.body", + preface: showsPreface ? "onboarding.permission.preface" : nil, status: statusLabel, statusColor: statusColor, primaryTitle: primaryButtonTitle, - primaryDisabled: isRequesting || status == .granted, + primaryDisabled: isRequesting || (status == .granted), onPrimary: { Task { await request() } }, - secondaryTitle: status == .denied ? "onboarding.permission.openSettings" : nil, - onSecondary: status == .denied ? { AppPermissions.openSystemSettings() } : nil, deniedHint: status == .denied ? "onboarding.permission.mic.deniedHint" : nil ) .onAppear { status = AppPermissions.micStatus } @@ -304,10 +428,18 @@ private struct MicPermissionPage: View { } private var primaryButtonTitle: LocalizedStringKey { - status == .granted ? "onboarding.permission.status.granted" : "onboarding.permission.mic.allow" + switch status { + case .granted: return "onboarding.permission.status.granted" + case .denied: return "onboarding.permission.openSettings" + case .undetermined: return "onboarding.permission.mic.allow" + } } private func request() async { + if status == .denied { + AppPermissions.openSystemSettings() + return + } isRequesting = true _ = await AppPermissions.requestMicrophone() status = AppPermissions.micStatus @@ -330,8 +462,6 @@ private struct SpeechPermissionPage: View { primaryTitle: primaryButtonTitle, primaryDisabled: isRequesting || status == .granted, onPrimary: { Task { await request() } }, - secondaryTitle: speechDenied ? "onboarding.permission.openSettings" : nil, - onSecondary: speechDenied ? { AppPermissions.openSystemSettings() } : nil, deniedHint: speechDenied ? "onboarding.permission.speech.deniedHint" : nil ) .onAppear { status = AppPermissions.speechStatus } @@ -364,12 +494,18 @@ private struct SpeechPermissionPage: View { } private var primaryButtonTitle: LocalizedStringKey { - status == .granted - ? "onboarding.permission.status.granted" - : "onboarding.permission.speech.allow" + switch status { + case .granted: return "onboarding.permission.status.granted" + case .denied, .restricted: return "onboarding.permission.openSettings" + case .undetermined: return "onboarding.permission.speech.allow" + } } private func request() async { + if speechDenied { + AppPermissions.openSystemSettings() + return + } isRequesting = true _ = await AppPermissions.requestSpeechRecognition() status = AppPermissions.speechStatus @@ -383,6 +519,7 @@ private struct PermissionPageLayout: View { let icon: String let title: LocalizedStringKey let detail: LocalizedStringKey + var preface: LocalizedStringKey? = nil let status: LocalizedStringKey let statusColor: Color let primaryTitle: LocalizedStringKey @@ -393,60 +530,62 @@ private struct PermissionPageLayout: View { var deniedHint: LocalizedStringKey? = nil var body: some View { - VStack(spacing: 0) { - Spacer() + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: Spacing.lg) - OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40) - - VStack(spacing: Spacing.xs) { - Text(title) - .font(TypeStyle.title2) - .foregroundStyle(palette.textPrimary) - Text(detail) - .font(TypeStyle.body) - .foregroundStyle(palette.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, Spacing.xl) - } - .padding(.top, OnboardingLayoutMetrics.heroTextGap) - - HStack(spacing: 6) { - Circle().fill(statusColor).frame(width: 8, height: 8) - Text(status) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - .padding(.top, OnboardingLayoutMetrics.heroTextGap) - - if let deniedHint { - Text(deniedHint) - .font(TypeStyle.caption2) - .foregroundStyle(palette.warning) - .multilineTextAlignment(.center) - .padding(.horizontal, Spacing.xl) - .padding(.top, Spacing.md) - } - - VStack(spacing: Spacing.sm) { - Button(action: onPrimary) { - Text(primaryTitle) - .primaryButton() + if let preface { + Text(preface) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.lg) + .padding(.bottom, Spacing.lg) } - .buttonStyle(.plain) - .disabled(primaryDisabled) - if let secondaryTitle, let onSecondary { - Button(action: onSecondary) { - Text(secondaryTitle) - .secondaryButton() + OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40) + + OnboardingTitleBlock(title: title, subtitle: detail) + .padding(.top, OnboardingLayoutMetrics.heroTextGap) + + HStack(spacing: 6) { + Circle().fill(statusColor).frame(width: 8, height: 8) + Text(status) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + .padding(.top, OnboardingLayoutMetrics.heroTextGap) + + if let deniedHint { + Text(deniedHint) + .font(TypeStyle.caption2) + .foregroundStyle(palette.warning) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.xl) + .padding(.top, Spacing.md) + } + + VStack(spacing: Spacing.sm) { + Button(action: onPrimary) { + Text(primaryTitle) + .primaryButton() } .buttonStyle(.plain) - } - } - .padding(.horizontal, Spacing.lg) - .padding(.top, Spacing.xl) + .disabled(primaryDisabled) - Spacer() + if let secondaryTitle, let onSecondary { + Button(action: onSecondary) { + Text(secondaryTitle) + .secondaryButton() + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, Spacing.lg) + .padding(.top, Spacing.xl) + + Spacer(minLength: Spacing.lg) + } } } } @@ -457,54 +596,45 @@ private struct EnableKeyboardPage: View { @Environment(\.themePalette) private var palette: ThemePalette var body: some View { - VStack(spacing: 0) { - Spacer() + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: Spacing.lg) - OnboardingHeroIcon(systemName: "keyboard.fill", circleSize: 96, iconSize: 40) + OnboardingHeroIcon(systemName: "keyboard.fill", circleSize: 96, iconSize: 40) - VStack(spacing: Spacing.xs) { - Text("onboarding.enable.title") - .font(TypeStyle.title2) - .foregroundStyle(palette.textPrimary) - Text("onboarding.enable.fullAccessNote") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, Spacing.lg) + OnboardingTitleBlock( + title: "onboarding.enable.title", + subtitle: "onboarding.enable.fullAccessNote" + ) + .padding(.top, OnboardingLayoutMetrics.heroTextGap) + + VStack(alignment: .leading, spacing: Spacing.lg) { + step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: "")) + step(num: 2, text: NSLocalizedString("onboarding.enable.step2", comment: "")) + switchKeyboardStep(num: 3) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Spacing.xl) + .padding(.top, OnboardingLayoutMetrics.heroTextGap) + + Button { + AppPermissions.openSystemSettings() + } label: { + Label(LocalizedStringKey("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square") + .primaryButton() + } + .buttonStyle(.plain) + .padding(.horizontal, Spacing.lg) + .padding(.top, Spacing.xxxl) + + Spacer(minLength: Spacing.lg) } - .padding(.top, OnboardingLayoutMetrics.heroTextGap) - - 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: "")) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Spacing.xl) - .padding(.top, OnboardingLayoutMetrics.heroTextGap) - - Button { - AppPermissions.openSystemSettings() - } label: { - Label(LocalizedStringKey("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square") - .primaryButton() - } - .buttonStyle(.plain) - .padding(.horizontal, Spacing.lg) - .padding(.top, Spacing.xxl) - - Spacer() } } private func step(num: Int, text: String) -> some View { HStack(alignment: .top, spacing: Spacing.sm) { - Text("\(num)") - .font(TypeStyle.caption2) - .frame(width: 24, height: 24) - .background(palette.accent, in: Circle()) - .foregroundStyle(palette.textOnAccent) + stepLabel(num) Text(text) .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) @@ -513,6 +643,34 @@ private struct EnableKeyboardPage: View { .fixedSize(horizontal: false, vertical: true) } } + + private func switchKeyboardStep(num: Int) -> some View { + HStack(alignment: .top, spacing: Spacing.sm) { + stepLabel(num) + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text("onboarding.enable.step3.prefix") + Image(systemName: "globe") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .alignmentGuide(.firstTextBaseline) { dimensions in + dimensions[.bottom] - dimensions.height * 0.12 + } + Text("onboarding.enable.step3.suffix") + } + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func stepLabel(_ num: Int) -> some View { + Text("\(num)") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .frame(width: 20, alignment: .leading) + } } // MARK: - API setup @@ -524,17 +682,12 @@ private struct APISetupPage: View { var body: some View { ScrollView { - 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(.horizontal, Spacing.lg) - .padding(.top, Spacing.xxxl) + VStack(spacing: Spacing.lg) { + OnboardingHeroIcon(systemName: "cpu", circleSize: 72, iconSize: 30) + .padding(.top, Spacing.xl) + + OnboardingTitleBlock(title: "onboarding.api.title") + .padding(.horizontal, Spacing.lg) EnginePickerSection(config: config) .padding(.horizontal, Spacing.lg) @@ -544,35 +697,6 @@ private struct APISetupPage: View { .padding(.horizontal, Spacing.lg) APISettingsCard(config: config) .padding(.horizontal, Spacing.lg) - } else { - VStack(alignment: .leading, spacing: Spacing.xs) { - HStack(spacing: Spacing.sm) { - 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) - Text("onboarding.api.localReady.body") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - } - Spacer() - } - .padding(Spacing.lg) - } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - .padding(.horizontal, Spacing.lg) } } .padding(.bottom, Spacing.xxxl) diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 787276b..e74be30 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -276,6 +276,27 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.about.title") VStack(spacing: 0) { + Button { + config.hasCompletedOnboarding = false + config.onboardingPage = 0 + } label: { + HStack(spacing: Spacing.sm) { + Text("settings.onboarding.replay") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Divider().background(palette.divider) + if let url = LegalLinks.privacyPolicyURL { footerLinkRow(title: "settings.privacy.policy", url: url) Divider().background(palette.divider) diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index b1ff851..bbd634c 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -3,32 +3,33 @@ "OSGKeyboard" = "OSGKeyboard"; /* Onboarding */ +"onboarding.progress" = "Step %1$d of %2$d"; "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" = "Voice input requires your permission to use the microphone."; -"onboarding.permission.mic.allow" = "Allow microphone"; -"onboarding.permission.mic.deniedHint" = "Microphone was denied. Open Settings to enable it, or tap Next to continue."; -"onboarding.permission.speech.title" = "Speech recognition"; -"onboarding.permission.speech.body" = "On-device speech recognition turns your voice into text."; -"onboarding.permission.speech.allow" = "Allow speech recognition"; -"onboarding.permission.speech.deniedHint" = "Speech recognition was denied. Open Settings to enable it, or tap Next to continue."; +"onboarding.welcome.subtitle" = "Open-source voice keyboard"; +"onboarding.welcome.subtitle2" = "We never read or upload anything you type."; +"onboarding.permission.preface" = "iOS will ask for two permissions — please allow both."; +"onboarding.permission.mic.title" = "Microphone"; +"onboarding.permission.mic.body" = "Records your voice."; +"onboarding.permission.mic.allow" = "Allow"; +"onboarding.permission.mic.deniedHint" = "Open Settings to enable, or tap Next to set up later."; +"onboarding.permission.speech.title" = "Speech Recognition"; +"onboarding.permission.speech.body" = "Turns your voice into text."; +"onboarding.permission.speech.allow" = "Allow"; +"onboarding.permission.speech.deniedHint" = "Open Settings to enable, or tap Next to set up later."; "onboarding.permission.openSettings" = "Open Settings"; "onboarding.permission.status.undetermined" = "Not requested yet"; "onboarding.permission.status.granted" = "Allowed"; "onboarding.permission.status.denied" = "Denied"; "legal.privacyPolicy" = "Privacy Policy"; -"onboarding.enable.title" = "Enable OSGKeyboard"; -"onboarding.enable.step1" = "Settings → General → Keyboard → Keyboards"; -"onboarding.enable.step2" = "Tap “Add New Keyboard…” and choose OSGKeyboard"; -"onboarding.enable.step3" = "Tap OSGKeyboard and enable “Allow Full Access”"; -"onboarding.enable.step4" = "Allow Full Access is required for the microphone and LLM calls."; -"onboarding.enable.openSettings" = "Open iOS Settings"; -"onboarding.api.title" = "Choose engine"; -"onboarding.api.subtitle" = "Local needs no API key; Cloud polishes your text via LLM."; -"onboarding.api.localReady.title" = "No API key needed"; -"onboarding.api.localReady.body" = "Local recognition is ready to use. Tap Done to start."; +"onboarding.enable.title" = "Add Keyboard"; +"onboarding.enable.fullAccessNote" = "Turn on Allow Full Access so the mic can work."; +"onboarding.enable.step1" = "Settings → General → Keyboard → Add New Keyboard"; +"onboarding.enable.step2" = "Select OSGKeyboard and enable Allow Full Access"; +"onboarding.enable.step3.prefix" = "Hold "; +"onboarding.enable.step3.suffix" = "and select OSGKeyboard"; +"onboarding.enable.openSettings" = "Open Settings"; +"onboarding.api.title" = "Choose Engine"; +"settings.onboarding.replay" = "Restart permission setup"; /* Common navigation */ "common.back" = "Back"; @@ -42,7 +43,7 @@ "common.space" = "Space"; "common.newline" = "Return"; -/* Privacy footnote (onboarding welcome page) */ +/* Privacy footnote (onboarding engine page) */ "privacy.audio.title" = "On-device transcription"; "privacy.audio.body" = "Powered by the on-device iOS speech engine."; "privacy.network.title" = "Automatic text polish"; @@ -186,7 +187,14 @@ "home.flow.active" = "Voice session active"; "home.flow.label" = "Ready"; "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.hint" = "Switch to any app and tap the keyboard mic to dictate."; +"home.setup.permission.mic" = "Microphone access is off — voice input won't work."; +"home.setup.permission.speech" = "Speech recognition is off — voice input won't work."; +"home.setup.permission.both" = "Microphone and speech recognition are off — voice input won't work."; +"home.setup.permission.request" = "Grant access"; +"home.setup.cloudIncomplete" = "Cloud engine needs an API key. Open the Settings tab."; +"home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings."; +"home.setup.keyboardHint.dismiss" = "Got it"; "home.flow.starting" = "Starting voice session…"; "home.flow.openSettings" = "Open Settings to grant permissions"; "home.flow.start" = "Start voice session"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index d4408d6..d49937f 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -3,32 +3,33 @@ "OSGKeyboard" = "OSGKeyboard"; /* Onboarding */ +"onboarding.progress" = "第 %1$d / %2$d 步"; "onboarding.welcome.tagline" = "能说就不打字"; -"onboarding.welcome.subtitle" = "你的开源语音输入法"; -"onboarding.enable.fullAccessNote" = "完全访问用于麦克风与 API 配置读取。我们不会记录或上传你的击键内容。"; -"onboarding.permission.mic.title" = "麦克风权限"; -"onboarding.permission.mic.body" = "语音输入需要您授予麦克风权限"; -"onboarding.permission.mic.allow" = "允许麦克风"; -"onboarding.permission.mic.deniedHint" = "麦克风被拒绝。可前往设置开启,或点「下一步」继续。"; -"onboarding.permission.speech.title" = "语音识别权限"; -"onboarding.permission.speech.body" = "端侧语音识别将语音转为文字。"; -"onboarding.permission.speech.allow" = "允许语音识别"; -"onboarding.permission.speech.deniedHint" = "语音识别被拒绝。可前往设置开启,或点「下一步」继续。"; +"onboarding.welcome.subtitle" = "开源语音输入法"; +"onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。"; +"onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。"; +"onboarding.permission.mic.title" = "麦克风"; +"onboarding.permission.mic.body" = "用于录制语音。"; +"onboarding.permission.mic.allow" = "允许"; +"onboarding.permission.mic.deniedHint" = "去设置打开,或先点「下一步」。"; +"onboarding.permission.speech.title" = "语音识别"; +"onboarding.permission.speech.body" = "把你说的话转成文字。"; +"onboarding.permission.speech.allow" = "允许"; +"onboarding.permission.speech.deniedHint" = "去设置打开,或先点「下一步」。"; "onboarding.permission.openSettings" = "打开设置"; "onboarding.permission.status.undetermined" = "尚未请求"; "onboarding.permission.status.granted" = "已允许"; "onboarding.permission.status.denied" = "已拒绝"; "legal.privacyPolicy" = "隐私政策"; -"onboarding.enable.title" = "启用 OSGKeyboard"; -"onboarding.enable.step1" = "设置 → 通用 → 键盘 → 键盘"; -"onboarding.enable.step2" = "点击「添加新键盘…」并选择 OSGKeyboard"; -"onboarding.enable.step3" = "点击 OSGKeyboard 并启用「允许完全访问」"; -"onboarding.enable.step4" = "允许完全访问是麦克风和网络调用的前提。"; -"onboarding.enable.openSettings" = "打开 iOS 设置"; -"onboarding.api.title" = "选择引擎"; -"onboarding.api.subtitle" = "Local 不需要 API Key;Cloud 用 LLM 润色文字。"; -"onboarding.api.localReady.title" = "无需配置 API Key"; -"onboarding.api.localReady.body" = "本地识别直接可用,按 Done 即可开始使用。"; +"onboarding.enable.title" = "添加键盘"; +"onboarding.enable.fullAccessNote" = "记得打开「允许完全访问」,不然麦克风用不了。"; +"onboarding.enable.step1" = "设置 → 通用 → 键盘 → 添加新键盘"; +"onboarding.enable.step2" = "选择 OSGKeyboard,打开「允许完全访问」"; +"onboarding.enable.step3.prefix" = "长按"; +"onboarding.enable.step3.suffix" = ",选中 OSGKeyboard"; +"onboarding.enable.openSettings" = "去设置"; +"onboarding.api.title" = "选择语音转文字 AI 引擎"; +"settings.onboarding.replay" = "重新开始权限引导"; /* Common navigation */ "common.back" = "返回"; @@ -42,7 +43,7 @@ "common.space" = "空格"; "common.newline" = "换行"; -/* Privacy footnote (onboarding welcome page) */ +/* Privacy footnote (onboarding engine page) */ "privacy.audio.title" = "支持本地转写"; "privacy.audio.body" = "支持 iOS 本地引擎转录"; "privacy.network.title" = "文字自动润色"; @@ -86,12 +87,12 @@ "settings.reset.message" = "API key、model 和 base URL 都会被清空。"; "settings.reset.confirm" = "重置所有设置"; "settings.engine.title" = "引擎"; -"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。"; +"settings.engine.subtitle" = "本地不用 Key,只转文字;云端会润色,要配 Key。"; "settings.engine.local.title" = "本地识别"; -"settings.engine.local.ios26" = "始终端侧,无需联网。"; +"settings.engine.local.ios26" = "全程在手机本地,不用联网"; "settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.cloud.title" = "云端润色"; -"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; +"settings.engine.cloud.subtitle" = "先转文字,再用 AI 润色,要 API Key"; "settings.provider.title" = "提供商"; "settings.provider.subtitle" = "选择 LLM 提供商。"; "provider.openai" = "OpenAI"; @@ -121,7 +122,7 @@ "settings.privacy.title" = "隐私"; "settings.privacy.policy" = "隐私政策"; "settings.privacy.fullAccess.title" = "关于完全访问"; -"settings.privacy.fullAccess.body" = "完全访问用于麦克风与读取 API Key。OSGKeyboard 不会记录或上传你在键盘上的击键内容。"; +"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。"; "settings.link.github" = "GitHub"; /* App group error */ @@ -185,14 +186,21 @@ "home.flow.active" = "语音会话进行中"; "home.flow.label" = "就绪"; "home.flow.inactive" = "语音会话未启动"; -"home.flow.hint" = "语音会话会自动启动。切到任意 App,点按键盘麦克风即可说话。"; +"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。"; +"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。"; +"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。"; +"home.setup.permission.both" = "麦克风和语音识别还没授权,语音输入用不了。"; +"home.setup.permission.request" = "去授权"; +"home.setup.cloudIncomplete" = "选了云端引擎,先去「设置」填 API Key。"; +"home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。"; +"home.setup.keyboardHint.dismiss" = "知道了"; "home.flow.starting" = "正在启动语音会话…"; -"home.flow.openSettings" = "前往设置授予权限"; +"home.flow.openSettings" = "去设置打开权限"; "home.flow.start" = "启动语音会话"; "home.flow.end" = "结束语音会话"; "home.flow.endShort" = "结束"; "home.preview.label" = "输入测试"; -"home.preview.placeholder" = "点击输入,测试键盘效果…"; +"home.preview.placeholder" = "点这里试试键盘"; /* Tabs */ "tab.keyboard" = "键盘"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 34a0d60..ccabe9b 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -99,6 +99,7 @@ public final class KeyboardViewController: UIInputViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess) consumePendingDictationResultIfNeeded() refreshDictationProgressStateIfNeeded() refreshFlowSessionState() diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 853f44b..0c4811c 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -3,7 +3,7 @@ "OSGKeyboard" = "OSGKeyboard"; /* Onboarding */ -"onboarding.welcome.subtitle" = "Hold to talk. Release for polished text, in any app."; +"onboarding.welcome.subtitle" = "Tap the mic to speak, tap again to finish — polished text in any app."; "onboarding.enable.title" = "Enable OSGKeyboard"; "onboarding.enable.step1" = "Settings → General → Keyboard → Keyboards"; "onboarding.enable.step2" = "Tap “Add New Keyboard…” and choose OSGKeyboard"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index fdab8e5..746fbe2 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -3,7 +3,7 @@ "OSGKeyboard" = "OSGKeyboard"; /* Onboarding */ -"onboarding.welcome.subtitle" = "按住说话,松开即得润色文字。"; +"onboarding.welcome.subtitle" = "点按麦克风说话,再点一次结束,文字即出现。"; "onboarding.enable.title" = "启用 OSGKeyboard"; "onboarding.enable.step1" = "设置 → 通用 → 键盘 → 键盘"; "onboarding.enable.step2" = "点击「添加新键盘…」并选择 OSGKeyboard"; diff --git a/OSGKeyboardShared/Services/KeyboardSetupBridge.swift b/OSGKeyboardShared/Services/KeyboardSetupBridge.swift new file mode 100644 index 0000000..6e633c3 --- /dev/null +++ b/OSGKeyboardShared/Services/KeyboardSetupBridge.swift @@ -0,0 +1,29 @@ +// KeyboardSetupBridge.swift +// OSGKeyboard · Shared +// +// The main app cannot query iOS for installed keyboards. The extension +// reports when it has appeared with Full Access so onboarding can skip +// the manual setup step for returning users. + +import Foundation + +public enum KeyboardSetupBridge { + private enum Key { + static let fullAccessReady = "keyboard.extension.fullAccessReady" + static let lastSeenAt = "keyboard.extension.lastSeenAt" + } + + /// True when the keyboard extension last appeared with Full Access enabled. + public static var isReadyForOnboardingSkip: Bool { + guard AppGroup.isAvailable else { return false } + return AppGroup.defaults.bool(forKey: Key.fullAccessReady) + } + + /// Called from the keyboard extension on each appearance. + public static func markExtensionAppearance(hasFullAccess: Bool) { + guard AppGroup.isAvailable else { return } + let defaults = AppGroup.defaults + defaults.set(Date().timeIntervalSince1970, forKey: Key.lastSeenAt) + defaults.set(hasFullAccess, forKey: Key.fullAccessReady) + } +}