feat(keyboard): harden clipboard command and add What's New sheet
Stabilize clipboard long-press prepare/resume across paste alerts and cold start, add an in-app release-notes sheet with remote bilingual HTML, localize typing input settings, and bump build to 55.
This commit is contained in:
@@ -23,10 +23,11 @@ public struct RecordButton: View {
|
||||
public let level: Double
|
||||
public let remainingSeconds: Int?
|
||||
public let isEnabled: Bool
|
||||
/// When true (and `phase == .recording`), use blue clipboard-command chrome.
|
||||
public let isClipboardCommandRecording: Bool
|
||||
public let onToggle: () -> Void
|
||||
/// When non-nil, a 0.45s hold starts clipboard-command recording instead of toggle.
|
||||
/// When non-nil, a 0.45s hold starts clipboard-command recording (tap again to stop).
|
||||
public let onClipboardLongPressBegan: (() -> Void)?
|
||||
public let onClipboardLongPressEnded: (() -> Void)?
|
||||
|
||||
@State private var breath = false
|
||||
@State private var longPressArmed = false
|
||||
@@ -36,17 +37,17 @@ public struct RecordButton: View {
|
||||
level: Double,
|
||||
remainingSeconds: Int? = nil,
|
||||
isEnabled: Bool = true,
|
||||
isClipboardCommandRecording: Bool = false,
|
||||
onToggle: @escaping () -> Void,
|
||||
onClipboardLongPressBegan: (() -> Void)? = nil,
|
||||
onClipboardLongPressEnded: (() -> Void)? = nil
|
||||
onClipboardLongPressBegan: (() -> Void)? = nil
|
||||
) {
|
||||
self.phase = phase
|
||||
self.level = level
|
||||
self.remainingSeconds = remainingSeconds
|
||||
self.isEnabled = isEnabled
|
||||
self.isClipboardCommandRecording = isClipboardCommandRecording
|
||||
self.onToggle = onToggle
|
||||
self.onClipboardLongPressBegan = onClipboardLongPressBegan
|
||||
self.onClipboardLongPressEnded = onClipboardLongPressEnded
|
||||
}
|
||||
|
||||
private var isUrgent: Bool {
|
||||
@@ -54,6 +55,17 @@ public struct RecordButton: View {
|
||||
return remainingSeconds <= 10
|
||||
}
|
||||
|
||||
/// Active recording tint: blue for clipboard-command, red for dictation.
|
||||
private var recordingTint: Color {
|
||||
isClipboardCommandRecording ? palette.recordBlue : palette.recordRed
|
||||
}
|
||||
|
||||
private var waveformColor: Color {
|
||||
isClipboardCommandRecording
|
||||
? Color(red: 0.78, green: 0.88, blue: 1.0)
|
||||
: Color(red: 1.0, green: 0.78, blue: 0.78)
|
||||
}
|
||||
|
||||
private enum Layout {
|
||||
static let disc: CGFloat = 95
|
||||
static let outerRing: CGFloat = 106
|
||||
@@ -64,16 +76,17 @@ public struct RecordButton: View {
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||
.stroke(recordingTint.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)
|
||||
.animation(colorTransition, value: isClipboardCommandRecording)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [palette.recordRed.opacity(0.55), .clear],
|
||||
colors: [recordingTint.opacity(0.55), .clear],
|
||||
center: .center,
|
||||
startRadius: 46,
|
||||
endRadius: 92
|
||||
@@ -84,6 +97,7 @@ public struct RecordButton: View {
|
||||
.blur(radius: 18)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: level)
|
||||
.animation(colorTransition, value: isClipboardCommandRecording)
|
||||
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(isIdle ? 0.08 : 0.12), lineWidth: 0.5)
|
||||
@@ -114,7 +128,7 @@ public struct RecordButton: View {
|
||||
}
|
||||
WaveformView(
|
||||
level: level,
|
||||
color: Color(red: 1.0, green: 0.78, blue: 0.78),
|
||||
color: waveformColor,
|
||||
active: true
|
||||
)
|
||||
.frame(width: 73, height: 32)
|
||||
@@ -137,6 +151,7 @@ public struct RecordButton: View {
|
||||
.frame(width: Layout.disc, height: Layout.disc)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
.animation(colorTransition, value: isClipboardCommandRecording)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
.modifier(
|
||||
@@ -146,8 +161,7 @@ public struct RecordButton: View {
|
||||
supportsClipboardLongPress: onClipboardLongPressBegan != nil,
|
||||
longPressArmed: $longPressArmed,
|
||||
onToggle: onToggle,
|
||||
onClipboardLongPressBegan: onClipboardLongPressBegan,
|
||||
onClipboardLongPressEnded: onClipboardLongPressEnded
|
||||
onClipboardLongPressBegan: onClipboardLongPressBegan
|
||||
)
|
||||
)
|
||||
.onAppear { breath = (phase == .recording) }
|
||||
@@ -160,6 +174,11 @@ public struct RecordButton: View {
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
|
||||
}
|
||||
|
||||
/// Red ↔ blue mode switch (~0.25s).
|
||||
private var colorTransition: Animation {
|
||||
.easeInOut(duration: 0.25)
|
||||
}
|
||||
|
||||
private var isIdle: Bool {
|
||||
switch phase {
|
||||
case .idleReady, .idleUnavailable:
|
||||
@@ -179,8 +198,8 @@ public struct RecordButton: View {
|
||||
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)]
|
||||
? [recordingTint, recordingTint.opacity(0.85)]
|
||||
: [recordingTint.opacity(0.95), recordingTint.opacity(0.75)]
|
||||
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
|
||||
case .processing:
|
||||
return LinearGradient(
|
||||
@@ -213,45 +232,49 @@ private struct RecordButtonPressModifier: ViewModifier {
|
||||
@Binding var longPressArmed: Bool
|
||||
let onToggle: () -> Void
|
||||
let onClipboardLongPressBegan: (() -> Void)?
|
||||
let onClipboardLongPressEnded: (() -> Void)?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if supportsClipboardLongPress {
|
||||
content.onLongPressGesture(
|
||||
minimumDuration: ClipboardMaterialFilter.longPressDuration,
|
||||
maximumDistance: 120,
|
||||
pressing: { pressing in
|
||||
if pressing {
|
||||
longPressArmed = false
|
||||
return
|
||||
}
|
||||
// Released.
|
||||
if longPressArmed {
|
||||
onClipboardLongPressEnded?()
|
||||
longPressArmed = false
|
||||
} else if phase != .processing, isEnabled || phase == .idleUnavailable {
|
||||
// Short press → existing tap-toggle dictation.
|
||||
guard phase != .recording else {
|
||||
// If somehow recording without arming, end via toggle.
|
||||
onToggle()
|
||||
return
|
||||
}
|
||||
onToggle()
|
||||
}
|
||||
},
|
||||
perform: {
|
||||
guard phase != .processing else { return }
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
longPressArmed = true
|
||||
onClipboardLongPressBegan?()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// While recording/processing, only tap-to-toggle — never treat finger-up
|
||||
// from the starting long-press as stop (clipboard is explicitly tap-to-stop).
|
||||
switch phase {
|
||||
case .recording, .processing:
|
||||
content.onTapGesture {
|
||||
guard phase != .processing else { return }
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
onToggle()
|
||||
}
|
||||
case .idleReady, .idleUnavailable, .error:
|
||||
if supportsClipboardLongPress {
|
||||
content.onLongPressGesture(
|
||||
minimumDuration: ClipboardMaterialFilter.longPressDuration,
|
||||
maximumDistance: 120,
|
||||
pressing: { pressing in
|
||||
if pressing {
|
||||
longPressArmed = false
|
||||
return
|
||||
}
|
||||
// Released before / without arming → short press = dictation toggle.
|
||||
// Armed release is ignored here; stop happens on a later tap
|
||||
// once phase becomes `.recording`.
|
||||
if longPressArmed {
|
||||
longPressArmed = false
|
||||
return
|
||||
}
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
onToggle()
|
||||
},
|
||||
perform: {
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
longPressArmed = true
|
||||
onClipboardLongPressBegan?()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
content.onTapGesture {
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
onToggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ public struct ThemePalette: Sendable, Equatable {
|
||||
public let dividerStrong: Color
|
||||
|
||||
public let recordRed: Color
|
||||
/// Clipboard-command hold-to-talk recording (distinct from dictation red).
|
||||
public let recordBlue: Color
|
||||
}
|
||||
|
||||
public enum Palette {
|
||||
@@ -70,6 +72,7 @@ public enum Palette {
|
||||
|
||||
// Recording state
|
||||
public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
|
||||
public static let recordBlue = Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
|
||||
|
||||
/// Canonical dark palette — preserves every legacy literal above so
|
||||
/// existing call sites that read `Palette.background` directly keep
|
||||
@@ -92,7 +95,8 @@ public enum Palette {
|
||||
textOnAccent: textOnAccent,
|
||||
divider: divider,
|
||||
dividerStrong: dividerStrong,
|
||||
recordRed: recordRed
|
||||
recordRed: recordRed,
|
||||
recordBlue: recordBlue
|
||||
)
|
||||
|
||||
/// Light palette — warm gray backgrounds for daytime use.
|
||||
@@ -113,7 +117,8 @@ public enum Palette {
|
||||
textOnAccent: Color.white,
|
||||
divider: Color.black.opacity(0.06),
|
||||
dividerStrong: Color.black.opacity(0.10),
|
||||
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
|
||||
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
|
||||
recordBlue: Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user