fix(healthchat): refresh the account status whenever settings appears

The banner kept telling the user to complete a profile they had just
completed, and nothing was fetched until the screen was actually shown.
This commit is contained in:
Sedat Qaja
2026-10-12 11:40:42 +02:00
parent d9524130fa
commit 1edfb8057f
6 changed files with 71 additions and 9 deletions
@@ -9,27 +9,37 @@ import Foundation
import Observation import Observation
/// Loads the own account's status for the banners that advertise it. /// Loads the own account's status for the banners that advertise it.
/// Nothing is fetched until ``load()`` is called, so the settings screen decides when.
@Observable @Observable
final class HealthChatAccountStatusLoader { final class HealthChatAccountStatusLoader {
private(set) var status: HealthChatAccountStatus = .unknown private(set) var status: HealthChatAccountStatus = .unknown
/// The load kicked off on creation — await this in tests before asserting. @ObservationIgnored private let apiService: HealthChatAPIServiceProtocol?
@ObservationIgnored private(set) var loadingTask: Task<Void, Never>?
/// The load in flight — await this in tests before asserting.
@ObservationIgnored @CancellableTask private(set) var loadingTask: Task<Void, Never>?
init(apiService: HealthChatAPIServiceProtocol) { init(apiService: HealthChatAPIServiceProtocol) {
loadingTask = Task { await load(from: apiService) } self.apiService = apiService
} }
/// Creates a loader with a fixed status, for previews. /// Creates a loader with a fixed status, for previews.
init(status: HealthChatAccountStatus) { init(status: HealthChatAccountStatus) {
self.status = status self.status = status
apiService = nil
}
/// Fetches the status again, replacing a load still in flight.
func load() {
guard let apiService else { return }
loadingTask = Task { await load(from: apiService) }
} }
// MARK: - Private // MARK: - Private
private func load(from apiService: HealthChatAPIServiceProtocol) async { private func load(from apiService: HealthChatAPIServiceProtocol) async {
// Every failure below leaves the status unknown: a banner must not report an error. // Every failure below leaves the status untouched: a banner must not report an error.
guard case let .success(profile) = await apiService.ownProfile() else { guard case let .success(profile) = await apiService.ownProfile(), !Task.isCancelled else {
return return
} }
@@ -38,7 +48,7 @@ final class HealthChatAccountStatusLoader {
return return
} }
guard case let .success(completeness) = await apiService.profileCompleteness() else { guard case let .success(completeness) = await apiService.profileCompleteness(), !Task.isCancelled else {
return return
} }
@@ -13,8 +13,6 @@ import SwiftUI
/// ///
/// Neither banner is dismissible — both resolve server-side. /// Neither banner is dismissible — both resolve server-side.
struct HealthChatAccountStatusSection: View { struct HealthChatAccountStatusSection: View {
// Owned by the hook, not the view: the settings screen rebuilds this section on
// every body pass, and a view-owned loader would fire a request each time.
private let loader: HealthChatAccountStatusLoader private let loader: HealthChatAccountStatusLoader
private let completeProfileAction: () -> Void private let completeProfileAction: () -> Void
@@ -12,7 +12,8 @@ import SwiftUI
// service is main-actor state — the hook's requirements are all @MainActor anyway. // service is main-actor state — the hook's requirements are all @MainActor anyway.
@MainActor @MainActor
final class HealthChatSettingsScreenHook: SettingsScreenHookProtocol { final class HealthChatSettingsScreenHook: SettingsScreenHookProtocol {
/// One load per session — the settings screen asks for this section on every body pass. // Owned here rather than by the section: the settings screen asks for the section on
// every body pass. Nothing is fetched until the screen appears and asks for a refresh.
private var statusLoader: HealthChatAccountStatusLoader? private var statusLoader: HealthChatAccountStatusLoader?
// Constructed during hook registration, before the main actor is reachable. // Constructed during hook registration, before the main actor is reachable.
@@ -32,5 +33,9 @@ final class HealthChatSettingsScreenHook: SettingsScreenHookProtocol {
return AnyView(HealthChatAccountStatusSection(loader: statusLoader, return AnyView(HealthChatAccountStatusSection(loader: statusLoader,
completeProfileAction: completeProfileAction)) completeProfileAction: completeProfileAction))
} }
func refreshAccountStatus() {
statusLoader?.load()
}
} }
#endif #endif
@@ -14,6 +14,9 @@ nonisolated protocol SettingsScreenHookProtocol: Sendable {
/// advertising whatever is holding the account back. /// advertising whatever is holding the account back.
/// - Parameter completeProfileAction: Opens the own profile editor. /// - Parameter completeProfileAction: Opens the own profile editor.
@MainActor func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? @MainActor func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView?
/// Called whenever the settings screen appears, so the section reflects what the user just did.
@MainActor func refreshAccountStatus()
} }
struct DefaultSettingsScreenHook: SettingsScreenHookProtocol { struct DefaultSettingsScreenHook: SettingsScreenHookProtocol {
@@ -22,4 +25,6 @@ struct DefaultSettingsScreenHook: SettingsScreenHookProtocol {
func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? { func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? {
nil nil
} }
func refreshAccountStatus() { }
} }
@@ -50,6 +50,7 @@ struct SettingsScreen: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarVisibility(context.viewState.navigationBarVisibility, for: .navigationBar) .toolbarVisibility(context.viewState.navigationBarVisibility, for: .navigationBar)
.toolbar { toolbar } .toolbar { toolbar }
.onAppear { context.viewState.appHooks.settingsScreenHook.refreshAccountStatus() }
.sheet(isPresented: $context.isPresentingStatusPicker) { .sheet(isPresented: $context.isPresentingStatusPicker) {
SettingsScreenUserStatusPickerView { action in SettingsScreenUserStatusPickerView { action in
context.send(viewAction: .userStatus(action)) context.send(viewAction: .userStatus(action))
@@ -60,10 +60,53 @@ final class HealthChatAccountStatusLoaderTests {
#expect(await status() == .unknown) #expect(await status() == .unknown)
} }
@Test
func fetchesNothingUntilAsked() throws {
apiService.ownProfileReturnValue = try .success(makeMember(isApproved: false))
let loader = HealthChatAccountStatusLoader(apiService: apiService)
#expect(loader.status == .unknown)
#expect(apiService.ownProfileCallsCount == 0)
}
@Test
func loadingAgainReflectsTheNewServerState() async throws {
apiService.ownProfileReturnValue = try .success(makeMember(isApproved: true))
apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: false,
message: "Profil unvollständig"))
let loader = HealthChatAccountStatusLoader(apiService: apiService)
loader.load()
await loader.loadingTask?.value
#expect(loader.status == .incompleteProfile(message: "Profil unvollständig"))
apiService.profileCompletenessReturnValue = try .success(makeCompleteness(isComplete: true, message: nil))
loader.load()
await loader.loadingTask?.value
#expect(loader.status == .ok)
}
@Test
func aFailedReloadKeepsTheLastKnownStatus() async throws {
apiService.ownProfileReturnValue = try .success(makeMember(isApproved: false))
let loader = HealthChatAccountStatusLoader(apiService: apiService)
loader.load()
await loader.loadingTask?.value
#expect(loader.status == .pendingApproval)
apiService.ownProfileReturnValue = .failure(.networkFailure)
loader.load()
await loader.loadingTask?.value
#expect(loader.status == .pendingApproval)
}
// MARK: - Helpers // MARK: - Helpers
private func status() async -> HealthChatAccountStatus { private func status() async -> HealthChatAccountStatus {
let loader = HealthChatAccountStatusLoader(apiService: apiService) let loader = HealthChatAccountStatusLoader(apiService: apiService)
loader.load()
await loader.loadingTask?.value await loader.loadingTask?.value
return loader.status return loader.status
} }