feat: initial release v0.1.0
- Custom Keyboard Extension with push-to-talk UI - iOS 26 SpeechAnalyzer + DictationTranscriber (iOS 18 SF fallback) - OpenAI-compatible LLM client (4 built-in providers + custom) - 3-page onboarding flow + provider config UI - App Group shared storage for cross-process config - 8s LLM timeout with raw-transcript fallback - App Store privacy manifests for both targets - SwiftLint + XcodeGen + GitHub Actions CI - Unit tests (4/4 passing)
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
// OnboardingView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Three-page horizontal onboarding:
|
||||
// 1) Welcome
|
||||
// 2) Enable keyboard + Allow Full Access
|
||||
// 3) Pick provider + enter API key
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct OnboardingView: View {
|
||||
@ObservedObject var config = ProviderConfig.shared
|
||||
@State private var page: Int = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
TabView(selection: $page) {
|
||||
WelcomePage().tag(0)
|
||||
EnableKeyboardPage().tag(1)
|
||||
APISetupPage(config: config) {
|
||||
// completion — root switches to HomeView
|
||||
}
|
||||
.tag(2)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
pageDots
|
||||
.padding(.bottom, 12)
|
||||
|
||||
bottomBar
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var pageDots: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i == page ? Theme.accent : Color.white.opacity(0.2))
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack {
|
||||
if page > 0 {
|
||||
Button("Back") { withAnimation { page -= 1 } }
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
if page < 2 {
|
||||
Button {
|
||||
withAnimation { page += 1 }
|
||||
} label: {
|
||||
Text("Next")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 20).padding(.vertical, 10)
|
||||
.background(Theme.accent, in: Capsule())
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
// finalise — ProviderConfig is already bound to App Group
|
||||
} label: {
|
||||
Text("Done")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 24).padding(.vertical, 10)
|
||||
.background(config.isConfigured ? Theme.accent : Color.gray.opacity(0.4),
|
||||
in: Capsule())
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.disabled(!config.isConfigured)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WelcomePage: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("OSGKeyboard")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Text("Hold the mic key, speak, and let AI polish your words into clean text — in every app.")
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(.horizontal, 28)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EnableKeyboardPage: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
Image(systemName: "keyboard.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("Enable OSGKeyboard")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
step(num: 1, text: "Open Settings → General → Keyboard → Keyboards")
|
||||
step(num: 2, text: "Tap “Add New Keyboard…” and choose OSGKeyboard")
|
||||
step(num: 3, text: "Tap OSGKeyboard and enable “Allow Full Access” (needed for mic + LLM)")
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Open iOS Settings", systemImage: "arrow.up.right.square")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||
.background(Theme.accent, in: Capsule())
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func step(num: Int, text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Text("\(num)")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.frame(width: 20, height: 20)
|
||||
.background(Theme.accent, in: Circle())
|
||||
.foregroundStyle(.black)
|
||||
Text(text)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct APISetupPage: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
let onDone: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
Text("Configure your AI provider")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(.top, 18)
|
||||
Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ProviderPickerSection(config: config)
|
||||
APISettingsCard(config: config)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user