feat: harden Flow cold-start/force-quit and polish macOS dictation UX
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// MacDictationOverlayView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Compact bottom-of-screen HUD shown while dictating. Lives inside a
|
||||
// non-activating NSPanel so it never steals focus from the front app.
|
||||
// One-line layout: status / live transcript preview + waveform + stop.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacDictationOverlayView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
private var isBusy: Bool {
|
||||
viewModel.isRecording || viewModel.isPreparingToRecord || viewModel.isProcessing
|
||||
}
|
||||
|
||||
/// Trimmed live / final transcript for the single-line preview.
|
||||
private var previewText: String {
|
||||
viewModel.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var hasPreview: Bool { !previewText.isEmpty }
|
||||
|
||||
private var showsLiveBadge: Bool {
|
||||
viewModel.isRecording && viewModel.isStreamingPartial
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
statusDot
|
||||
primaryLine
|
||||
Spacer(minLength: Spacing.xs)
|
||||
trailingControl
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, 11)
|
||||
.frame(minWidth: 300, idealWidth: 400, maxWidth: 520)
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
.background(palette.surface, in: Capsule(style: .continuous))
|
||||
.overlay(
|
||||
Capsule(style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
|
||||
.padding(2)
|
||||
.animation(Motion.soft, value: hasPreview)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
.animation(Motion.quick, value: viewModel.isStreamingPartial)
|
||||
}
|
||||
|
||||
// MARK: - Primary line (status or one-line transcript)
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryLine: some View {
|
||||
if hasPreview {
|
||||
HStack(spacing: 6) {
|
||||
if showsLiveBadge {
|
||||
liveBadge
|
||||
}
|
||||
Text(previewText)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .leading)
|
||||
.contentTransition(.opacity)
|
||||
.animation(Motion.quick, value: previewText)
|
||||
.accessibilityLabel(previewText)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
Text(statusText)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.contentTransition(.opacity)
|
||||
if let appName = viewModel.foregroundAppName, isBusy {
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.45))
|
||||
Text(appName)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.animation(Motion.quick, value: statusText)
|
||||
}
|
||||
}
|
||||
|
||||
private var liveBadge: some View {
|
||||
Text(MacL10n.string("mac.overlay.live", language: lang))
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(palette.recordRed)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(palette.recordRed.opacity(0.12), in: Capsule(style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var statusDot: some View {
|
||||
Circle()
|
||||
.fill(dotColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
.animation(Motion.quick, value: viewModel.isProcessing)
|
||||
.animation(Motion.quick, value: viewModel.isPreparingToRecord)
|
||||
.animation(Motion.quick, value: viewModel.isStreamingPartial)
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
if viewModel.isRecording {
|
||||
return viewModel.isStreamingPartial ? palette.accent : palette.recordRed
|
||||
}
|
||||
if viewModel.isPreparingToRecord || viewModel.isProcessing { return palette.warning }
|
||||
return palette.accent
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if viewModel.isRecording {
|
||||
return MacL10n.string("mac.overlay.listening", language: lang)
|
||||
}
|
||||
if viewModel.isPreparingToRecord {
|
||||
return MacL10n.string("mac.overlay.preparing", language: lang)
|
||||
}
|
||||
if viewModel.isProcessing {
|
||||
if viewModel.isStreamingPartial {
|
||||
return MacL10n.string("mac.overlay.polishing", language: lang)
|
||||
}
|
||||
return MacL10n.string("mac.overlay.transcribing", language: lang)
|
||||
}
|
||||
return MacL10n.string("mac.overlay.done", language: lang)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingControl: some View {
|
||||
if viewModel.isRecording {
|
||||
MiniWaveform(
|
||||
level: viewModel.audioLevel,
|
||||
barCount: 7,
|
||||
tint: (viewModel.isStreamingPartial ? palette.accent : palette.recordRed)
|
||||
.opacity(0.9),
|
||||
maxBarHeight: 28,
|
||||
barWidth: 3.5,
|
||||
barSpacing: 2.5
|
||||
)
|
||||
stopButton
|
||||
} else if viewModel.isPreparingToRecord || viewModel.isProcessing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
|
||||
private var stopButton: some View {
|
||||
Button(action: viewModel.toggleRecording) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(palette.recordRed, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(MacL10n.string("mac.record.stop", language: lang))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user