feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site
Migrate keyboard dictation to continuous Flow sessions with auto-start, tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
This commit is contained in:
@@ -22,7 +22,7 @@ import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
public struct KeyboardRootView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject var state: State
|
||||
|
||||
@@ -35,6 +35,10 @@ public struct KeyboardRootView: View {
|
||||
/// it up.
|
||||
static let totalHeight: CGFloat = 280
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
@@ -48,26 +52,12 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
// iOS keyboard extensions always render dark (Apple's default
|
||||
// for custom keyboards), and we let the system UI chrome show
|
||||
// through by drawing no background of our own.
|
||||
// Let the system UI chrome show through by drawing no background
|
||||
// of our own.
|
||||
.background(Color.clear)
|
||||
.frame(height: Self.totalHeight)
|
||||
// Top edge: subtle highlight gradient + 0.5pt divider line.
|
||||
// These give the keyboard a "physical surface" feel and visually
|
||||
// separate it from the host text field above. We overlay (not
|
||||
// background) so the underlying color stays clear.
|
||||
.overlay(alignment: .top) {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(
|
||||
colors: [Color.white.opacity(0.05), .clear],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
))
|
||||
.frame(height: 1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
// Feed the resolved palette to all nested chips/buttons.
|
||||
.environment(\.themePalette, palette)
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
@@ -96,7 +86,7 @@ public struct KeyboardRootView: View {
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("home.action.openSettingsA11y"))
|
||||
.accessibilityLabel(Text(KeyboardL10n.openSettingsA11y))
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
@@ -115,9 +105,8 @@ public struct KeyboardRootView: View {
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
onPressBegan: state.beginRecording,
|
||||
onPressEnded: state.endRecording,
|
||||
onTap: state.tapMic
|
||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||
onToggle: state.tapMic
|
||||
)
|
||||
.frame(width: 140, height: 140)
|
||||
}
|
||||
@@ -139,7 +128,7 @@ public struct KeyboardRootView: View {
|
||||
state.deleteBackward()
|
||||
}
|
||||
Button(action: state.insertSpace) {
|
||||
Text("common.space")
|
||||
Text(KeyboardL10n.space)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 42)
|
||||
@@ -150,7 +139,7 @@ public struct KeyboardRootView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("common.space"))
|
||||
.accessibilityLabel(Text(KeyboardL10n.space))
|
||||
ToolbarIconButton(systemName: "return", label: "newline") {
|
||||
state.insertNewline()
|
||||
}
|
||||
@@ -211,13 +200,13 @@ private struct TranscriptLine: View {
|
||||
ZStack {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Text("keyboard.placeholder.idle")
|
||||
Text(KeyboardL10n.placeholderIdle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
case .requestingPermissions:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
||||
Text("keyboard.placeholder.preparing")
|
||||
Text(KeyboardL10n.placeholderPreparing)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -231,9 +220,11 @@ private struct TranscriptLine: View {
|
||||
case .processing:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.accent)
|
||||
Text("keyboard.placeholder.processing")
|
||||
Text(transcript.isEmpty ? KeyboardL10n.placeholderProcessing : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
case .error(_, let msg):
|
||||
Text(msg ?? "")
|
||||
@@ -256,7 +247,7 @@ private struct TranscriptLine: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(Text("keyboard.deniedHint"))
|
||||
.accessibilityHint(Text(KeyboardL10n.deniedHint))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -265,8 +256,8 @@ private struct TranscriptLine: View {
|
||||
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||
switch reason {
|
||||
case .mic: return "麦克风被拒绝 · Mic denied"
|
||||
case .speech: return "语音识别被拒绝 · Speech denied"
|
||||
case .mic: return KeyboardL10n.micDenied
|
||||
case .speech: return KeyboardL10n.speechDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,7 +352,7 @@ private struct LocalEngineChip: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "iphone.badge.checkmark")
|
||||
Text("keyboard.placeholder.localBadge")
|
||||
Text(KeyboardL10n.localBadge)
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
@@ -372,6 +363,34 @@ private struct LocalEngineChip: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extension text fallback
|
||||
//
|
||||
// Custom keyboard extensions can end up without the expected localized
|
||||
// resource table when signing/project generation drifts. Keep a tiny
|
||||
// in-code fallback map so UI never shows raw key names like
|
||||
// "common.space" in production.
|
||||
private enum KeyboardL10n {
|
||||
private static var isChinese: Bool {
|
||||
Locale.preferredLanguages.first?.hasPrefix("zh") == true
|
||||
}
|
||||
|
||||
static var space: String { isChinese ? "空格" : "Space" }
|
||||
static var placeholderIdle: String { isChinese ? "点按说话" : "Tap to talk" }
|
||||
static var placeholderPreparing: String { isChinese ? "准备中…" : "Preparing" }
|
||||
static var placeholderProcessing: String { isChinese ? "处理中…" : "Processing" }
|
||||
static var localBadge: String { isChinese ? "本地" : "On-device" }
|
||||
static var micDenied: String { isChinese ? "麦克风被拒绝" : "Mic denied" }
|
||||
static var speechDenied: String { isChinese ? "语音识别被拒绝" : "Speech denied" }
|
||||
static var deniedHint: String {
|
||||
isChinese
|
||||
? "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。"
|
||||
: "Open OSGKeyboard settings to grant microphone / speech access."
|
||||
}
|
||||
static var openSettingsA11y: String {
|
||||
isChinese ? "打开 OSGKeyboard 设置" : "Open OSGKeyboard settings"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mode chip
|
||||
|
||||
private struct ModeChip: View {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// RecordButton.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// The hero control. 120 pt primary disc with a soft inner gradient, a
|
||||
// breathing outer ring while recording, and a centred waveform that maps
|
||||
// directly to the real audio RMS. Idle / recording / processing are three
|
||||
// distinct visual states — no flicker, no surprise transitions.
|
||||
// Tap-to-toggle mic: tap once to start, tap again to stop. Shows a
|
||||
// remaining-time countdown while recording; last 10 seconds turn red.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -21,38 +19,38 @@ struct RecordButton: View {
|
||||
|
||||
let phase: Phase
|
||||
let level: Double // 0...1
|
||||
let onPressBegan: () -> Void
|
||||
let onPressEnded: () -> Void
|
||||
let onTap: () -> Void
|
||||
/// Seconds left in the current utterance; shown only while recording.
|
||||
let remainingSeconds: Int?
|
||||
let onToggle: () -> Void
|
||||
|
||||
@GestureState private var isPressed: Bool = false
|
||||
@State private var breath: Bool = false
|
||||
|
||||
init(
|
||||
phase: Phase,
|
||||
level: Double,
|
||||
onPressBegan: @escaping () -> Void,
|
||||
onPressEnded: @escaping () -> Void,
|
||||
onTap: @escaping () -> Void
|
||||
remainingSeconds: Int? = nil,
|
||||
onToggle: @escaping () -> Void
|
||||
) {
|
||||
self.phase = phase
|
||||
self.level = level
|
||||
self.onPressBegan = onPressBegan
|
||||
self.onPressEnded = onPressEnded
|
||||
self.onTap = onTap
|
||||
self.remainingSeconds = remainingSeconds
|
||||
self.onToggle = onToggle
|
||||
}
|
||||
|
||||
private var isUrgent: Bool {
|
||||
guard phase == .recording, let remainingSeconds else { return false }
|
||||
return remainingSeconds <= 10
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Outer breathing ring (recording only)
|
||||
Circle()
|
||||
.stroke(palette.recordRed.opacity(0.35), lineWidth: 2)
|
||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||
.frame(width: 150, height: 150)
|
||||
.scaleEffect(breath ? 1.18 : 0.95)
|
||||
.opacity(phase == .recording ? 1 : 0)
|
||||
.animation(Motion.breath, value: breath)
|
||||
|
||||
// Halo: soft red glow that intensifies with input level
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
@@ -68,7 +66,6 @@ struct RecordButton: View {
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: level)
|
||||
|
||||
// Secondary outer ring (always present, dimmer when idle)
|
||||
Circle()
|
||||
.stroke(
|
||||
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
|
||||
@@ -76,7 +73,6 @@ struct RecordButton: View {
|
||||
)
|
||||
.frame(width: 140, height: 140)
|
||||
|
||||
// Main disc with gradient + soft inner highlight
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(discGradient)
|
||||
@@ -84,7 +80,6 @@ struct RecordButton: View {
|
||||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
.blendMode(.overlay)
|
||||
|
||||
// Centre content — switches by phase
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
@@ -92,17 +87,19 @@ struct RecordButton: View {
|
||||
.font(.system(size: 38, weight: .medium))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
case .recording:
|
||||
WaveformView(level: level, active: true)
|
||||
.frame(width: 80, height: 44)
|
||||
.transition(.opacity)
|
||||
VStack(spacing: 4) {
|
||||
if let remainingSeconds {
|
||||
Text(formatRemaining(remainingSeconds))
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(isUrgent ? .white : palette.textPrimary)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
WaveformView(level: level, active: true)
|
||||
.frame(width: 72, height: 32)
|
||||
}
|
||||
.transition(.opacity)
|
||||
case .processing:
|
||||
// Scaled to ~50pt inside a 120pt disc (~42%) —
|
||||
// same absolute size as the preview stub's
|
||||
// spinner (2.5x of the default ProgressView),
|
||||
// just a smaller fraction because the real
|
||||
// disc is bigger. The user gets the same
|
||||
// visual weight whether they're looking at
|
||||
// the in-app preview or the live keyboard.
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(palette.textPrimary)
|
||||
@@ -115,63 +112,34 @@ struct RecordButton: View {
|
||||
}
|
||||
}
|
||||
.frame(width: 120, height: 120)
|
||||
.scaleEffect(isPressed ? 0.94 : 1.0)
|
||||
.animation(Motion.quick, value: isPressed)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
// Press-to-talk: act on the FIRST touch-down, not after a 150 ms
|
||||
// minimum duration. That's what Typeless feels like, and it's what
|
||||
// makes the keyboard feel responsive. A tap (very short press) is
|
||||
// interpreted as "toggle" for the secondary action (onTap), not
|
||||
// "record" — the recording only fires if the press lasts long
|
||||
// enough to read as intentional. This avoids the previous bug
|
||||
// where every single tap fired both onPressBegan AND onTap.
|
||||
.gesture(
|
||||
LongPressGesture(minimumDuration: 0.18)
|
||||
.sequenced(before: DragGesture(minimumDistance: 0))
|
||||
.updating($isPressed) { value, state, _ in
|
||||
switch value {
|
||||
case .second(true, _): state = true
|
||||
default: state = false
|
||||
}
|
||||
}
|
||||
.onChanged { value in
|
||||
if case .second(true, _) = value, !pressArmed {
|
||||
pressArmed = true
|
||||
onPressBegan()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if pressArmed { pressArmed = false; onPressEnded() }
|
||||
}
|
||||
)
|
||||
.simultaneousGesture(
|
||||
// Pure tap: only fires when the user lifts before the long-press
|
||||
// threshold. This becomes the "secondary action" (e.g. cycle
|
||||
// mode). It is paired with, not conflicting with, the long-press.
|
||||
TapGesture(count: 1)
|
||||
.onEnded {
|
||||
if !pressArmed { onTap() }
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
guard phase != .processing else { return }
|
||||
onToggle()
|
||||
}
|
||||
.onAppear { breath = (phase == .recording) }
|
||||
.onChange(of: phase) { _, new in
|
||||
breath = (new == .recording)
|
||||
}
|
||||
.accessibilityLabel(Text("keyboard.pressToTalkA11y"))
|
||||
.accessibilityLabel(Text("keyboard.tapToTalkA11y"))
|
||||
}
|
||||
|
||||
@State private var pressArmed: Bool = false
|
||||
private func formatRemaining(_ seconds: Int) -> String {
|
||||
let m = seconds / 60
|
||||
let s = seconds % 60
|
||||
return String(format: "%d:%02d", m, s)
|
||||
}
|
||||
|
||||
private var discGradient: LinearGradient {
|
||||
switch phase {
|
||||
case .recording:
|
||||
return LinearGradient(
|
||||
colors: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
let colors: [Color] = isUrgent
|
||||
? [palette.recordRed, palette.recordRed.opacity(0.85)]
|
||||
: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)]
|
||||
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
|
||||
case .processing:
|
||||
return LinearGradient(
|
||||
colors: [palette.surfaceElevated, palette.surface],
|
||||
@@ -185,10 +153,6 @@ struct RecordButton: View {
|
||||
endPoint: .bottom
|
||||
)
|
||||
case .idle:
|
||||
// Brand green — same hue as `Palette.{dark,light}.accent`
|
||||
// and the AccentColor asset. The disc is the keyboard's
|
||||
// primary CTA, and a dark-gray disc looked like an inert
|
||||
// surface, not an actionable button.
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
palette.accent.opacity(0.95),
|
||||
|
||||
Reference in New Issue
Block a user