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:
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user