Let the banner speak only for what it answers

Standing the feed error down under the context banner was aimed at one
sentence and hit every one. The fallback to local reads asks for a Yattee
Server nobody configured, and that question the banner already answers —
but a local refresh that failed on the way out answers nothing, and it was
being swallowed too. With the banner up, an offline refresh left the last
feed on screen with no explanation and no retry: the state said error, the
view said nothing.

The decision now names the errors the banner covers. `yatteeServerRequired`
and `notAuthenticated` are the account the banner is already talking about
and stand down; a `networkError` is news the banner does not carry and
keeps its own message and its retry button. It lives on `FeedLoadState` as
`displayedError(contextBannerVisible:)` so the rule is one function rather
than a guard in a view property, and so it can be tested.
This commit is contained in:
Arkadiusz Fal
2026-09-05 23:49:10 +02:00
parent 6bf3942529
commit 3dc129dcfa
3 changed files with 109 additions and 13 deletions

View File

@@ -22,11 +22,44 @@ enum FeedLoadState: Equatable {
case partiallyLoaded(readyCount: Int, pendingCount: Int, errorCount: Int)
case error(FeedLoadError)
/// The feed error worth showing in this state, if any.
///
/// While the context banner is up, only the errors it already answers for
/// stand down — the banner names their cause, so it is the one that stays.
/// Everything else keeps its own message and its retry: a local refresh
/// that failed on the network is news the banner does not carry, and
/// swallowing it leaves a stale feed with nothing to say and nothing to
/// press.
func displayedError(contextBannerVisible: Bool) -> FeedLoadError? {
guard case let .error(error) = self else { return nil }
guard !contextBannerVisible || !error.isAnsweredByContextBanner else { return nil }
return error
}
/// Specific error types for feed loading.
enum FeedLoadError: Equatable {
case yatteeServerRequired
case notAuthenticated
case networkError(String)
/// Whether a context banner, when one is up, already answers for this.
///
/// A selected account that cannot be used reads local data, and the
/// local feed then asks for the Yattee Server it has no reason to have
/// — "add a subscriptions account in Settings" under a banner saying
/// *this* account needs signing in again is two answers to one
/// question. These two are the account the banner is already talking
/// about; a refresh that failed on the way out is not, and has a retry
/// worth offering.
var isAnsweredByContextBanner: Bool {
switch self {
case .yatteeServerRequired, .notAuthenticated:
return true
case .networkError:
return false
}
}
}
}

View File

@@ -165,20 +165,10 @@ struct SubscriptionsView: View {
}
#endif
/// The feed error worth showing, if any.
///
/// Nothing while the context banner is up: a selected account that cannot be
/// used reads local data, and the local feed then reports the Yattee Server
/// it has no reason to have — "add a subscriptions account in Settings"
/// under a banner saying *this* account needs signing in again is two
/// answers to one question. The banner names the cause, so it is the one
/// that stays.
/// The feed error worth showing, if any — the banner takes over only for
/// the errors it is already the answer to.
private var displayedFeedError: FeedLoadState.FeedLoadError? {
guard contextUnavailability == nil,
case let .error(error) = feedCache.feedLoadState
else { return nil }
return error
feedCache.feedLoadState.displayedError(contextBannerVisible: contextUnavailability != nil)
}
private var isShowingFullScreenError: Bool {

View File

@@ -0,0 +1,73 @@
//
// SubscriptionFeedErrorTests.swift
// YatteeTests
//
// Tests for which feed errors the context banner speaks for.
//
import Testing
import Foundation
@testable import Yattee
@Suite("Subscription Feed Error Display")
struct SubscriptionFeedErrorTests {
// MARK: - Without a banner
@Test("Every feed error shows when no banner is up")
func everyErrorShowsWithoutBanner() {
let errors: [FeedLoadState.FeedLoadError] = [
.yatteeServerRequired,
.notAuthenticated,
.networkError("The Internet connection appears to be offline.")
]
for error in errors {
#expect(FeedLoadState.error(error).displayedError(contextBannerVisible: false) == error)
}
}
// MARK: - Under the banner
@Test("The Yattee Server the fallback asks for stands down under the banner")
func yatteeServerRequiredStandsDownUnderBanner() {
let state = FeedLoadState.error(.yatteeServerRequired)
#expect(state.displayedError(contextBannerVisible: true) == nil)
}
@Test("An account error stands down under the banner")
func notAuthenticatedStandsDownUnderBanner() {
let state = FeedLoadState.error(.notAuthenticated)
#expect(state.displayedError(contextBannerVisible: true) == nil)
}
@Test("A failed refresh keeps its error under the banner")
func networkErrorSurvivesTheBanner() {
let state = FeedLoadState.error(.networkError("The Internet connection appears to be offline."))
#expect(
state.displayedError(contextBannerVisible: true)
== .networkError("The Internet connection appears to be offline.")
)
}
// MARK: - Other states
@Test("States that are not errors have nothing to show")
func nonErrorStatesShowNothing() {
let states: [FeedLoadState] = [
.idle,
.loading,
.loadingMore,
.ready,
.partiallyLoaded(readyCount: 1, pendingCount: 2, errorCount: 0)
]
for state in states {
#expect(state.displayedError(contextBannerVisible: false) == nil)
#expect(state.displayedError(contextBannerVisible: true) == nil)
}
}
}