feat: keyboard bottom-row layout, handedness preference, and screen wake lock

Rework the action cluster to a mic-above-bottom-row layout, add left/right
handedness setting that swaps delete and return, and keep the screen awake
during Flow recording sessions.
This commit is contained in:
Rocky
2026-07-02 21:01:54 +08:00
parent 1bdb8824ac
commit bc77cabb62
18 changed files with 501 additions and 104 deletions
+71 -88
View File
@@ -11,8 +11,8 @@
// [polish] [] header band (top)
// (transcript preview)
//
// () mic () action cluster:
// (space) centred below header
// mic (centred) action cluster:
// [delete] [ space ] [return] mic + bottom row
//
//
@@ -20,36 +20,39 @@ import SwiftUI
import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let sideActionButtonSize: CGFloat = 53
static let sideActionIconSize: CGFloat = 19
static let sideSpaceBarWidth: CGFloat = 19
static let micFlankMinSpacing: CGFloat = 36
static let sideActionStackSpacing: CGFloat = 16
static let micSize: CGFloat = 121
static let micToButtonGap: CGFloat = 8
static let bottomActionRowHeight: CGFloat = 48
static let bottomActionFixedWidth: CGFloat = 86
static let bottomActionSpacing: CGFloat = Spacing.xs
/// Gap between the top chip row and the transcript / hint line (4 pt 8 pt, +100%).
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs
/// Outer inset for delete / return·space from screen edges (8 pt 24 pt, +200%).
/// Outer inset for the bottom action row from screen edges (8 pt 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
// MARK: - Content-driven keyboard height (single source of truth)
static let outerPaddingTop: CGFloat = 2
static let outerPaddingBottom: CGFloat = 6
static let outerPaddingBottom: CGFloat = 1
static let topBarHeight: CGFloat = 38
static let transcriptLineHeight: CGFloat = 22
static let actionClusterHeight: CGFloat = 132
/// Fixed breathing room above/below the mic row (not flexible Spacers).
static let actionClusterVerticalGap: CGFloat = Spacing.md
/// mic (121) + gap (8) + bottom row (48) = 177 pt
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
/// Gap between transcript line and mic (30% from former 16 pt).
static let actionClusterTopGap: CGFloat = Spacing.md * 0.7
/// Minimal gap below the bottom action row.
static let actionClusterBottomGap: CGFloat = Spacing.xs / 2
static var headerBandHeight: CGFloat {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt
/// 2 + 68 + 11.2 + 177 + 4 + 1 = 263.2 pt
static var totalHeight: CGFloat {
outerPaddingTop
+ headerBandHeight
+ actionClusterVerticalGap
+ actionClusterTopGap
+ actionClusterHeight
+ actionClusterVerticalGap
+ actionClusterBottomGap
+ outerPaddingBottom
}
}
@@ -76,13 +79,13 @@ public struct KeyboardRootView: View {
headerBand
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
}
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
@@ -152,33 +155,29 @@ public struct KeyboardRootView: View {
// MARK: - Action cluster
/// Delete (left), mic (centre), return + space stacked on the right.
/// Fixed vertical gaps in `body` keep the cluster centred without
/// flexible Spacers consuming extra keyboard height.
/// Mic centred above a bottom row: delete · space · return (or swapped).
private var micActionRow: some View {
HStack(alignment: .center, spacing: 0) {
CircularToolbarButton(systemName: "delete.left", label: "delete") {
state.deleteBackward()
}
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
let editingBlocked = voiceInputBlocksEditing
let swapKeys = state.handednessPreference.swapsActionKeys
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
onToggle: state.tapMic
)
.frame(width: 132, height: 132)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) {
CircularToolbarButton(systemName: "return", label: "newline") {
state.insertNewline()
}
CircularToolbarButton(spaceStyle: true, label: "space") {
state.insertSpace()
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
bottomDeleteButton(disabled: editingBlocked)
} else {
bottomDeleteButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
}
}
}
@@ -186,6 +185,43 @@ public struct KeyboardRootView: View {
.frame(maxWidth: .infinity)
}
private func bottomDeleteButton(disabled: Bool) -> some View {
RepeatingDeleteButton(disabled: disabled) {
state.deleteBackward()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
}
private func bottomSpaceButton(disabled: Bool) -> some View {
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
state.insertSpace()
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomReturnButton(disabled: Bool) -> some View {
RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) {
state.insertNewline()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
}
/// Option C: block typing keys during the full voice-input pipeline.
private var voiceInputBlocksEditing: Bool {
switch state.phase {
case .requestingPermissions, .recording, .processing:
return true
case .idle, .error, .denied:
return false
}
}
private var buttonPhase: RecordButton.Phase {
switch state.phase {
case .idle: return .idle
@@ -337,59 +373,6 @@ private struct TranscriptLine: View {
}
}
// MARK: - Circular toolbar button
private struct CircularToolbarButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette: ThemePalette
let systemName: String?
let spaceStyle: Bool
let label: String
let action: () -> Void
init(systemName: String, label: String, action: @escaping () -> Void) {
self.systemName = systemName
self.spaceStyle = false
self.label = label
self.action = action
}
init(spaceStyle: Bool, label: String, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.label = label
self.action = action
}
var body: some View {
Button(action: action) {
Group {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: KeyboardLayoutMetrics.sideSpaceBarWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: KeyboardLayoutMetrics.sideActionIconSize, weight: .medium))
.foregroundStyle(palette.textPrimary)
}
}
.frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize)
.background(sideButtonFill, in: Circle())
.overlay(Circle().stroke(palette.dividerStrong, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
}
private var sideButtonFill: Color {
colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
}
}
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
private struct CloudEngineChip: View {
+11 -11
View File
@@ -42,13 +42,13 @@ struct RecordButton: View {
return remainingSeconds <= 10
}
/// Decorative rings are sized to stay inside the 132 pt frame applied
/// Decorative rings are sized to stay inside the 121 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
static let disc: CGFloat = 95
static let outerRing: CGFloat = 106
static let breathRing: CGFloat = 100
static let glow: CGFloat = 119
}
var body: some View {
@@ -65,8 +65,8 @@ struct RecordButton: View {
RadialGradient(
colors: [palette.recordRed.opacity(0.55), .clear],
center: .center,
startRadius: 50,
endRadius: 100
startRadius: 46,
endRadius: 92
)
)
.frame(width: Layout.glow, height: Layout.glow)
@@ -93,10 +93,10 @@ struct RecordButton: View {
switch phase {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 38, weight: .medium))
.font(.system(size: 36, weight: .medium))
.foregroundStyle(.white)
case .recording:
VStack(spacing: 4) {
VStack(spacing: 3) {
if let remainingSeconds {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded))
@@ -109,7 +109,7 @@ struct RecordButton: View {
color: Color(red: 1.0, green: 0.78, blue: 0.78),
active: true
)
.frame(width: 72, height: 32)
.frame(width: 73, height: 32)
}
.transition(.opacity)
case .processing:
@@ -119,7 +119,7 @@ struct RecordButton: View {
.scaleEffect(2.5)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 30, weight: .medium))
.font(.system(size: 32, weight: .medium))
.foregroundStyle(palette.warning)
}
}
@@ -0,0 +1,224 @@
// ToolbarActionButtons.swift
// OSGKeyboard · Keyboard Extension
//
// Bottom-row action keys: repeating delete, space, and return.
import SwiftUI
import UIKit
import OSGKeyboardShared
// MARK: - Layout metrics
private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14
static let cornerRadius: CGFloat = 12
static let spaceBarCapsuleWidth: CGFloat = 31
static let pressScale: CGFloat = 0.94
static let pressOverlayOpacity: CGFloat = 0.18
}
// MARK: - Haptics
private enum ToolbarHaptics {
@MainActor
static func tap() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
// MARK: - Press styling
private struct ToolbarKeyPressStyle: ButtonStyle {
let cornerRadius: CGFloat
func makeBody(configuration: Configuration) -> some View {
configuration.label
.overlay {
if configuration.isPressed {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
}
}
.scaleEffect(configuration.isPressed ? ToolbarButtonMetrics.pressScale : 1)
.animation(.easeOut(duration: 0.1), value: configuration.isPressed)
.sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed) { _, pressed in
pressed
}
}
}
private struct ToolbarKeySurface<Content: View>: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
let isPressed: Bool
let cornerRadius: CGFloat
@ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(buttonFill, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
}
.overlay {
if isPressed {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
}
}
.scaleEffect(isPressed ? ToolbarButtonMetrics.pressScale : 1)
.animation(.easeOut(duration: 0.1), value: isPressed)
}
private var buttonFill: Color {
let base = colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
return isPressed ? base.opacity(0.82) : base
}
}
// MARK: - Repeating delete
/// Tap deletes once; hold repeats with tiered acceleration after 5 s.
struct RepeatingDeleteButton: View {
@Environment(\.themePalette) private var palette
let disabled: Bool
let action: () -> Void
@State private var isPressing = false
@State private var repeatTask: Task<Void, Never>?
@State private var repeatStartedAt: Date?
private let initialDelay: TimeInterval = 0.4
private let normalInterval: TimeInterval = 0.08
private let accelTier2: TimeInterval = 0.05
private let accelTier3: TimeInterval = 0.03
private let accelTier4: TimeInterval = 0.015
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
Image(systemName: "delete.left")
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text("delete"))
.accessibilityAddTraits(.isButton)
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
repeatStartedAt = Date()
ToolbarHaptics.tap()
action()
startRepeating()
}
.onEnded { _ in
stopRepeating()
}
}
private func interval(for elapsed: TimeInterval) -> TimeInterval {
if elapsed < 5 { return normalInterval }
if elapsed < 8 { return accelTier2 }
if elapsed < 12 { return accelTier3 }
return accelTier4
}
private func startRepeating() {
repeatTask?.cancel()
repeatTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(initialDelay * 1_000_000_000))
guard !Task.isCancelled, isPressing else { return }
let anchor = repeatStartedAt ?? Date()
while !Task.isCancelled, isPressing {
action()
let elapsed = Date().timeIntervalSince(anchor)
let wait = interval(for: elapsed)
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
}
}
}
private func stopRepeating() {
isPressing = false
repeatStartedAt = nil
repeatTask?.cancel()
repeatTask = nil
}
}
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@Environment(\.themePalette) private var palette
let systemName: String?
let spaceStyle: Bool
let label: String
let disabled: Bool
let action: () -> Void
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = systemName
self.spaceStyle = false
self.label = label
self.disabled = disabled
self.action = action
}
init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.label = label
self.disabled = disabled
self.action = action
}
var body: some View {
Button(action: action) {
Group {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(keyBackground)
.overlay(
RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
}
.buttonStyle(ToolbarKeyPressStyle(cornerRadius: ToolbarButtonMetrics.cornerRadius))
.disabled(disabled)
.opacity(disabled ? 0.38 : 1)
.accessibilityLabel(Text(label))
}
@Environment(\.colorScheme) private var colorScheme
private var keyBackground: some View {
let fill = colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
return RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
.fill(fill)
}
}