aeb28f4b36
Three user-flagged fixes, scoped tightly to the files each affects.
The uncommitted Engine-mode wiring / locale picker / etc. from a
prior agent pass is intentionally not included in this commit.
1. Revert accent to brand green (#3AA05A).
Last commit flipped `AccentColor` + `Palette.light.accent` to
Apple system blue (#007AFF) on the assumption that "green CTA
on a near-white surface looks wrong." Review pushed back:
the brand *is* the green, and the system tint should match it
so that NavStack Done buttons, Toggles, and our custom
`primaryButton()` modifier all read as the same colour. Restored
`#3AA05A` in both `AccentColor.colorset/Contents.json` and
`Palette.light.accent` (plus the muted / glow variants).
2. Real iOS ASR in `KeyboardPreviewSheet`.
The previous fix only swapped the static placeholder for a
`TextField` and routed a hardcoded stub through it — review
asked, fairly, "are you actually calling `SFSpeechRecognizer`?"
Answer: no. This change makes the preview *run real ASR*:
- `ASRService` (+ the iOS 26 `SpeechAnalyzer` path) moves from
`OSGKeyboardExt/Services/` to `OSGKeyboardShared/Services/`,
so the host app can import the same `ASRServiceFactory.make()`
the keyboard extension uses.
- `OSGKeyboardShared` gains `Speech.framework` and
`AVFoundation.framework` as SDK dependencies in `project.yml`.
- New `OSGKeyboard/Views/PreviewASRController.swift` (the
extension's `AudioCaptureService` is app-extension-only, so
the preview owns its own `AVAudioEngine` + `AVAudioSession`
and downsamples to 16 kHz mono Float32 via `AVAudioConverter`).
- `KeyboardPreviewSheet.cyclePhase()` now calls
`asr.start(locale:)` / `asr.stop()` instead of toggling a
`StubPhase`. The disc's level meter is driven by RMS from the
actual audio tap; the transcript line under the chips shows
the live `SFSpeechRecognizer` partial; the top textbox
receives the `.final` transcript via `onChange(of: lastFinal)`.
3. Record disc in BOTH stubs now uses the green accent for idle.
Was `Color(white: 0.22)` dark-gray, which read as "inert
surface" rather than "tap me". The keyboard extension and the
in-app preview now share the same brand-green disc gradient so
the keyboard's primary CTA is the same colour in both modes.
4. Bilingual audit across every user-facing string.
Every Text() in the main-app and extension views is now
`中文 · English` or carries an English secondary line. Covered:
- OnboardingView (Back, Next, Done, Continue, step instructions,
PrivacyFootnote rows)
- HomeView (status header, hero label, accessibility)
- APISettingsCard (Base URL, API Key, Model, "Get an API key",
"Connection", test-connection states + error messages)
- KeyboardPreviewSheet (title, subtitle, TextField placeholder,
clear button accessibility)
- KeyboardPreviewStub (mode/locale chips, REC badge, space bar)
- KeyboardRootView (gear accessibility, space bar, requesting
state, denied messages, local-engine chip)
- RecordButton (accessibility label)
- SettingsView (Done, Reset dialog, language section subtitle,
engine section, local-engine subtitle, on-device label)
- AppGroupErrorView (title, body, three remediation steps)
- LLMProvider preset names and blurbs
Where the prior pattern was "Chinese headline + English footnote"
(e.g. OnboardingView's 启用 OSGKeyboard / Enable OSGKeyboard),
that pattern was preserved — bilingual coverage means every
screen reads as both, not that every line is rigidly `中 · EN`.
Build: BUILD SUCCEEDED on iPhone 17 Pro / iOS 26 simulator.
Tests: 21/21 pass (no test changes).
Visual: light-mode home shot at /tmp/osgk_light_green.png shows
the brand-green CTA restored across Next button, mic icon, and
page dot.
🤖 Generated with Claude Code
197 lines
7.3 KiB
Swift
197 lines
7.3 KiB
Swift
// 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.
|
|
|
|
import SwiftUI
|
|
import OSGKeyboardShared
|
|
|
|
struct RecordButton: View {
|
|
@Environment(\.themePalette) private var palette: ThemePalette
|
|
|
|
enum Phase: Equatable {
|
|
case idle
|
|
case recording
|
|
case processing
|
|
case error
|
|
}
|
|
|
|
let phase: Phase
|
|
let level: Double // 0...1
|
|
let onPressBegan: () -> Void
|
|
let onPressEnded: () -> Void
|
|
let onTap: () -> 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
|
|
) {
|
|
self.phase = phase
|
|
self.level = level
|
|
self.onPressBegan = onPressBegan
|
|
self.onPressEnded = onPressEnded
|
|
self.onTap = onTap
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Outer breathing ring (recording only)
|
|
Circle()
|
|
.stroke(palette.recordRed.opacity(0.35), lineWidth: 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(
|
|
colors: [palette.recordRed.opacity(0.55), .clear],
|
|
center: .center,
|
|
startRadius: 50,
|
|
endRadius: 100
|
|
)
|
|
)
|
|
.frame(width: 200, height: 200)
|
|
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
|
|
.blur(radius: 18)
|
|
.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),
|
|
lineWidth: 0.5
|
|
)
|
|
.frame(width: 140, height: 140)
|
|
|
|
// Main disc with gradient + soft inner highlight
|
|
ZStack {
|
|
Circle()
|
|
.fill(discGradient)
|
|
Circle()
|
|
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
|
.blendMode(.overlay)
|
|
|
|
// Centre content — switches by phase
|
|
Group {
|
|
switch phase {
|
|
case .idle:
|
|
Image(systemName: "mic.fill")
|
|
.font(.system(size: 38, weight: .medium))
|
|
.foregroundStyle(palette.textPrimary)
|
|
case .recording:
|
|
WaveformView(level: level, active: true)
|
|
.frame(width: 80, height: 44)
|
|
.transition(.opacity)
|
|
case .processing:
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
.tint(palette.textPrimary)
|
|
.scaleEffect(1.2)
|
|
case .error:
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 30, weight: .medium))
|
|
.foregroundStyle(palette.warning)
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 120, height: 120)
|
|
.scaleEffect(isPressed ? 0.94 : 1.0)
|
|
.shadow(color: .black.opacity(0.45), radius: 14, y: 8)
|
|
.animation(Motion.quick, value: isPressed)
|
|
.animation(Motion.soft, value: phase)
|
|
}
|
|
.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() }
|
|
}
|
|
)
|
|
.onAppear { breath = (phase == .recording) }
|
|
.onChange(of: phase) { _, new in
|
|
breath = (new == .recording)
|
|
}
|
|
.accessibilityLabel(Text("按住说话 · Push to talk"))
|
|
}
|
|
|
|
@State private var pressArmed: Bool = false
|
|
|
|
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
|
|
)
|
|
case .processing:
|
|
return LinearGradient(
|
|
colors: [palette.surfaceElevated, palette.surface],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
case .error:
|
|
return LinearGradient(
|
|
colors: [palette.warning.opacity(0.85), palette.warning.opacity(0.55)],
|
|
startPoint: .top,
|
|
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),
|
|
palette.accent.opacity(0.75)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
}
|
|
}
|
|
}
|