fix(home): simplify transient Flow status card
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:
@@ -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.
|
||||||
|
if showsFlowConnectionCard {
|
||||||
scrollStatusFooter
|
scrollStatusFooter
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
.padding(.bottom, extrasBottomPadding)
|
.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
|
||||||
|
|
||||||
|
if showsFlowConnectionCard {
|
||||||
scrollStatusFooter
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user