Files
OSGKeyboard/OSGKeyboard/Views/HomeView.swift
T
rocky 9a4107cef8 chore(home): remove the DEBUG host bundle probe card
Host identification has been verified on device; the temporary Home test card is no longer needed. The extension-side probe stays in place.
2026-10-20 23:00:11 +02:00

840 lines
32 KiB
Swift

// HomeView.swift
// OSGKeyboard · Main App
//
// Home: logo, transient Flow connection status, usage stats, account rewards,
// and dictionary suggestions. History/dictionary/account destinations open
// via push (system back) rather than bottom-tab destinations.
import OSGKeyboardHostSupport
import OSGKeyboardShared
import SwiftUI
import UIKit
private enum HomeRoute: Hashable {
case history
case dictionary
case account
}
enum FlowHomePiPStatusDescriptor: Equatable {
case text(localizationKey: String)
case progress(localizationKey: String, attempt: Int, total: Int)
}
enum FlowHomePiPStatusPolicy {
static func descriptor(
lifecycle: FlowPiPLifecycleState,
isStarting: Bool,
isRecording: Bool,
isProcessing: Bool,
isActive: Bool,
isHostReady: Bool
) -> FlowHomePiPStatusDescriptor {
switch lifecycle {
case .preparing(let attempt, let total):
return .progress(
localizationKey: "home.flow.preparingProgress",
attempt: attempt,
total: total
)
case .recovering(let attempt, let total):
return .progress(
localizationKey: "home.flow.recoveringProgress",
attempt: attempt,
total: total
)
case .waitingForForeground:
return .text(localizationKey: "home.flow.waitingForForeground")
case .failed:
return .text(localizationKey: "home.flow.recoveryFailed")
case .inactive, .active:
break
}
if isStarting {
return .text(localizationKey: "home.flow.starting")
}
if isRecording {
return .text(localizationKey: "home.flow.recording")
}
if isProcessing {
return .text(localizationKey: "home.flow.processing")
}
if isActive, isHostReady {
return .text(localizationKey: "home.flow.label")
}
if isActive {
// Session flag is up but the ready contract is not — do not lie.
return .text(localizationKey: "home.flow.notReady")
}
return .text(localizationKey: "home.flow.inactive")
}
static func canRetry(
lifecycle: FlowPiPLifecycleState,
needsPermissionSetup: Bool
) -> Bool {
guard case .failed = lifecycle else { return false }
return !needsPermissionSetup
}
static func shouldShowConnectionCard(
lifecycle: FlowPiPLifecycleState,
needsPermissionSetup: Bool,
needsAPIKeySetup: Bool,
hasSessionWarning: Bool,
isRecording: Bool,
isProcessing: Bool,
isHostReady: Bool
) -> Bool {
if needsPermissionSetup || needsAPIKeySetup || hasSessionWarning {
return true
}
if isRecording || isProcessing {
return false
}
if case .active = lifecycle {
return !isHostReady
}
return true
}
}
enum HomeServiceSetupPolicy {
static func apiKeyMessageKey(
isLocalEngine: Bool,
isASRConfigured: Bool
) -> String {
if isLocalEngine || isASRConfigured {
return "home.setup.polishKeyMissing"
}
return "home.setup.cloudIncomplete"
}
static func apiKeyDeepLink(
isLocalEngine: Bool,
isASRConfigured: Bool
) -> SettingsDeepLink {
if isLocalEngine || isASRConfigured {
return .textPolish
}
return .speechRecognition
}
}
struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
@State private var path = NavigationPath()
@State private var dictionarySuggestions: [FrequentTerm] = []
@State private var pendingDictionarySuggestion: FrequentTerm?
private let appGroupStore = AppGroupStore()
private let dictionaryEntryService = PersonalDictionaryEntryService()
private let frequentTermStore = FrequentTermStore()
private var usesWideLayout: Bool {
horizontalSizeClass == .regular
}
private var sessionIsLive: Bool {
flowManager.isActive || flowManager.isStarting
}
/// Cloud: ASR + polish keys. Local: polish LLM key (ASR is on-device).
private var needsAPIKeySetup: Bool {
if config.isLocalEngine {
return !config.isPolishConfigured
}
return !config.isConfigured
}
private var apiKeySetupMessageKey: String {
HomeServiceSetupPolicy.apiKeyMessageKey(
isLocalEngine: config.isLocalEngine,
isASRConfigured: config.isASRConfigured
)
}
private var apiKeySettingsDeepLink: SettingsDeepLink {
HomeServiceSetupPolicy.apiKeyDeepLink(
isLocalEngine: config.isLocalEngine,
isASRConfigured: config.isASRConfigured
)
}
private var needsPermissionSetup: Bool {
micStatus != .granted || speechStatus != .granted
}
/// Can the user start a session right now from the Home footer? Only when
/// nothing is live/starting and permissions are already granted (otherwise
/// the permission guidance card is the correct call to action).
private var canManuallyStartSession: Bool {
!sessionIsLive && !needsPermissionSetup
}
private var canRetryPiP: Bool {
FlowHomePiPStatusPolicy.canRetry(
lifecycle: flowManager.pipLifecycleState,
needsPermissionSetup: needsPermissionSetup
)
}
private var canEndFlowSession: Bool {
if flowManager.isActive || flowManager.isStarting { return true }
switch flowManager.pipLifecycleState {
case .waitingForForeground, .failed:
return true
case .inactive, .preparing, .recovering, .active:
return false
}
}
/// Healthy sessions need no persistent chrome. Keep the connection card
/// only for setup, startup, recovery, or a genuinely unavailable session.
private var showsFlowConnectionCard: Bool {
FlowHomePiPStatusPolicy.shouldShowConnectionCard(
lifecycle: flowManager.pipLifecycleState,
needsPermissionSetup: needsPermissionSetup,
needsAPIKeySetup: needsAPIKeySetup,
hasSessionWarning: flowManager.sessionWarning != nil,
isRecording: flowManager.isUtteranceRecording,
isProcessing: flowManager.isUtteranceProcessing,
isHostReady: FlowSessionBridge.isHostReady()
)
}
var body: some View {
NavigationStack(path: $path) {
Group {
if usesWideLayout {
wideBody
} else {
phoneBody
}
}
.toolbar(path.isEmpty ? .hidden : .automatic, for: .navigationBar)
.navigationDestination(for: HomeRoute.self) { route in
switch route {
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
case .account:
AccountCenterView()
}
}
}
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
.onAppear {
refreshPermissionStatuses()
refreshDictionarySuggestions()
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
refreshPermissionStatuses()
refreshDictionarySuggestions()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshPermissionStatuses()
refreshDictionarySuggestions()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionarySuggestions()
}
.onChange(of: path.count) { _, count in
guard count == 0 else { return }
refreshDictionarySuggestions()
}
}
// MARK: - Phone layout
private var phoneBody: some View {
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
let isCompact = geo.size.height < 700
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
ScrollView {
VStack(spacing: 0) {
logoHeader(compact: isCompact)
.padding(.top, logoTopPadding)
.padding(.bottom, logoBottomPadding)
// Connection status is transient: once Flow is ready,
// content moves up and the card disappears completely.
if showsFlowConnectionCard {
scrollStatusFooter
.padding(.horizontal, Spacing.lg)
.padding(.bottom, CardLayoutMetrics.sectionSpacing)
}
homeContentSections(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.xl)
}
.frame(maxWidth: .infinity)
.tabBarScrollBottomPadding()
}
}
.background(palette.background)
}
}
/// Compact Flow connection status — no engine/model details.
private var scrollStatusFooter: some View {
setupGuidanceCard {
flowStatusFooter
if needsAPIKeySetup {
apiKeySetupGuidance
}
}
}
// MARK: - Wide layout (iPad / regular width)
private var wideBody: some View {
ScrollView {
VStack(alignment: .leading, spacing: CardLayoutMetrics.sectionSpacing) {
wideHeroHeader
if showsFlowConnectionCard {
scrollStatusFooter
}
homeContentSections(layout: .split)
}
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
.frame(maxWidth: .infinity, alignment: .topLeading)
.tabBarScrollBottomPadding()
}
.background(palette.background)
}
private var wideHeroHeader: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(AppL10n.string("onboarding.welcome.tagline"))
.font(TypeStyle.pageTitle)
.foregroundStyle(palette.textPrimary)
.lineLimit(2)
.minimumScaleFactor(0.85)
Text(AppL10n.string("home.wide.tagline.subtitle"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Usage, account, and dictionary
/// Keep the Home information hierarchy stable on both phone and iPad:
/// account rewards → four metrics → calendar → personal dictionary.
private func homeContentSections(
layout: UsageStatsClusterLayout,
compact: Bool = false
) -> some View {
VStack(spacing: CardLayoutMetrics.sectionSpacing) {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--home-dictionary-screenshot") {
dictionaryCard
} else if ProcessInfo.processInfo.arguments.contains("--home-calendar-screenshot") {
HomeUsageStatsSection(layout: layout, compact: compact, content: .calendar)
} else {
standardHomeContentSections(layout: layout, compact: compact)
}
#else
standardHomeContentSections(layout: layout, compact: compact)
#endif
}
}
@ViewBuilder
private func standardHomeContentSections(
layout: UsageStatsClusterLayout,
compact: Bool
) -> some View {
AccountRewardsCard {
path.append(HomeRoute.account)
}
HomeUsageStatsSection(
layout: layout,
compact: compact,
content: .metrics,
onOpenHistory: {
path.append(HomeRoute.history)
},
onOpenDictionary: {
path.append(HomeRoute.dictionary)
}
)
HomeUsageStatsSection(
layout: layout,
compact: compact,
content: .calendar
)
dictionaryCard
}
private var dictionaryCard: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Button {
path.append(HomeRoute.dictionary)
} label: {
HStack(spacing: Spacing.xs) {
Image(systemName: "sparkles")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.symbolRenderingMode(.monochrome)
Text(AppL10n.string("settings.personalDictionary.title"))
.font(TypeStyle.footnote.weight(.semibold))
.tracking(0.6)
.textCase(.uppercase)
.foregroundStyle(palette.textTertiary)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.right")
.font(TypeStyle.caption.weight(.semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if dictionarySuggestions.isEmpty {
Text(AppL10n.string("home.card.dictionary.smart.empty"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, minHeight: 54, alignment: .center)
} else {
VStack(spacing: Spacing.sm) {
LazyVGrid(
columns: [
GridItem(
.adaptive(minimum: 88, maximum: 160),
spacing: CardLayoutMetrics.compactItemSpacing
)
],
alignment: .leading,
spacing: CardLayoutMetrics.compactItemSpacing
) {
ForEach(dictionarySuggestions) { suggestion in
Button {
pendingDictionarySuggestion = suggestion
} label: {
Text(suggestion.term)
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.horizontal, Spacing.sm)
.frame(maxWidth: .infinity, minHeight: 38)
.background(
palette.textPrimary.opacity(0.08),
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
)
}
.buttonStyle(.plain)
.popover(
isPresented: Binding(
get: {
pendingDictionarySuggestion?.id == suggestion.id
},
set: { isPresented in
if !isPresented,
pendingDictionarySuggestion?.id == suggestion.id {
pendingDictionarySuggestion = nil
}
}
),
attachmentAnchor: .rect(.bounds),
arrowEdge: .bottom
) {
dictionarySuggestionConfirmation(suggestion)
.presentationCompactAdaptation(.popover)
}
.accessibilityHint(
AppL10n.format(
"home.card.dictionary.smart.accessibilityHint",
suggestion.commitCount
)
)
}
}
Text(AppL10n.string("home.card.dictionary.smart.addHint"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.surfaceCard()
}
private func dictionarySuggestionConfirmation(_ suggestion: FrequentTerm) -> some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Text(AppL10n.format("home.card.dictionary.confirm.title", suggestion.term))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
Text(
AppL10n.format(
"home.card.dictionary.confirm.message",
suggestion.commitCount
)
)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
HStack(spacing: Spacing.sm) {
Button(AppL10n.string("common.cancel")) {
pendingDictionarySuggestion = nil
}
.buttonStyle(.bordered)
.tint(palette.textPrimary)
Spacer(minLength: 0)
Button {
addSuggestedTerm(suggestion)
} label: {
Text(AppL10n.string("home.card.dictionary.confirm.add"))
.foregroundStyle(palette.background)
}
.buttonStyle(.borderedProminent)
.tint(palette.textPrimary)
}
}
.padding(Spacing.lg)
.frame(width: 300)
.background(palette.surface)
}
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
}
private func refreshDictionarySuggestions() {
let dictionary = appGroupStore.personalDictionary
let suggestions = frequentTermStore.suggestions(
excludingPersonalTerms: Set(dictionary.entries.map(\.term)),
limit: Self.dictionarySuggestionLimit
)
let visibleSuggestions = Array(suggestions.prefix(Self.dictionarySuggestionLimit))
dictionarySuggestions = visibleSuggestions.count >= Self.minimumDictionarySuggestionCount
? visibleSuggestions
: []
}
private func addSuggestedTerm(_ suggestion: FrequentTerm) {
guard let saved = dictionaryEntryService.saveEntry(
term: suggestion.term,
source: .history,
minimumUsageCount: suggestion.commitCount
) else {
return
}
pendingDictionarySuggestion = nil
refreshDictionarySuggestions()
Task {
await dictionaryEntryService.finishSaving(saved)
}
}
private func handlePermissionGuidanceAction() {
if AppPermissions.canRequestPermissionsInApp {
Task {
await AppPermissions.requestFlowPermissionsIfNeeded()
refreshPermissionStatuses()
}
} else {
AppPermissions.openSystemSettings()
}
}
private func openSettings(_ deepLink: SettingsDeepLink) {
SettingsDeepLink.setPending(deepLink)
NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil)
}
// MARK: - Top gradient
private func sessionHeaderGradient(height: CGFloat) -> some View {
LinearGradient(
colors: headerGradientColors,
startPoint: .top,
endPoint: .bottom
)
.frame(maxWidth: .infinity)
.frame(height: height)
.animation(Motion.soft, value: sessionIsLive)
}
private var headerGradientColors: [Color] {
// 云端引擎未配置(缺 API Key)时不算就绪,保持中性灰渐变。
if sessionIsLive, !needsAPIKeySetup {
let leadingOpacity = colorScheme == .dark ? 0.18 : 0.28
let trailingOpacity = colorScheme == .dark ? 0.06 : 0.10
return [
palette.accent.opacity(leadingOpacity),
palette.accent.opacity(trailingOpacity),
palette.background.opacity(0)
]
}
return [
palette.textTertiary.opacity(0.14),
palette.textTertiary.opacity(0.05),
palette.background.opacity(0)
]
}
// MARK: - Header
// Logo 保持 144:41 比例,并使用随系统深浅色切换的黑白单色。
private func logoHeader(compact: Bool) -> some View {
let logoWidth: CGFloat = compact ? 88 : 108
let logoHeight = logoWidth * (41.0 / 144.0)
return VStack(spacing: Spacing.xxl) {
Image("osglogo")
.resizable()
.renderingMode(.template)
.scaledToFit()
.frame(width: logoWidth, height: logoHeight)
.foregroundStyle(
colorScheme == .dark
? OSGColor.fixedLightContent
: OSGColor.fixedDarkContent
)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.lg)
}
// 就绪信息:绿点 + 状态文字(+ 计时 / 结束文本按钮),字号对齐引擎信息行。
private var flowStatusFooter: some View {
HStack(spacing: Spacing.xs) {
Circle()
.fill(flowStatusColor)
.frame(width: 6, height: 6)
if needsPermissionSetup || needsAPIKeySetup {
Text(AppL10n.string("home.flow.notReady"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.lineLimit(1)
.minimumScaleFactor(0.85)
} else if flowManager.isUtteranceRecording {
Text(AppL10n.string("home.flow.recording"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else if flowManager.isUtteranceProcessing {
Text(AppL10n.string("home.flow.processing"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else if flowManager.isActive,
FlowSessionBridge.isHostReady(),
let expires = flowManager.sessionExpiresAt {
Text(AppL10n.string("home.flow.label"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
Text(":")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Text(expires, style: .timer)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.monospacedDigit()
} else {
Text(flowCapsuleStatusMessage)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
if needsPermissionSetup {
Button(action: handlePermissionGuidanceAction) {
Text(
AppPermissions.canRequestPermissionsInApp
? "home.setup.permission.request"
: "home.flow.openSettings"
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
} else if needsAPIKeySetup {
// The explanatory copy and both setup paths sit below this
// compact status row so they remain readable on narrow phones.
EmptyView()
} else if canRetryPiP {
Button {
flowManager.retryPiPRecovery()
} label: {
Text(AppL10n.string("home.flow.retry"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
Button {
flowManager.endSession()
} label: {
Text(AppL10n.string("home.flow.endShort"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
} else if canEndFlowSession {
Button {
flowManager.endSession()
} label: {
Text(AppL10n.string("home.flow.endShort"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
} else if canManuallyStartSession {
// Foreground activation is automatic. After an explicit stop
// while staying on this screen, Start is the explicit re-entry.
Button {
flowManager.activateOnForeground(
reason: "HomeView.startButton",
startCapture: true
)
} label: {
Text(AppL10n.string("home.flow.startShort"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
}
}
.animation(Motion.soft, value: flowManager.isActive)
}
private var apiKeySetupGuidance: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(AppL10n.string(apiKeySetupMessageKey))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Text(AppL10n.string("home.setup.credits.alternative"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: Spacing.sm) {
Button {
openSettings(apiKeySettingsDeepLink)
} label: {
Label(AppL10n.string("home.setup.byok.configure"), systemImage: "key")
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 34)
.background(
palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
}
.buttonStyle(.plain)
Button {
openSettings(.aiService)
} label: {
Label(AppL10n.string("home.setup.credits.open"), systemImage: "sparkles")
.foregroundStyle(palette.textOnAccent)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 34)
.background(
palette.accent,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
}
.buttonStyle(.plain)
}
.font(TypeStyle.body)
}
}
private func setupGuidanceCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.cardElevation()
}
private var flowStatusColor: Color {
if needsAPIKeySetup { return palette.warning }
if flowManager.isUtteranceRecording { return palette.accent }
if flowManager.isUtteranceProcessing { return palette.accent }
if case .failed = flowManager.pipLifecycleState { return palette.warning }
if case .waitingForForeground = flowManager.pipLifecycleState { return palette.warning }
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
if flowManager.isStarting { return palette.accent }
if needsPermissionSetup { return palette.warning }
if flowManager.sessionWarning != nil { return palette.warning }
// Active but not host-ready (e.g. mid-utterance / audio proof) — amber.
if flowManager.isActive { return palette.warning }
return palette.textTertiary
}
/// Single source of truth for the logo status capsule. The local
/// engine is always "ready" because iOS `SpeechAnalyzer` ships
/// with the OS), so the previous downloading / warming / failed
/// states collapse into the cloud-engine branch.
private var flowCapsuleStatusMessage: String {
let descriptor = FlowHomePiPStatusPolicy.descriptor(
lifecycle: flowManager.pipLifecycleState,
isStarting: flowManager.isStarting,
isRecording: flowManager.isUtteranceRecording,
isProcessing: flowManager.isUtteranceProcessing,
isActive: flowManager.isActive,
isHostReady: FlowSessionBridge.isHostReady()
)
switch descriptor {
case .text(let localizationKey):
return AppL10n.string(localizationKey)
case .progress(let localizationKey, let attempt, let total):
return AppL10n.format(localizationKey, attempt, total)
}
}
private static let minimumDictionarySuggestionCount = 3
private static let dictionarySuggestionLimit = 6
}