Files
OSGKeyboard/OSGKeyboardExt/Views/RecordButton.swift
T
Rocky 1bdb8824ac feat: polish scenarios and stabilize keyboard layout height
Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip)
with ScenarioPrompt and style directives; drive keyboard height from
content (240pt) and use viewIsAppearing encapsulated-height offset for
smoother keyboard switches; remove redundant StatusBadge and hide system
dictation via hasDictationKey.
2026-06-29 00:08:00 +08:00

180 lines
6.3 KiB
Swift

// RecordButton.swift
// OSGKeyboard · Keyboard Extension
//
// 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
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
/// Seconds left in the current utterance; shown only while recording.
let remainingSeconds: Int?
let onToggle: () -> Void
@State private var breath: Bool = false
init(
phase: Phase,
level: Double,
remainingSeconds: Int? = nil,
onToggle: @escaping () -> Void
) {
self.phase = phase
self.level = level
self.remainingSeconds = remainingSeconds
self.onToggle = onToggle
}
private var isUrgent: Bool {
guard phase == .recording, let remainingSeconds else { return false }
return remainingSeconds <= 10
}
/// Decorative rings are sized to stay inside the 132 pt frame applied
/// by `KeyboardRootView` so glow / breath animations are not clipped.
private enum Layout {
static let disc: CGFloat = 104
static let outerRing: CGFloat = 112
static let breathRing: CGFloat = 108
static let glow: CGFloat = 128
}
var body: some View {
ZStack {
Circle()
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
.frame(width: Layout.breathRing, height: Layout.breathRing)
.scaleEffect(breath ? 1.18 : 0.95)
.opacity(phase == .recording ? 1 : 0)
.animation(Motion.breath, value: breath)
Circle()
.fill(
RadialGradient(
colors: [palette.recordRed.opacity(0.55), .clear],
center: .center,
startRadius: 50,
endRadius: 100
)
)
.frame(width: Layout.glow, height: Layout.glow)
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
.blur(radius: 18)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: level)
Circle()
.stroke(
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
lineWidth: 0.5
)
.frame(width: Layout.outerRing, height: Layout.outerRing)
ZStack {
Circle()
.fill(discGradient)
Circle()
.stroke(Color.white.opacity(0.16), lineWidth: 1)
.blendMode(.overlay)
Group {
switch phase {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 38, weight: .medium))
.foregroundStyle(.white)
case .recording:
VStack(spacing: 4) {
if let remainingSeconds {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
}
WaveformView(
level: level,
color: Color(red: 1.0, green: 0.78, blue: 0.78),
active: true
)
.frame(width: 72, height: 32)
}
.transition(.opacity)
case .processing:
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: Layout.disc, height: Layout.disc)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: remainingSeconds)
}
.contentShape(Circle())
.onTapGesture {
guard phase != .processing else { return }
onToggle()
}
.onAppear { breath = (phase == .recording) }
.onChange(of: phase) { _, new in
breath = (new == .recording)
}
.accessibilityLabel(ExtL10n.text("keyboard.tapToTalkA11y"))
}
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:
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],
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:
return LinearGradient(
colors: [
palette.accent.opacity(0.95),
palette.accent.opacity(0.75)
],
startPoint: .top,
endPoint: .bottom
)
}
}
}