fix(home): simplify transient Flow status card
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Show only connection state while Flow is preparing or unavailable, and remove the card entirely once the session is healthy.
This commit is contained in:
Rocky
2026-08-20 20:26:09 +08:00
parent 10bc457c72
commit 1ad22b8697
2 changed files with 110 additions and 118 deletions
+60 -118
View File
@@ -1,8 +1,8 @@
// HomeView.swift // HomeView.swift
// OSGKeyboard · Main App // OSGKeyboard · Main App
// //
// Home: logo, flow hints, usage stats, history + dictionary entry card, // Home: logo, transient Flow connection status, usage stats, history +
// then engine / session status at the scroll bottom. History/dictionary // dictionary entry card. History/dictionary
// open via push (system back) rather than bottom-tab destinations. // open via push (system back) rather than bottom-tab destinations.
import OSGKeyboardShared import OSGKeyboardShared
@@ -74,6 +74,27 @@ enum FlowHomePiPStatusPolicy {
guard case .failed = lifecycle else { return false } guard case .failed = lifecycle else { return false }
return !needsPermissionSetup 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
}
} }
struct HomeView: View { struct HomeView: View {
@@ -84,7 +105,6 @@ struct HomeView: View {
@ObservedObject private var config = ProviderConfig.shared @ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var speechHistory = SpeechHistoryStore.shared @ObservedObject private var speechHistory = SpeechHistoryStore.shared
@EnvironmentObject private var flowManager: FlowSessionManager @EnvironmentObject private var flowManager: FlowSessionManager
@State private var keyboardHintDismissed = HomeGuideState.isKeyboardHintDismissed
@State private var micStatus = AppPermissions.micStatus @State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus @State private var speechStatus = AppPermissions.speechStatus
@State private var path = NavigationPath() @State private var path = NavigationPath()
@@ -134,12 +154,18 @@ struct HomeView: View {
} }
} }
private var shouldShowKeyboardHint: Bool { /// Healthy sessions need no persistent chrome. Keep the connection card
!keyboardHintDismissed /// only for setup, startup, recovery, or a genuinely unavailable session.
&& !KeyboardSetupBridge.isReadyForOnboardingSkip private var showsFlowConnectionCard: Bool {
&& !needsPermissionSetup FlowHomePiPStatusPolicy.shouldShowConnectionCard(
&& flowManager.sessionWarning == nil lifecycle: flowManager.pipLifecycleState,
&& !needsAPIKeySetup needsPermissionSetup: needsPermissionSetup,
needsAPIKeySetup: needsAPIKeySetup,
hasSessionWarning: flowManager.sessionWarning != nil,
isRecording: flowManager.isUtteranceRecording,
isProcessing: flowManager.isUtteranceProcessing,
isHostReady: FlowSessionBridge.isHostReady()
)
} }
var body: some View { var body: some View {
@@ -204,11 +230,13 @@ struct HomeView: View {
.padding(.top, logoTopPadding) .padding(.top, logoTopPadding)
.padding(.bottom, logoBottomPadding) .padding(.bottom, logoBottomPadding)
// Engine + Flow status now stays directly below the // Connection status is transient: once Flow is ready,
// logo so startup and recovery are always visible. // content moves up and the card disappears completely.
scrollStatusFooter if showsFlowConnectionCard {
.padding(.horizontal, Spacing.lg) scrollStatusFooter
.padding(.bottom, extrasBottomPadding) .padding(.horizontal, Spacing.lg)
.padding(.bottom, extrasBottomPadding)
}
HomeUsageStatsSection(layout: .stacked, compact: isCompact) HomeUsageStatsSection(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
@@ -226,13 +254,10 @@ struct HomeView: View {
} }
} }
/// Engine + Flow status visible near the logo instead of at scroll bottom. /// Compact Flow connection status no engine/model details.
private var scrollStatusFooter: some View { private var scrollStatusFooter: some View {
setupGuidanceCard { setupGuidanceCard {
engineStatusLine
flowStatusFooter flowStatusFooter
Divider()
flowSessionExtras
} }
} }
@@ -243,7 +268,9 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: Spacing.lg) { VStack(alignment: .leading, spacing: Spacing.lg) {
wideHeroHeader wideHeroHeader
scrollStatusFooter if showsFlowConnectionCard {
scrollStatusFooter
}
HomeUsageStatsSection(layout: .split) HomeUsageStatsSection(layout: .split)
@@ -494,8 +521,7 @@ struct HomeView: View {
.fill(flowStatusColor) .fill(flowStatusColor)
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
if needsAPIKeySetup { if needsPermissionSetup || needsAPIKeySetup {
// API Key
Text("home.flow.notReady") Text("home.flow.notReady")
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.warning) .foregroundStyle(palette.warning)
@@ -532,8 +558,19 @@ struct HomeView: View {
.minimumScaleFactor(0.85) .minimumScaleFactor(0.85)
} }
if needsAPIKeySetup { if needsPermissionSetup {
// API Key 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 {
EmptyView() EmptyView()
} else if canRetryPiP { } else if canRetryPiP {
Button { Button {
@@ -584,69 +621,6 @@ struct HomeView: View {
.animation(Motion.soft, value: flowManager.isActive) .animation(Motion.soft, value: flowManager.isActive)
} }
// MARK: - Flow extras (warnings / hints)
@ViewBuilder
private var flowSessionExtras: some View {
if needsPermissionSetup {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(AppPermissions.homePermissionGuidanceMessage)
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.fixedSize(horizontal: false, vertical: true)
Button(action: handlePermissionGuidanceAction) {
Text(
AppPermissions.canRequestPermissionsInApp
? "home.setup.permission.request"
: "home.flow.openSettings"
)
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
} else if let warning = flowManager.sessionWarning {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(warning)
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.fixedSize(horizontal: false, vertical: true)
}
} else if needsAPIKeySetup {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(config.isLocalEngine
? "home.setup.polishKeyMissing"
: "home.setup.cloudIncomplete")
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.fixedSize(horizontal: false, vertical: true)
}
} else if shouldShowKeyboardHint {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("home.setup.keyboardHint")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Button {
keyboardHintDismissed = true
HomeGuideState.dismissKeyboardHint()
} label: {
Text("home.setup.keyboardHint.dismiss")
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
} else {
Text("home.flow.hint")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.sm)
}
}
private func setupGuidanceCard<Content: View>(@ViewBuilder content: () -> Content) -> some View { private func setupGuidanceCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) { VStack(alignment: .leading, spacing: Spacing.sm) {
content() content()
@@ -696,22 +670,6 @@ struct HomeView: View {
} }
} }
private var engineStatusLine: some View {
Text(
EngineServiceLabel.summary(
engineMode: config.engineMode,
providerId: config.providerId,
model: config.model,
asrProviderId: config.asrProviderId,
asrModel: config.asrModel
)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
/// Rows shown inside the history / dictionary preview cards. /// Rows shown inside the history / dictionary preview cards.
private static let libraryPreviewLimit = 3 private static let libraryPreviewLimit = 3
@@ -722,19 +680,3 @@ struct HomeView: View {
return formatter return formatter
}() }()
} }
// MARK: - Home guidance persistence
private enum HomeGuideState {
private static let keyboardHintDismissedKey = "home.keyboardHintDismissed"
static var isKeyboardHintDismissed: Bool {
guard AppGroup.isAvailable else { return false }
return AppGroup.defaults.bool(forKey: keyboardHintDismissedKey)
}
static func dismissKeyboardHint() {
guard AppGroup.isAvailable else { return }
AppGroup.defaults.set(true, forKey: keyboardHintDismissedKey)
}
}
@@ -82,6 +82,40 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
) )
} }
func testConnectionCardDisappearsWhenSessionIsHealthy() {
XCTAssertFalse(
shouldShowConnectionCard(
lifecycle: .active,
isHostReady: true
)
)
}
func testConnectionCardRemainsVisibleWhilePreparingOrFailed() {
XCTAssertTrue(
shouldShowConnectionCard(
lifecycle: .preparing(attempt: 1, total: 3),
isHostReady: false
)
)
XCTAssertTrue(
shouldShowConnectionCard(
lifecycle: .failed(.timedOut),
isHostReady: false
)
)
}
func testNormalRecordingDoesNotBringConnectionCardBack() {
XCTAssertFalse(
shouldShowConnectionCard(
lifecycle: .active,
isRecording: true,
isHostReady: false
)
)
}
private func descriptor( private func descriptor(
for lifecycle: FlowPiPLifecycleState for lifecycle: FlowPiPLifecycleState
) -> FlowHomePiPStatusDescriptor { ) -> FlowHomePiPStatusDescriptor {
@@ -94,4 +128,20 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
isHostReady: false isHostReady: false
) )
} }
private func shouldShowConnectionCard(
lifecycle: FlowPiPLifecycleState,
isRecording: Bool = false,
isHostReady: Bool
) -> Bool {
FlowHomePiPStatusPolicy.shouldShowConnectionCard(
lifecycle: lifecycle,
needsPermissionSetup: false,
needsAPIKeySetup: false,
hasSessionWarning: false,
isRecording: isRecording,
isProcessing: false,
isHostReady: isHostReady
)
}
} }