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,276 @@
|
||||
// MainSplitView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// iPad / regular-width shell: sidebar navigation + detail workspace.
|
||||
// Mirrors the macOS `NavigationSplitView` structure while keeping iOS tabs
|
||||
// and Flow session behaviour unchanged underneath.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
struct MainSplitView: View {
|
||||
@Binding var selection: AppTab
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
sidebar
|
||||
.navigationSplitViewColumnWidth(WideLayoutMetrics.sidebarWidth)
|
||||
} detail: {
|
||||
detail
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.background(palette.background)
|
||||
// No floating dock in split mode — child scroll views should not
|
||||
// reserve bottom clearance for the phone tab bar.
|
||||
.environment(\.isTabBarVisible, false)
|
||||
}
|
||||
|
||||
// MARK: - Sidebar
|
||||
|
||||
private var sidebar: some View {
|
||||
VStack(spacing: 0) {
|
||||
brandHeader
|
||||
VStack(spacing: 4) {
|
||||
ForEach(AppTab.allCases, id: \.rawValue) { tab in
|
||||
WideSidebarRow(
|
||||
tab: tab,
|
||||
isSelected: selection == tab
|
||||
) {
|
||||
withAnimation(Motion.soft) { selection = tab }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, WideLayoutMetrics.sidebarInset)
|
||||
Spacer()
|
||||
devicesFooter
|
||||
}
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private var brandHeader: some View {
|
||||
HStack {
|
||||
Image("osglogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 28)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, WideLayoutMetrics.sidebarContentInset)
|
||||
.padding(.trailing, WideLayoutMetrics.sidebarInset)
|
||||
.padding(.top, Spacing.lg)
|
||||
.padding(.bottom, Spacing.md)
|
||||
}
|
||||
|
||||
private var devicesFooter: some View {
|
||||
Label("home.wide.devices", systemImage: "ipad.and.iphone")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, WideLayoutMetrics.sidebarInset + Spacing.sm)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
// MARK: - Detail
|
||||
|
||||
private var detail: some View {
|
||||
VStack(spacing: 0) {
|
||||
MainTabContent(tab: selection)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.id(selection)
|
||||
.transition(.opacity)
|
||||
WideStatusFooter()
|
||||
}
|
||||
.background(palette.background)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sidebar row
|
||||
|
||||
private struct WideSidebarRow: View {
|
||||
let tab: AppTab
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage)
|
||||
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
rowBackground,
|
||||
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(Motion.quick, value: isSelected)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
private var rowBackground: Color {
|
||||
isSelected ? palette.accentMuted : .clear
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status footer
|
||||
|
||||
/// Quiet bottom strip: engine mode + translation target + Flow readiness.
|
||||
private struct WideStatusFooter: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
|
||||
private var needsCloudSetup: Bool {
|
||||
!config.isLocalEngine && !config.isConfigured
|
||||
}
|
||||
|
||||
private var needsPermissionSetup: Bool {
|
||||
micStatus != .granted || speechStatus != .granted
|
||||
}
|
||||
|
||||
private var canManuallyStartSession: Bool {
|
||||
!flowManager.isActive && !flowManager.isStarting && !needsPermissionSetup
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Spacer()
|
||||
Label(
|
||||
config.engineMode == "cloud" ? "home.wide.mode.cloud" : "home.wide.mode.local",
|
||||
systemImage: config.engineMode == "cloud" ? "cloud" : "cpu"
|
||||
)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
Label(
|
||||
translationLabel,
|
||||
systemImage: "translate"
|
||||
)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
flowStatusControl
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.animation(Motion.quick, value: config.engineMode)
|
||||
.animation(Motion.quick, value: config.translationTargetLocaleId)
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
.onAppear { refreshPermissionStatuses() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var flowStatusControl: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Circle()
|
||||
.fill(flowStatusColor)
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
if needsCloudSetup {
|
||||
Text("home.flow.notReady")
|
||||
.foregroundStyle(palette.warning)
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
Text("home.flow.recording")
|
||||
} else if flowManager.isUtteranceProcessing {
|
||||
Text("home.flow.processing")
|
||||
} else if flowManager.isActive,
|
||||
FlowSessionBridge.isHostReady(),
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text("home.flow.label")
|
||||
Text(":")
|
||||
Text(expires, style: .timer)
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text(flowStatusLabel)
|
||||
}
|
||||
|
||||
if flowManager.isActive {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
Text("home.flow.endShort")
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if canManuallyStartSession && !needsCloudSetup {
|
||||
Button {
|
||||
flowManager.activateOnForeground()
|
||||
} label: {
|
||||
Text("home.flow.startShort")
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
micStatus = AppPermissions.micStatus
|
||||
speechStatus = AppPermissions.speechStatus
|
||||
}
|
||||
|
||||
private var translationLabel: String {
|
||||
let resolved = TranslationLanguageCatalog.resolve(config.translationTargetLocaleId)
|
||||
if TranslationLanguageCatalog.isOff(resolved.id) {
|
||||
return AppL10n.string("keyboard.translation.offMenu", language: config.uiLanguage)
|
||||
}
|
||||
return resolved.nativeName
|
||||
}
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
if !config.isLocalEngine && !config.isConfigured { return palette.warning }
|
||||
if flowManager.isUtteranceRecording || flowManager.isUtteranceProcessing {
|
||||
return palette.accent
|
||||
}
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
|
||||
if flowManager.isStarting { return palette.accent }
|
||||
if flowManager.isActive { return palette.warning }
|
||||
return palette.textTertiary
|
||||
}
|
||||
|
||||
private var flowStatusLabel: LocalizedStringKey {
|
||||
if !config.isLocalEngine && !config.isConfigured {
|
||||
return "home.flow.notReady"
|
||||
}
|
||||
if flowManager.isStarting {
|
||||
return "home.flow.starting"
|
||||
}
|
||||
if flowManager.isUtteranceRecording {
|
||||
return "home.flow.recording"
|
||||
}
|
||||
if flowManager.isUtteranceProcessing {
|
||||
return "home.flow.processing"
|
||||
}
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() {
|
||||
return "home.flow.label"
|
||||
}
|
||||
if flowManager.isActive {
|
||||
return "home.flow.notReady"
|
||||
}
|
||||
return "home.flow.inactive"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user