Files
OSGKeyboard/OSGKeyboard/Views/MainSplitView.swift
T
Rocky e5a83843db Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
2026-08-22 16:33:18 +08:00

315 lines
11 KiB
Swift

// 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 OSGKeyboardShared
import SwiftUI
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 tab bar in split mode; shared footer spacing remains
// useful for the final rows in each scroll view.
}
// 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 {
// Match macOS sidebar: template wide mark tinted with accent so
// light / dark both stay readable (PNG `osglogo` can fail to
// show under NavigationSplitView chrome on some iPad sizes).
Image("OSGLogoWide")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: 28)
.foregroundStyle(palette.accent)
.accessibilityLabel("OSGKeyboard")
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, minHeight: 28, alignment: .leading)
.padding(.leading, WideLayoutMetrics.sidebarContentInset)
.padding(.trailing, WideLayoutMetrics.sidebarInset)
.padding(.top, Spacing.lg)
// Roughly double the gap between the brand header and the first
// sidebar row on iPad so the logo gets visual breathing room above
// the menu list (was `Spacing.md`, now `Spacing.xxxl`).
.padding(.bottom, Spacing.xxxl)
}
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(selected: isSelected))
.labelStyle(SidebarIconColumnLabelStyle())
// Slightly larger label + taller vertical padding so each
// sidebar row hits the Apple HIG 44pt touch target on iPad
// (was size 13 / vertical 7 → ~32pt row; now size 15 /
// vertical 12 → ~44pt row).
.font(.system(size: 15, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 12)
.background(
rowBackground,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.animation(Motion.quick, value: isSelected)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private var rowBackground: Color {
isSelected ? palette.accentMuted : .clear
}
}
/// Reserves a uniform icon column so every sidebar title starts at the same x.
///
/// `DefaultLabelStyle` sizes the icon to each SF Symbol's intrinsic width, and
/// this sidebar's symbols range from ~13pt (`character.book.closed`) to ~17.5pt
/// (`house`) — enough to push titles apart by ~5pt. `List` reserves that column
/// automatically; this hand-rolled `VStack` sidebar has to do it itself.
private struct SidebarIconColumnLabelStyle: LabelStyle {
/// Wider than the widest symbol in `AppTab.sidebarSystemImage(selected:)`.
private static let iconColumnWidth: CGFloat = 22
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: Spacing.xs) {
configuration.icon
.frame(width: Self.iconColumnWidth)
configuration.title
}
}
}
// 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 needsAPIKeySetup: Bool {
if config.isLocalEngine {
return !config.isPolishConfigured
}
return !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 needsAPIKeySetup {
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 && !needsAPIKeySetup {
Button {
flowManager.activateOnForeground(
reason: "MainSplitView.startButton",
startCapture: true
)
} 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 needsAPIKeySetup { 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 needsAPIKeySetup {
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"
}
}