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.
This commit is contained in:
Rocky
2026-06-21 16:14:13 +08:00
parent 642c97c540
commit 993784d223
11 changed files with 591 additions and 276 deletions
-5
View File
@@ -14,11 +14,6 @@ struct OSGKeyboardApp: App {
init() { init() {
MaterialIconsFont.registerIfNeeded() 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 { var body: some Scene {
+28
View File
@@ -76,4 +76,32 @@ enum AppPermissions {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return } guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url) 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()
}
}
} }
+121 -20
View File
@@ -5,19 +5,40 @@
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
import UIKit
struct HomeView: View { struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@ObservedObject private var config = ProviderConfig.shared @ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager @EnvironmentObject private var flowManager: FlowSessionManager
@FocusState private var previewFocused: Bool @FocusState private var previewFocused: Bool
@State private var previewText = "" @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 { private var sessionIsLive: Bool {
flowManager.isActive || flowManager.isStarting 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 { var body: some View {
GeometryReader { geo in GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
@@ -49,6 +70,33 @@ struct HomeView: View {
} }
.background(palette.background) .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 // MARK: - Top gradient
@@ -152,34 +200,57 @@ struct HomeView: View {
: palette.surface.opacity(0.88) : palette.surface.opacity(0.88)
} }
// MARK: - Flow extras (warnings / hint) // MARK: - Flow extras (warnings / hints)
@ViewBuilder @ViewBuilder
private var flowSessionExtras: some View { private var flowSessionExtras: some View {
if let warning = flowManager.sessionWarning { if needsPermissionSetup {
VStack(alignment: .leading, spacing: Spacing.sm) { 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) Text(warning)
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.warning) .foregroundStyle(palette.warning)
.fixedSize(horizontal: false, vertical: true) .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) } else if needsCloudSetup {
.padding(Spacing.md) setupGuidanceCard {
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) Text("home.setup.cloudIncomplete")
.overlay( .font(TypeStyle.caption2)
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) .foregroundStyle(palette.warning)
.stroke(palette.divider, lineWidth: 0.5) .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 { } else if !flowManager.isActive {
Text("home.flow.hint") Text("home.flow.hint")
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
@@ -190,9 +261,23 @@ struct HomeView: View {
} }
} }
private func setupGuidanceCard<Content: View>(@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 { private var flowStatusColor: Color {
if flowManager.isActive { return palette.accent } if flowManager.isActive { return palette.accent }
if flowManager.isStarting { return palette.accent } if flowManager.isStarting { return palette.accent }
if needsPermissionSetup { return palette.warning }
if flowManager.sessionWarning != nil { return palette.warning } if flowManager.sessionWarning != nil { return palette.warning }
return palette.textTertiary return palette.textTertiary
} }
@@ -235,3 +320,19 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .center) .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)
}
}
+323 -199
View File
@@ -29,6 +29,11 @@ struct OnboardingView: View {
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
@State private var micStatus = AppPermissions.micStatus @State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus @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 { var body: some View {
GeometryReader { geo in GeometryReader { geo in
@@ -38,15 +43,22 @@ struct OnboardingView: View {
palette.background.ignoresSafeArea() palette.background.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
progressHeader
.padding(.top, Spacing.md)
.padding(.bottom, Spacing.sm)
Group { Group {
switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome { switch currentPage {
case .welcome: WelcomePage() case .welcome:
WelcomePage()
case .microphone: case .microphone:
MicPermissionPage(status: $micStatus) MicPermissionPage(status: $micStatus, showsPreface: speechStatus != .granted)
case .speech: case .speech:
SpeechPermissionPage(status: $speechStatus) SpeechPermissionPage(status: $speechStatus)
case .keyboard: EnableKeyboardPage() case .keyboard:
case .api: APISetupPage(config: config) EnableKeyboardPage()
case .api:
APISetupPage(config: config)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -69,11 +81,95 @@ struct OnboardingView: View {
if config.onboardingPage < 0 || config.onboardingPage >= OnboardingPage.count { if config.onboardingPage < 0 || config.onboardingPage >= OnboardingPage.count {
config.onboardingPage = 0 config.onboardingPage = 0
} }
applyOnboardingDefaultsIfNeeded()
refreshPermissionStatuses() refreshPermissionStatuses()
snapToVisiblePageIfNeeded()
} }
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
if phase == .active { refreshPermissionStatuses() } 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)..<OnboardingPage.count {
guard let candidate = OnboardingPage(rawValue: index) else { continue }
if shouldShowPage(candidate) { return index }
}
return nil
}
private func previousVisiblePage(before page: Int) -> 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 { private func onboardingHeaderGradient(height: CGFloat) -> some View {
@@ -93,6 +189,20 @@ struct OnboardingView: View {
private func refreshPermissionStatuses() { private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus 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 { private var pageDots: some View {
@@ -109,7 +219,7 @@ struct OnboardingView: View {
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue } private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue }
private var canAdvance: Bool { private var canAdvance: Bool {
switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome { switch currentPage {
case .welcome, .keyboard, .api: case .welcome, .keyboard, .api:
return !isLastPage || config.isConfigured return !isLastPage || config.isConfigured
case .microphone: 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 @ViewBuilder
private var bottomBar: some View { private var bottomBar: some View {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
if config.onboardingPage > 0 { if let previous = previousVisiblePage(before: config.onboardingPage) {
Button { withAnimation(Motion.soft) { config.onboardingPage -= 1 } } label: { Button {
withAnimation(Motion.soft) { config.onboardingPage = previous }
} label: {
Text("common.back") Text("common.back")
.font(TypeStyle.headline) .font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50) .frame(maxWidth: .infinity, minHeight: 50)
@@ -137,18 +262,8 @@ struct OnboardingView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
Button { Button { advancePage() } label: {
withAnimation(Motion.soft) { Text(primaryActionTitle)
if isLastPage {
config.hasCompletedOnboarding = true
} else {
config.onboardingPage += 1
}
}
} label: {
Text(isLastPage
? NSLocalizedString("common.done", comment: "")
: NSLocalizedString("common.next", comment: ""))
.font(TypeStyle.headline) .font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50) .frame(maxWidth: .infinity, minHeight: 50)
.background( .background(
@@ -185,40 +300,72 @@ private struct OnboardingHeroIcon: View {
} }
private enum OnboardingLayoutMetrics { 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 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 // MARK: - Welcome
private struct WelcomePage: View { private struct WelcomePage: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@State private var logoAppeared = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer(minLength: Spacing.xl) Spacer()
Image("osglogo") Image("osglogo")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(maxWidth: 168, maxHeight: 48) .frame(maxWidth: 168, maxHeight: 48)
.opacity(logoAppeared ? 1 : 0)
.offset(y: logoAppeared ? 0 : 14)
.scaleEffect(logoAppeared ? 1 : 0.9)
.accessibilityHidden(true) .accessibilityHidden(true)
.onAppear {
withAnimation(.spring(response: 0.75, dampingFraction: 0.82)) {
logoAppeared = true
}
}
VStack(spacing: Spacing.xs) { OnboardingTitleBlock(
Text("onboarding.welcome.tagline") title: "onboarding.welcome.tagline",
.font(TypeStyle.title3) subtitle: "onboarding.welcome.subtitle",
.foregroundStyle(palette.textPrimary) secondarySubtitle: "onboarding.welcome.subtitle2"
Text("onboarding.welcome.subtitle") )
.font(TypeStyle.body) .opacity(logoAppeared ? 1 : 0)
.foregroundStyle(palette.textSecondary) .offset(y: logoAppeared ? 0 : 10)
}
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
.padding(.top, OnboardingLayoutMetrics.heroTextGap) .padding(.top, OnboardingLayoutMetrics.heroTextGap)
.animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared)
PrivacyFootnote()
.padding(.horizontal, Spacing.xl)
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
if let url = LegalLinks.privacyPolicyURL { if let url = LegalLinks.privacyPolicyURL {
Link(destination: url) { Link(destination: url) {
@@ -227,36 +374,13 @@ private struct WelcomePage: View {
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
} }
.padding(.top, Spacing.xxxl) .padding(.top, Spacing.xxxl)
.opacity(logoAppeared ? 1 : 0)
.animation(.easeOut(duration: 0.45).delay(0.28), value: logoAppeared)
} }
Spacer() Spacer()
} }
} .frame(maxWidth: .infinity, maxHeight: .infinity)
}
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)
}
} }
} }
@@ -265,6 +389,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
@Binding var status: AppPermissions.MicStatus @Binding var status: AppPermissions.MicStatus
var showsPreface: Bool = false
@State private var isRequesting = false @State private var isRequesting = false
var body: some View { var body: some View {
@@ -272,13 +397,12 @@ private struct MicPermissionPage: View {
icon: "mic.fill", icon: "mic.fill",
title: "onboarding.permission.mic.title", title: "onboarding.permission.mic.title",
detail: "onboarding.permission.mic.body", detail: "onboarding.permission.mic.body",
preface: showsPreface ? "onboarding.permission.preface" : nil,
status: statusLabel, status: statusLabel,
statusColor: statusColor, statusColor: statusColor,
primaryTitle: primaryButtonTitle, primaryTitle: primaryButtonTitle,
primaryDisabled: isRequesting || status == .granted, primaryDisabled: isRequesting || (status == .granted),
onPrimary: { Task { await request() } }, 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 deniedHint: status == .denied ? "onboarding.permission.mic.deniedHint" : nil
) )
.onAppear { status = AppPermissions.micStatus } .onAppear { status = AppPermissions.micStatus }
@@ -304,10 +428,18 @@ private struct MicPermissionPage: View {
} }
private var primaryButtonTitle: LocalizedStringKey { 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 { private func request() async {
if status == .denied {
AppPermissions.openSystemSettings()
return
}
isRequesting = true isRequesting = true
_ = await AppPermissions.requestMicrophone() _ = await AppPermissions.requestMicrophone()
status = AppPermissions.micStatus status = AppPermissions.micStatus
@@ -330,8 +462,6 @@ private struct SpeechPermissionPage: View {
primaryTitle: primaryButtonTitle, primaryTitle: primaryButtonTitle,
primaryDisabled: isRequesting || status == .granted, primaryDisabled: isRequesting || status == .granted,
onPrimary: { Task { await request() } }, onPrimary: { Task { await request() } },
secondaryTitle: speechDenied ? "onboarding.permission.openSettings" : nil,
onSecondary: speechDenied ? { AppPermissions.openSystemSettings() } : nil,
deniedHint: speechDenied ? "onboarding.permission.speech.deniedHint" : nil deniedHint: speechDenied ? "onboarding.permission.speech.deniedHint" : nil
) )
.onAppear { status = AppPermissions.speechStatus } .onAppear { status = AppPermissions.speechStatus }
@@ -364,12 +494,18 @@ private struct SpeechPermissionPage: View {
} }
private var primaryButtonTitle: LocalizedStringKey { private var primaryButtonTitle: LocalizedStringKey {
status == .granted switch status {
? "onboarding.permission.status.granted" case .granted: return "onboarding.permission.status.granted"
: "onboarding.permission.speech.allow" case .denied, .restricted: return "onboarding.permission.openSettings"
case .undetermined: return "onboarding.permission.speech.allow"
}
} }
private func request() async { private func request() async {
if speechDenied {
AppPermissions.openSystemSettings()
return
}
isRequesting = true isRequesting = true
_ = await AppPermissions.requestSpeechRecognition() _ = await AppPermissions.requestSpeechRecognition()
status = AppPermissions.speechStatus status = AppPermissions.speechStatus
@@ -383,6 +519,7 @@ private struct PermissionPageLayout: View {
let icon: String let icon: String
let title: LocalizedStringKey let title: LocalizedStringKey
let detail: LocalizedStringKey let detail: LocalizedStringKey
var preface: LocalizedStringKey? = nil
let status: LocalizedStringKey let status: LocalizedStringKey
let statusColor: Color let statusColor: Color
let primaryTitle: LocalizedStringKey let primaryTitle: LocalizedStringKey
@@ -393,60 +530,62 @@ private struct PermissionPageLayout: View {
var deniedHint: LocalizedStringKey? = nil var deniedHint: LocalizedStringKey? = nil
var body: some View { var body: some View {
VStack(spacing: 0) { ScrollView {
Spacer() VStack(spacing: 0) {
Spacer(minLength: Spacing.lg)
OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40) if let preface {
Text(preface)
VStack(spacing: Spacing.xs) { .font(TypeStyle.caption)
Text(title) .foregroundStyle(palette.textSecondary)
.font(TypeStyle.title2) .multilineTextAlignment(.center)
.foregroundStyle(palette.textPrimary) .padding(.horizontal, Spacing.lg)
Text(detail) .padding(.bottom, Spacing.lg)
.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()
} }
.buttonStyle(.plain)
.disabled(primaryDisabled)
if let secondaryTitle, let onSecondary { OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40)
Button(action: onSecondary) {
Text(secondaryTitle) OnboardingTitleBlock(title: title, subtitle: detail)
.secondaryButton() .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) .buttonStyle(.plain)
} .disabled(primaryDisabled)
}
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xl)
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 @Environment(\.themePalette) private var palette: ThemePalette
var body: some View { var body: some View {
VStack(spacing: 0) { ScrollView {
Spacer() 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) { OnboardingTitleBlock(
Text("onboarding.enable.title") title: "onboarding.enable.title",
.font(TypeStyle.title2) subtitle: "onboarding.enable.fullAccessNote"
.foregroundStyle(palette.textPrimary) )
Text("onboarding.enable.fullAccessNote") .padding(.top, OnboardingLayoutMetrics.heroTextGap)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) VStack(alignment: .leading, spacing: Spacing.lg) {
.multilineTextAlignment(.center) step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: ""))
.padding(.horizontal, Spacing.lg) 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 { private func step(num: Int, text: String) -> some View {
HStack(alignment: .top, spacing: Spacing.sm) { HStack(alignment: .top, spacing: Spacing.sm) {
Text("\(num)") stepLabel(num)
.font(TypeStyle.caption2)
.frame(width: 24, height: 24)
.background(palette.accent, in: Circle())
.foregroundStyle(palette.textOnAccent)
Text(text) Text(text)
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
@@ -513,6 +643,34 @@ private struct EnableKeyboardPage: View {
.fixedSize(horizontal: false, vertical: true) .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 // MARK: - API setup
@@ -524,17 +682,12 @@ private struct APISetupPage: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) { VStack(spacing: Spacing.lg) {
VStack(alignment: .leading, spacing: Spacing.xs) { OnboardingHeroIcon(systemName: "cpu", circleSize: 72, iconSize: 30)
Text("onboarding.api.title") .padding(.top, Spacing.xl)
.font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary) OnboardingTitleBlock(title: "onboarding.api.title")
Text("onboarding.api.subtitle") .padding(.horizontal, Spacing.lg)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xxxl)
EnginePickerSection(config: config) EnginePickerSection(config: config)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
@@ -544,35 +697,6 @@ private struct APISetupPage: View {
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
APISettingsCard(config: config) APISettingsCard(config: config)
.padding(.horizontal, Spacing.lg) .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) .padding(.bottom, Spacing.xxxl)
+21
View File
@@ -276,6 +276,27 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.about.title") sectionHeader("settings.about.title")
VStack(spacing: 0) { 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 { if let url = LegalLinks.privacyPolicyURL {
footerLinkRow(title: "settings.privacy.policy", url: url) footerLinkRow(title: "settings.privacy.policy", url: url)
Divider().background(palette.divider) Divider().background(palette.divider)
+30 -22
View File
@@ -3,32 +3,33 @@
"OSGKeyboard" = "OSGKeyboard"; "OSGKeyboard" = "OSGKeyboard";
/* Onboarding */ /* Onboarding */
"onboarding.progress" = "Step %1$d of %2$d";
"onboarding.welcome.tagline" = "Speak instead of type"; "onboarding.welcome.tagline" = "Speak instead of type";
"onboarding.welcome.subtitle" = "Your open-source voice keyboard"; "onboarding.welcome.subtitle" = "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.welcome.subtitle2" = "We never read or upload anything you type.";
"onboarding.permission.mic.title" = "Microphone access"; "onboarding.permission.preface" = "iOS will ask for two permissions — please allow both.";
"onboarding.permission.mic.body" = "Voice input requires your permission to use the microphone."; "onboarding.permission.mic.title" = "Microphone";
"onboarding.permission.mic.allow" = "Allow microphone"; "onboarding.permission.mic.body" = "Records your voice.";
"onboarding.permission.mic.deniedHint" = "Microphone was denied. Open Settings to enable it, or tap Next to continue."; "onboarding.permission.mic.allow" = "Allow";
"onboarding.permission.speech.title" = "Speech recognition"; "onboarding.permission.mic.deniedHint" = "Open Settings to enable, or tap Next to set up later.";
"onboarding.permission.speech.body" = "On-device speech recognition turns your voice into text."; "onboarding.permission.speech.title" = "Speech Recognition";
"onboarding.permission.speech.allow" = "Allow speech recognition"; "onboarding.permission.speech.body" = "Turns your voice into text.";
"onboarding.permission.speech.deniedHint" = "Speech recognition was denied. Open Settings to enable it, or tap Next to continue."; "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.openSettings" = "Open Settings";
"onboarding.permission.status.undetermined" = "Not requested yet"; "onboarding.permission.status.undetermined" = "Not requested yet";
"onboarding.permission.status.granted" = "Allowed"; "onboarding.permission.status.granted" = "Allowed";
"onboarding.permission.status.denied" = "Denied"; "onboarding.permission.status.denied" = "Denied";
"legal.privacyPolicy" = "Privacy Policy"; "legal.privacyPolicy" = "Privacy Policy";
"onboarding.enable.title" = "Enable OSGKeyboard"; "onboarding.enable.title" = "Add Keyboard";
"onboarding.enable.step1" = "Settings → General → Keyboard → Keyboards"; "onboarding.enable.fullAccessNote" = "Turn on Allow Full Access so the mic can work.";
"onboarding.enable.step2" = "Tap “Add New Keyboard…” and choose OSGKeyboard"; "onboarding.enable.step1" = "Settings → General → Keyboard → Add New Keyboard";
"onboarding.enable.step3" = "Tap OSGKeyboard and enable Allow Full Access"; "onboarding.enable.step2" = "Select OSGKeyboard and enable Allow Full Access";
"onboarding.enable.step4" = "Allow Full Access is required for the microphone and LLM calls."; "onboarding.enable.step3.prefix" = "Hold ";
"onboarding.enable.openSettings" = "Open iOS Settings"; "onboarding.enable.step3.suffix" = "and select OSGKeyboard";
"onboarding.api.title" = "Choose engine"; "onboarding.enable.openSettings" = "Open Settings";
"onboarding.api.subtitle" = "Local needs no API key; Cloud polishes your text via LLM."; "onboarding.api.title" = "Choose Engine";
"onboarding.api.localReady.title" = "No API key needed"; "settings.onboarding.replay" = "Restart permission setup";
"onboarding.api.localReady.body" = "Local recognition is ready to use. Tap Done to start.";
/* Common navigation */ /* Common navigation */
"common.back" = "Back"; "common.back" = "Back";
@@ -42,7 +43,7 @@
"common.space" = "Space"; "common.space" = "Space";
"common.newline" = "Return"; "common.newline" = "Return";
/* Privacy footnote (onboarding welcome page) */ /* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "On-device transcription"; "privacy.audio.title" = "On-device transcription";
"privacy.audio.body" = "Powered by the on-device iOS speech engine."; "privacy.audio.body" = "Powered by the on-device iOS speech engine.";
"privacy.network.title" = "Automatic text polish"; "privacy.network.title" = "Automatic text polish";
@@ -186,7 +187,14 @@
"home.flow.active" = "Voice session active"; "home.flow.active" = "Voice session active";
"home.flow.label" = "Ready"; "home.flow.label" = "Ready";
"home.flow.inactive" = "Voice session inactive"; "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.starting" = "Starting voice session…";
"home.flow.openSettings" = "Open Settings to grant permissions"; "home.flow.openSettings" = "Open Settings to grant permissions";
"home.flow.start" = "Start voice session"; "home.flow.start" = "Start voice session";
+36 -28
View File
@@ -3,32 +3,33 @@
"OSGKeyboard" = "OSGKeyboard"; "OSGKeyboard" = "OSGKeyboard";
/* Onboarding */ /* Onboarding */
"onboarding.progress" = "第 %1$d / %2$d 步";
"onboarding.welcome.tagline" = "能说就不打字"; "onboarding.welcome.tagline" = "能说就不打字";
"onboarding.welcome.subtitle" = "你的开源语音输入法"; "onboarding.welcome.subtitle" = "开源语音输入法";
"onboarding.enable.fullAccessNote" = "完全访问用于麦克风与 API 配置读取。我们不会记录或上传你的击键内容。"; "onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。";
"onboarding.permission.mic.title" = "麦克风权限"; "onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。";
"onboarding.permission.mic.body" = "语音输入需要您授予麦克风权限"; "onboarding.permission.mic.title" = "麦克风";
"onboarding.permission.mic.allow" = "允许麦克风"; "onboarding.permission.mic.body" = "用于录制语音。";
"onboarding.permission.mic.deniedHint" = "麦克风被拒绝。可前往设置开启,或点「下一步」继续。"; "onboarding.permission.mic.allow" = "允许";
"onboarding.permission.speech.title" = "语音识别权限"; "onboarding.permission.mic.deniedHint" = "去设置打开,或先点「下一步」。";
"onboarding.permission.speech.body" = "端侧语音识别将语音转为文字。"; "onboarding.permission.speech.title" = "语音识别";
"onboarding.permission.speech.allow" = "允许语音识别"; "onboarding.permission.speech.body" = "把你说的话转成文字。";
"onboarding.permission.speech.deniedHint" = "语音识别被拒绝。可前往设置开启,或点「下一步」继续。"; "onboarding.permission.speech.allow" = "允许";
"onboarding.permission.speech.deniedHint" = "去设置打开,或先点「下一步」。";
"onboarding.permission.openSettings" = "打开设置"; "onboarding.permission.openSettings" = "打开设置";
"onboarding.permission.status.undetermined" = "尚未请求"; "onboarding.permission.status.undetermined" = "尚未请求";
"onboarding.permission.status.granted" = "已允许"; "onboarding.permission.status.granted" = "已允许";
"onboarding.permission.status.denied" = "已拒绝"; "onboarding.permission.status.denied" = "已拒绝";
"legal.privacyPolicy" = "隐私政策"; "legal.privacyPolicy" = "隐私政策";
"onboarding.enable.title" = "启用 OSGKeyboard"; "onboarding.enable.title" = "添加键盘";
"onboarding.enable.step1" = "设置 → 通用 → 键盘 → 键盘"; "onboarding.enable.fullAccessNote" = "记得打开「允许完全访问」,不然麦克风用不了。";
"onboarding.enable.step2" = "点击「添加新键盘…」并选择 OSGKeyboard"; "onboarding.enable.step1" = "设置 → 通用 → 键盘 → 添加新键盘";
"onboarding.enable.step3" = "点击 OSGKeyboard 并启用「允许完全访问」"; "onboarding.enable.step2" = "选择 OSGKeyboard,打开「允许完全访问」";
"onboarding.enable.step4" = "允许完全访问是麦克风和网络调用的前提。"; "onboarding.enable.step3.prefix" = "长按";
"onboarding.enable.openSettings" = "打开 iOS 设置"; "onboarding.enable.step3.suffix" = ",选中 OSGKeyboard";
"onboarding.api.title" = "选择引擎"; "onboarding.enable.openSettings" = "去设置";
"onboarding.api.subtitle" = "Local 不需要 API Key;Cloud 用 LLM 润色文字。"; "onboarding.api.title" = "选择语音转文字 AI 引擎";
"onboarding.api.localReady.title" = "无需配置 API Key"; "settings.onboarding.replay" = "重新开始权限引导";
"onboarding.api.localReady.body" = "本地识别直接可用,按 Done 即可开始使用。";
/* Common navigation */ /* Common navigation */
"common.back" = "返回"; "common.back" = "返回";
@@ -42,7 +43,7 @@
"common.space" = "空格"; "common.space" = "空格";
"common.newline" = "换行"; "common.newline" = "换行";
/* Privacy footnote (onboarding welcome page) */ /* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "支持本地转写"; "privacy.audio.title" = "支持本地转写";
"privacy.audio.body" = "支持 iOS 本地引擎转录"; "privacy.audio.body" = "支持 iOS 本地引擎转录";
"privacy.network.title" = "文字自动润色"; "privacy.network.title" = "文字自动润色";
@@ -86,12 +87,12 @@
"settings.reset.message" = "API key、model 和 base URL 都会被清空。"; "settings.reset.message" = "API key、model 和 base URL 都会被清空。";
"settings.reset.confirm" = "重置所有设置"; "settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "引擎"; "settings.engine.title" = "引擎";
"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。"; "settings.engine.subtitle" = "本地不用 Key,只转文字;云端会润色,要配 Key。";
"settings.engine.local.title" = "本地识别"; "settings.engine.local.title" = "本地识别";
"settings.engine.local.ios26" = "始终端侧,无需联网"; "settings.engine.local.ios26" = "全程在手机本地,不用联网";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
"settings.engine.cloud.title" = "云端润色"; "settings.engine.cloud.title" = "云端润色";
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key"; "settings.engine.cloud.subtitle" = "先转文字,再用 AI 润色要 API Key";
"settings.provider.title" = "提供商"; "settings.provider.title" = "提供商";
"settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.provider.subtitle" = "选择 LLM 提供商。";
"provider.openai" = "OpenAI"; "provider.openai" = "OpenAI";
@@ -121,7 +122,7 @@
"settings.privacy.title" = "隐私"; "settings.privacy.title" = "隐私";
"settings.privacy.policy" = "隐私政策"; "settings.privacy.policy" = "隐私政策";
"settings.privacy.fullAccess.title" = "关于完全访问"; "settings.privacy.fullAccess.title" = "关于完全访问";
"settings.privacy.fullAccess.body" = "完全访问用于麦克风读取 API Key。OSGKeyboard 不会记录或上传你在键盘上的击键内容。"; "settings.privacy.fullAccess.body" = "用来调用麦克风读取 API Key;不会读取或上传你的输入内容。";
"settings.link.github" = "GitHub"; "settings.link.github" = "GitHub";
/* App group error */ /* App group error */
@@ -185,14 +186,21 @@
"home.flow.active" = "语音会话进行中"; "home.flow.active" = "语音会话进行中";
"home.flow.label" = "就绪"; "home.flow.label" = "就绪";
"home.flow.inactive" = "语音会话未启动"; "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.starting" = "正在启动语音会话…";
"home.flow.openSettings" = "前往设置授予权限"; "home.flow.openSettings" = "去设置打开权限";
"home.flow.start" = "启动语音会话"; "home.flow.start" = "启动语音会话";
"home.flow.end" = "结束语音会话"; "home.flow.end" = "结束语音会话";
"home.flow.endShort" = "结束"; "home.flow.endShort" = "结束";
"home.preview.label" = "输入测试"; "home.preview.label" = "输入测试";
"home.preview.placeholder" = "点击输入,测试键盘效果…"; "home.preview.placeholder" = "点这里试试键盘";
/* Tabs */ /* Tabs */
"tab.keyboard" = "键盘"; "tab.keyboard" = "键盘";
@@ -99,6 +99,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) { public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
consumePendingDictationResultIfNeeded() consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded() refreshDictationProgressStateIfNeeded()
refreshFlowSessionState() refreshFlowSessionState()
+1 -1
View File
@@ -3,7 +3,7 @@
"OSGKeyboard" = "OSGKeyboard"; "OSGKeyboard" = "OSGKeyboard";
/* Onboarding */ /* 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.title" = "Enable OSGKeyboard";
"onboarding.enable.step1" = "Settings → General → Keyboard → Keyboards"; "onboarding.enable.step1" = "Settings → General → Keyboard → Keyboards";
"onboarding.enable.step2" = "Tap “Add New Keyboard…” and choose OSGKeyboard"; "onboarding.enable.step2" = "Tap “Add New Keyboard…” and choose OSGKeyboard";
@@ -3,7 +3,7 @@
"OSGKeyboard" = "OSGKeyboard"; "OSGKeyboard" = "OSGKeyboard";
/* Onboarding */ /* Onboarding */
"onboarding.welcome.subtitle" = "按住说话,松开即得润色文字。"; "onboarding.welcome.subtitle" = "点按麦克风说话,再点一次结束,文字即出现。";
"onboarding.enable.title" = "启用 OSGKeyboard"; "onboarding.enable.title" = "启用 OSGKeyboard";
"onboarding.enable.step1" = "设置 → 通用 → 键盘 → 键盘"; "onboarding.enable.step1" = "设置 → 通用 → 键盘 → 键盘";
"onboarding.enable.step2" = "点击「添加新键盘…」并选择 OSGKeyboard"; "onboarding.enable.step2" = "点击「添加新键盘…」并选择 OSGKeyboard";
@@ -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)
}
}