07067ca6c5
- 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)
122 lines
3.6 KiB
Swift
122 lines
3.6 KiB
Swift
// KeyboardRootView.swift
|
|
// OSGKeyboard · Keyboard Extension
|
|
//
|
|
// The single SwiftUI view that backs the keyboard. Shows status text,
|
|
// the record button, waveform (when active), and a settings shortcut.
|
|
|
|
import SwiftUI
|
|
|
|
public struct KeyboardRootView: View {
|
|
public enum Phase: Equatable {
|
|
case idle
|
|
case recording
|
|
case processing
|
|
case error(String)
|
|
}
|
|
|
|
public let phase: Phase
|
|
public let level: Double // 0...1, used while recording
|
|
public let onPressBegan: () -> Void
|
|
public let onPressEnded: () -> Void
|
|
public let onTap: () -> Void
|
|
public let onOpenSettings: () -> Void
|
|
|
|
public init(
|
|
phase: Phase,
|
|
level: Double,
|
|
onPressBegan: @escaping () -> Void,
|
|
onPressEnded: @escaping () -> Void,
|
|
onTap: @escaping () -> Void,
|
|
onOpenSettings: @escaping () -> Void
|
|
) {
|
|
self.phase = phase
|
|
self.level = level
|
|
self.onPressBegan = onPressBegan
|
|
self.onPressEnded = onPressEnded
|
|
self.onTap = onTap
|
|
self.onOpenSettings = onOpenSettings
|
|
}
|
|
|
|
public var body: some View {
|
|
ZStack {
|
|
// frosted glass background
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.ignoresSafeArea()
|
|
|
|
HStack {
|
|
Spacer()
|
|
statusLine
|
|
Spacer()
|
|
recordButton
|
|
Spacer()
|
|
settingsButton
|
|
}
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.frame(height: 56)
|
|
}
|
|
|
|
private var statusLine: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
switch phase {
|
|
case .idle:
|
|
Text("Hold to talk")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
case .recording:
|
|
HStack(spacing: 6) {
|
|
WaveformView(level: level, barCount: 7, color: .red)
|
|
Text("Recording…")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(.red)
|
|
}
|
|
case .processing:
|
|
HStack(spacing: 6) {
|
|
ProgressView().controlSize(.small)
|
|
Text("Polishing…")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
case .error(let msg):
|
|
Text(msg)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.orange)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var recordButton: some View {
|
|
RecordButton(
|
|
phase: recordState,
|
|
onPressBegan: onPressBegan,
|
|
onPressEnded: onPressEnded,
|
|
onTap: onTap
|
|
)
|
|
}
|
|
|
|
private var settingsButton: some View {
|
|
Button(action: onOpenSettings) {
|
|
Image(systemName: "gearshape.fill")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.padding(8)
|
|
.background(Color.white.opacity(0.06), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("Open OSGKeyboard settings"))
|
|
}
|
|
|
|
private var recordState: RecordButton.Phase {
|
|
switch phase {
|
|
case .idle: return .idle
|
|
case .recording: return .recording
|
|
case .processing: return .processing
|
|
case .error(let s): return .error(s)
|
|
}
|
|
}
|
|
}
|