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:
Rocky
2026-07-10 12:39:41 +08:00
parent dcb66a9849
commit cdf833935a
104 changed files with 5794 additions and 853 deletions
+185 -38
View File
@@ -25,6 +25,22 @@ enum MacMetrics {
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
/// Reading width for single-column content.
static let contentMaxWidth: CGFloat = 720
/// Horizontal inset for page titles and scroll *content* (cards).
/// ScrollViews / Forms stay full-bleed so the scrollbar sits on the
/// window edge; only the content inside is inset.
/// Doubled from `Spacing.lg` so title + cards breathe from the edges.
static let pageHorizontalInset: CGFloat = Spacing.lg * 2
/// Built-in horizontal inset macOS grouped `Form` adds around its section
/// cards, on top of any padding we apply. Subtracted from
/// `pageHorizontalInset` on the Settings Form so its card outer edge lands
/// on `pageHorizontalInset` matching the History page and the page title.
static let groupedFormSectionInset: CGFloat = Spacing.lg
/// Default (= minimum) main-window size. Opening the app uses this size;
/// the window cannot shrink below it.
static let windowMinWidth: CGFloat = 860
static let windowMinHeight: CGFloat = 600
/// Compact dictation-canvas height so Home fits the min window without scrolling.
static let dictationCanvasMinHeight: CGFloat = 120
/// Top inset that clears the window traffic-light buttons now that the
/// title bar is hidden.
static let trafficLightInset: CGFloat = 28
@@ -81,16 +97,67 @@ extension View {
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
}
// MARK: - Page header
/// Page title for History / Dictionary / Settings. Applies the shared
/// `pageHorizontalInset` so its left edge matches inset card content below.
/// Type size matches Home's brand line (`TypeStyle.pageTitle`).
struct MacPageHeader<Trailing: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String?
@ViewBuilder var trailing: () -> Trailing
init(
title: String,
subtitle: String? = nil,
@ViewBuilder trailing: @escaping () -> Trailing
) {
self.title = title
self.subtitle = subtitle
self.trailing = trailing
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(TypeStyle.pageTitle)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.85)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
}
}
Spacer(minLength: 0)
trailing()
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.sm)
}
}
extension MacPageHeader where Trailing == EmptyView {
init(title: String, subtitle: String? = nil) {
self.init(title: title, subtitle: subtitle) { EmptyView() }
}
}
// MARK: - Card container
/// Elevated surface used for stat tiles and the dictation canvas.
struct MacCard<Content: View>: View {
@Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md
var cornerRadius: CGFloat = Radius.medium
@ViewBuilder var content: () -> Content
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
content()
.padding(padding)
@@ -111,34 +178,82 @@ struct StatCard: View {
let caption: String
var systemImage: String?
var accent: Bool = false
/// Hero metric: wide horizontal layout that uses full-card width without
/// stretching to fill dead vertical space used for the primary word count.
var prominent: Bool = false
var body: some View {
MacCard {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Spacer()
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
}
MacCard(padding: prominent ? Spacing.md : Spacing.md) {
if prominent {
prominentBody
} else {
compactBody
}
}
}
private var compactBody: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Spacer()
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
.symbolRenderingMode(.hierarchical)
}
Text(value)
.font(TypeStyle.title2)
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
}
Text(value)
.font(TypeStyle.title2)
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
/// Wide "hero bar" layout: icon badge + title/caption on the left, the
/// big number anchored right fills the full card width edge-to-edge
/// instead of a tall card with empty space below a small number.
private var prominentBody: some View {
HStack(spacing: Spacing.md) {
if let systemImage {
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: 44, height: 44)
Image(systemName: systemImage)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: Spacing.md)
Text(value)
.font(.system(size: 34, weight: .bold))
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.6)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
}
}
}
@@ -152,30 +267,39 @@ struct MiniWaveform: View {
var barCount: Int = 5
/// Pass nil to inherit the palette accent automatically.
var tint: Color?
/// Peak bar height; overlay HUD uses a taller meter than the mic button.
var maxBarHeight: CGFloat = 22
var barWidth: CGFloat = 3
var barSpacing: CGFloat = 3
@State private var phase: CGFloat = 0
var body: some View {
HStack(spacing: 3) {
HStack(spacing: barSpacing) {
ForEach(0..<barCount, id: \.self) { index in
Capsule()
.fill(tint ?? palette.accent)
.frame(width: 3, height: barHeight(index))
.frame(width: barWidth, height: barHeight(index))
}
}
.frame(height: 22)
.frame(height: maxBarHeight)
.animation(Motion.instant, value: level)
.onAppear {
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
withAnimation(.linear(duration: 0.75).repeatForever(autoreverses: true)) {
phase = 1
}
}
}
private func barHeight(_ index: Int) -> CGFloat {
let base = CGFloat(level) * 22
let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4
return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble))
// Stronger level coupling + staggered phase so the meter reads as
// "alive" even at modest mic levels.
let boosted = min(1, CGFloat(level) * 1.35 + 0.08)
let base = boosted * maxBarHeight
let wobble = sin((phase * .pi * 2) + CGFloat(index) * 0.85) * (maxBarHeight * 0.22)
+ (maxBarHeight * 0.12)
let parity = 0.55 + CGFloat(index % 3) * 0.2
return max(maxBarHeight * 0.18, min(maxBarHeight, base * parity + wobble))
}
}
@@ -195,8 +319,8 @@ enum MacTranslationDisplay {
// MARK: - Status footer
/// Bottom status strip: engine mode (cloud/local), translation target, and
/// the connection state icons and wording mirror the dashboard record bar.
/// Bottom status strip: engine mode, translation target, connection
/// kept visually quiet so it never competes with the record bar.
struct MacStatusFooter: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@@ -204,7 +328,7 @@ struct MacStatusFooter: View {
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
HStack(spacing: Spacing.md) {
HStack(spacing: Spacing.sm) {
Spacer()
Label(
viewModel.isCloudMode
@@ -212,24 +336,47 @@ struct MacStatusFooter: View {
: MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
systemImage: "translate"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent)
.foregroundStyle(palette.accent.opacity(0.85))
}
.font(TypeStyle.caption)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.labelStyle(.titleAndIcon)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.vertical, Spacing.sm)
.animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
}
}
// MARK: - Form alignment
private struct MacFormPageAlignModifier: ViewModifier {
func body(content: Content) -> some View {
// Form stays full-bleed (scrollbar on the window edge). Section
// cards are inset to match `MacPageHeader`.
content
.contentMargins(.horizontal, MacMetrics.pageHorizontalInset, for: .scrollContent)
}
}
extension View {
/// Insets grouped-`Form` section cards to `pageHorizontalInset`.
func macFormPageAligned() -> some View {
modifier(MacFormPageAlignModifier())
}
}