12608fe618
Two design cleanups, both for visual polish on the record disc
(the hero control of the keyboard preview AND the live keyboard
extension). The shadows were a leftover from the iOS 18 mockup
that visually fought the brand-green disc; the processing-state
spinner was the system default at 1.1x, which on a 96-120pt disc
reads as "a dot in the middle" rather than "an active process".
Changes:
1) Remove the disc body's drop shadow on BOTH surfaces.
- `KeyboardPreviewStub.swift:244` — `.shadow(color: .black.opacity(0.4),
radius: 10, y: 6)` deleted. Hardcoded dark shadow on a
light surface, completely theme-blind (looked like a 2009
skeuomorphic button).
- `RecordButton.swift:112` — `.shadow(color: .black.opacity(0.45),
radius: 14, y: 8)` deleted. Same problem on the real
keyboard's disc, plus the shadow competed with the existing
"halo" radial gradient and the outer breathing ring.
The internal "ambient glow" (radial green gradient inside the
disc on idle) is NOT touched — that's brand colour, not a
drop shadow, and matches the live keyboard's idle accent.
2) Scale up the processing-state spinner on BOTH surfaces.
- Preview: `ProgressView().tint(.white).scaleEffect(1.1)` →
`ProgressView().progressViewStyle(.circular).tint(palette.textPrimary)
.scaleEffect(2.5)`.
- 1.1x (~22pt) → 2.5x (~50pt) = 52% of the 96pt disc.
- Tint changed from hardcoded `.white` to `palette.textPrimary`
because the processing-state disc gradient is
`surfaceElevated → surface` (light grey in light mode);
a white spinner on a light grey disc is invisible regardless
of scale.
- Real keyboard: `ProgressView().scaleEffect(1.2)` →
`ProgressView().scaleEffect(2.5)`. Same absolute spinner
size (~50pt) — 42% of the 120pt disc — so the user gets the
same visual weight whether they're looking at the in-app
preview or the live keyboard. Tint was already
`palette.textPrimary` so no change there.
Proportion rationale: ~50% of the disc diameter is the sweet
spot. 100% would crowd the disc's edge, <30% reads as
"decoration" rather than "process". 50pt absolute size matches
the same spinner across both surfaces — same weight, different
disc sizes — so the user doesn't have to re-learn the visual
language when they switch from preview to real keyboard.
Tests: 27/27 pass (no behavioural change, just visual).
Build: BUILD SUCCEEDED.
🤖 Generated with Claude Code
203 lines
7.7 KiB
Swift
203 lines
7.7 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:
|
|
// 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)
|
|
.scaleEffect(2.5)
|
|
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)
|
|
.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("keyboard.pressToTalkA11y"))
|
|
}
|
|
|
|
@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
|
|
)
|
|
}
|
|
}
|
|
}
|