mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
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:
@@ -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<Void, Never>?
|
||||
@ObservationIgnored private let apiService: HealthChatAPIServiceProtocol?
|
||||
|
||||
/// The load in flight — await this in tests before asserting.
|
||||
@ObservationIgnored @CancellableTask private(set) var loadingTask: Task<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user