diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusLoader.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusLoader.swift index 154239fb0..8304c32f3 100644 --- a/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusLoader.swift +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusLoader.swift @@ -9,27 +9,37 @@ import Foundation import Observation /// 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 final class HealthChatAccountStatusLoader { private(set) var status: HealthChatAccountStatus = .unknown - /// The load kicked off on creation — await this in tests before asserting. - @ObservationIgnored private(set) var loadingTask: Task? + @ObservationIgnored private let apiService: HealthChatAPIServiceProtocol? + + /// The load in flight — await this in tests before asserting. + @ObservationIgnored @CancellableTask private(set) var loadingTask: Task? init(apiService: HealthChatAPIServiceProtocol) { - loadingTask = Task { await load(from: apiService) } + self.apiService = apiService } /// Creates a loader with a fixed status, for previews. init(status: HealthChatAccountStatus) { 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 private func load(from apiService: HealthChatAPIServiceProtocol) async { - // Every failure below leaves the status unknown: a banner must not report an error. - guard case let .success(profile) = await apiService.ownProfile() else { + // Every failure below leaves the status untouched: a banner must not report an error. + guard case let .success(profile) = await apiService.ownProfile(), !Task.isCancelled else { return } @@ -38,7 +48,7 @@ final class HealthChatAccountStatusLoader { return } - guard case let .success(completeness) = await apiService.profileCompleteness() else { + guard case let .success(completeness) = await apiService.profileCompleteness(), !Task.isCancelled else { return } diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusSection.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusSection.swift index 2c3b4d75e..e081edeff 100644 --- a/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusSection.swift +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatAccountStatusSection.swift @@ -13,8 +13,6 @@ import SwiftUI /// /// Neither banner is dismissible — both resolve server-side. 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 completeProfileAction: () -> Void diff --git a/ElementX/Sources/AppHooks/HealthChat/HealthChatSettingsScreenHook.swift b/ElementX/Sources/AppHooks/HealthChat/HealthChatSettingsScreenHook.swift index d4cca80b4..fa9a3d4fe 100644 --- a/ElementX/Sources/AppHooks/HealthChat/HealthChatSettingsScreenHook.swift +++ b/ElementX/Sources/AppHooks/HealthChat/HealthChatSettingsScreenHook.swift @@ -12,7 +12,8 @@ import SwiftUI // service is main-actor state — the hook's requirements are all @MainActor anyway. @MainActor 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? // Constructed during hook registration, before the main actor is reachable. @@ -32,5 +33,9 @@ final class HealthChatSettingsScreenHook: SettingsScreenHookProtocol { return AnyView(HealthChatAccountStatusSection(loader: statusLoader, completeProfileAction: completeProfileAction)) } + + func refreshAccountStatus() { + statusLoader?.load() + } } #endif diff --git a/ElementX/Sources/AppHooks/Hooks/SettingsScreenHook.swift b/ElementX/Sources/AppHooks/Hooks/SettingsScreenHook.swift index e176150e7..9685c4d7b 100644 --- a/ElementX/Sources/AppHooks/Hooks/SettingsScreenHook.swift +++ b/ElementX/Sources/AppHooks/Hooks/SettingsScreenHook.swift @@ -14,6 +14,9 @@ nonisolated protocol SettingsScreenHookProtocol: Sendable { /// advertising whatever is holding the account back. /// - Parameter completeProfileAction: Opens the own profile editor. @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 { @@ -22,4 +25,6 @@ struct DefaultSettingsScreenHook: SettingsScreenHookProtocol { func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? { nil } + + func refreshAccountStatus() { } } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift index dee5164db..e336ba9e0 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift @@ -50,6 +50,7 @@ struct SettingsScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbarVisibility(context.viewState.navigationBarVisibility, for: .navigationBar) .toolbar { toolbar } + .onAppear { context.viewState.appHooks.settingsScreenHook.refreshAccountStatus() } .sheet(isPresented: $context.isPresentingStatusPicker) { SettingsScreenUserStatusPickerView { action in context.send(viewAction: .userStatus(action)) diff --git a/UnitTests/Sources/HealthChat/HealthChatAccountStatusLoaderTests.swift b/UnitTests/Sources/HealthChat/HealthChatAccountStatusLoaderTests.swift index af22fa22a..859b6f676 100644 --- a/UnitTests/Sources/HealthChat/HealthChatAccountStatusLoaderTests.swift +++ b/UnitTests/Sources/HealthChat/HealthChatAccountStatusLoaderTests.swift @@ -60,10 +60,53 @@ final class HealthChatAccountStatusLoaderTests { #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 private func status() async -> HealthChatAccountStatus { let loader = HealthChatAccountStatusLoader(apiService: apiService) + loader.load() await loader.loadingTask?.value return loader.status }