diff --git a/Anytype/Sources/PresentationLayer/Debug/Views/MembershipDebugView.swift b/Anytype/Sources/PresentationLayer/Debug/Views/MembershipDebugView.swift index 5ef1963ac4..147b0a76ef 100644 --- a/Anytype/Sources/PresentationLayer/Debug/Views/MembershipDebugView.swift +++ b/Anytype/Sources/PresentationLayer/Debug/Views/MembershipDebugView.swift @@ -21,6 +21,7 @@ struct MembershipDebugView: View { @State private var transactions: [StoreKit.Transaction] = [] @State private var toastBarData: ToastBarData? + @State private var currentStatus: MembershipStatus = .empty @State private var refundId: StoreKit.Transaction.ID? @State private var showRefund = false @State private var showMembership = false @@ -33,29 +34,35 @@ struct MembershipDebugView: View { transactionsView } .frame(maxWidth: .infinity) - .background(storage.currentStatus.tier?.gradient.ignoresSafeArea()) + .background(currentStatus.tier?.gradient.ignoresSafeArea()) .snackbar(toastBarData: $toastBarData) .refundRequestSheet(for: refundId ?? 0, isPresented: $showRefund) - .sheet(isPresented: $showMembership) { + .sheet(isPresented: $showMembership) { MembershipCoordinator() } .task { + currentStatus = await storage.currentStatus() await loadTiers() await loadTransactions() } + .task { + for await status in storage.statusStream() { + currentStatus = status + } + } } @MainActor private var membershipInfo: some View { VStack(alignment: .center) { AnytypeText("Current tier", style: .heading) - if let mediumIcon = storage.currentStatus.tier?.mediumIcon { + if let mediumIcon = currentStatus.tier?.mediumIcon { Image(asset: mediumIcon) } - AnytypeText(storage.currentStatus.debugDescription, style: .codeBlock) + AnytypeText(currentStatus.debugDescription, style: .codeBlock) Spacer() } .padding() diff --git a/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinator.swift b/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinator.swift index afcb7c0052..7af4101180 100644 --- a/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinator.swift +++ b/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinator.swift @@ -49,7 +49,7 @@ struct MembershipCoordinator: View { style: .error, buttonData: EmptyStateView.ButtonData( title: Loc.tryAgain, - action: { model.loadTiers() } + action: { model.retryLoadTiers() } ) ) } diff --git a/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift b/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift index d13dc2d52f..a3909b29e4 100644 --- a/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift +++ b/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift @@ -14,24 +14,75 @@ final class MembershipCoordinatorModel: ObservableObject { @Published var fireConfetti = false @Published var emailUrl: URL? - @Injected(\.membershipService) - private var membershipService: any MembershipServiceProtocol @Injected(\.membershipStatusStorage) private var membershipStatusStorage: any MembershipStatusStorageProtocol @Injected(\.accountManager) private var accountManager: any AccountManagerProtocol private let initialTierId: Int? - + private var statusTask: Task? + private var tiersTask: Task? + init(initialTierId: Int?) { self.initialTierId = initialTierId - membershipStatusStorage.statusPublisher.receiveOnMain().assign(to: &$userMembership) + + statusTask = Task { [weak self] in + guard let self else { return } + for await status in membershipStatusStorage.statusStream() { + self.userMembership = status + } + } + + tiersTask = Task { [weak self] in + guard let self else { return } + for await (status, allTiers) in self.combinedStream() { + let currentTierId = status.tier?.type.id ?? 0 + self.tiers = allTiers + .filter { FeatureFlags.membershipTestTiers || !$0.isTest } + .filter { !$0.iosProductID.isEmpty || $0.type.id == currentTierId } + } + } + } + + deinit { + statusTask?.cancel() + tiersTask?.cancel() + } + + private func combinedStream() -> AsyncStream<(MembershipStatus, [MembershipTier])> { + let storage = membershipStatusStorage + return AsyncStream { continuation in + let task = Task { + var currentStatus = await storage.currentStatus() + var currentTiers = await storage.currentTiers() + + continuation.yield((currentStatus, currentTiers)) + + await withTaskGroup(of: Void.self) { group in + group.addTask { + for await status in storage.statusStream() { + currentStatus = status + continuation.yield((currentStatus, currentTiers)) + } + } + + group.addTask { + for await tiers in storage.tiersStream() { + currentTiers = tiers + continuation.yield((currentStatus, currentTiers)) + } + } + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } } func onAppear() { Task { - await loadTiers() - guard let initialTierId else { return } guard let initialTier = tiers.first(where: { $0.type.id == initialTierId }) else { anytypeAssertionFailure("Not found initial id for Memberhsip coordinator", info: ["tierId": String(initialTierId)]) @@ -40,17 +91,15 @@ final class MembershipCoordinatorModel: ObservableObject { onTierSelected(tier: initialTier) } } - - func loadTiers(noCache: Bool = false) { - Task { await loadTiers(noCache: noCache) } - } - - private func loadTiers(noCache: Bool = false) async { - do { - tiers = try await membershipService.getTiers(noCache: noCache) - showTiersLoadingError = false - } catch { - showTiersLoadingError = true + + func retryLoadTiers() { + Task { + do { + try await membershipStatusStorage.refreshMembership() + showTiersLoadingError = false + } catch { + showTiersLoadingError = true + } } } @@ -64,14 +113,14 @@ final class MembershipCoordinatorModel: ObservableObject { private func showSuccessScreen(tier: MembershipTier) { showTier = nil - loadTiers(noCache: true) - Task { + try? await membershipStatusStorage.refreshMembership() + // https://linear.app/anytype/issue/IOS-2434/bottom-sheet-nesting try await Task.sleep(seconds: 0.5) showSuccess = tier - try await Task.sleep(seconds:0.5) + try await Task.sleep(seconds: 0.5) UINotificationFeedbackGenerator().notificationOccurred(.success) fireConfetti = true } diff --git a/Anytype/Sources/PresentationLayer/Flows/SpaceHub/SpaceHubCoordinatorViewModel.swift b/Anytype/Sources/PresentationLayer/Flows/SpaceHub/SpaceHubCoordinatorViewModel.swift index 00518fcdde..fffca38d2c 100644 --- a/Anytype/Sources/PresentationLayer/Flows/SpaceHub/SpaceHubCoordinatorViewModel.swift +++ b/Anytype/Sources/PresentationLayer/Flows/SpaceHub/SpaceHubCoordinatorViewModel.swift @@ -201,7 +201,7 @@ final class SpaceHubCoordinatorViewModel: SpaceHubModuleOutput { func startHandleMembershipStatus() async { for await membership in Container.shared.membershipStatusStorage.resolve() - .statusPublisher.values { + .statusStream() { guard membership.status == .pendingRequiresFinalization else { continue } membershipNameFinalizationData = membership.tier diff --git a/Anytype/Sources/PresentationLayer/Membership/MembershipUpgradeViewModifier.swift b/Anytype/Sources/PresentationLayer/Membership/MembershipUpgradeViewModifier.swift index 0ab012fd4b..2e2060497c 100644 --- a/Anytype/Sources/PresentationLayer/Membership/MembershipUpgradeViewModifier.swift +++ b/Anytype/Sources/PresentationLayer/Membership/MembershipUpgradeViewModifier.swift @@ -13,10 +13,10 @@ final class MembershipUpgradeViewModifierModel: ObservableObject { nonisolated init() { } - func updateState(reason: MembershipUpgradeReason?) { + func updateState(reason: MembershipUpgradeReason?) async { guard let reason else { return } - guard let currentTier = statusStorage.currentStatus.tier else { return } + guard let currentTier = await statusStorage.currentStatus().tier else { return } if accountManager.account.allowMembership && currentTier.isPossibleToUpgrade(reason: reason) { showMembershipScreen = true } else { @@ -56,11 +56,13 @@ struct MembershipUpgradeViewModifier: ViewModifier { } }) - .onAppear { - model.updateState(reason: reason) + .task { + await model.updateState(reason: reason) } .onChange(of: reason) { _, reason in - model.updateState(reason: reason) + Task { + await model.updateState(reason: reason) + } } } } diff --git a/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipPricingView.swift b/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipPricingView.swift index 6a6a82b193..aa5fc8a48f 100644 --- a/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipPricingView.swift +++ b/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipPricingView.swift @@ -19,12 +19,12 @@ struct MembershipPricingView: View { AnytypeText(info.localizedPeriod ?? "", style: .caption1Regular) .foregroundColor(.Text.primary) case nil: - Rectangle().hidden().onAppear { + Rectangle().hidden().task { anytypeAssertionFailure( "No pricing view for empty payment info", info: [ "Tier": String(reflecting: tier), - "Status": String(reflecting: Container.shared.membershipStatusStorage.resolve().currentStatus) + "Status": String(reflecting: await Container.shared.membershipStatusStorage.resolve().currentStatus()) ] ) } diff --git a/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipTierView/MembershipTierView.swift b/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipTierView/MembershipTierView.swift index f69bd779d3..3e239d34d5 100644 --- a/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipTierView/MembershipTierView.swift +++ b/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipTierView/MembershipTierView.swift @@ -114,7 +114,7 @@ struct MembershipTierView: View { #Preview("No tier") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: nil, status: .pending) + MembershipStatusStorageMock.shared.setStatus(.mock(tier: nil, status: .pending)) } content: { HStack { MembershipTierView(tierToDisplay: .mockStarter, onTap: { }) @@ -129,7 +129,7 @@ struct MembershipTierView: View { #Preview("Pending starter") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: .mockStarter, status: .pending) + MembershipStatusStorageMock.shared.setStatus(.mock(tier: .mockStarter, status: .pending)) } content: { HStack { MembershipTierView(tierToDisplay: .mockStarter, onTap: { }) @@ -144,7 +144,7 @@ struct MembershipTierView: View { #Preview("Active starter") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: .mockStarter) + MembershipStatusStorageMock.shared.setStatus(.mock(tier: .mockStarter)) } content: { HStack { MembershipTierView(tierToDisplay: .mockStarter, onTap: { }) @@ -159,7 +159,7 @@ struct MembershipTierView: View { #Preview("Active builder") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: .mockBuilder) + MembershipStatusStorageMock.shared.setStatus(.mock(tier: .mockBuilder)) } content: { HStack { MembershipTierView(tierToDisplay: .mockStarter, onTap: { }) @@ -174,7 +174,7 @@ struct MembershipTierView: View { #Preview("Active custom") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: .mockCustom, paymentMethod: .methodCrypto) + MembershipStatusStorageMock.shared.setStatus(.mock(tier: .mockCustom, paymentMethod: .methodCrypto)) } content: { HStack { MembershipTierView(tierToDisplay: .mockStarter, onTap: { }) diff --git a/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipTierView/MembershipTierViewModel.swift b/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipTierView/MembershipTierViewModel.swift index 750ad43827..3573fee907 100644 --- a/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipTierView/MembershipTierViewModel.swift +++ b/Anytype/Sources/PresentationLayer/Modules/Membership/InitialScreen/Views/MembershipTierView/MembershipTierViewModel.swift @@ -13,7 +13,8 @@ final class MembershipTierViewModel: ObservableObject { @Injected(\.membershipMetadataProvider) private var tierMetadataProvider: any MembershipMetadataProviderProtocol - + private var statusTask: Task? + init( tierToDisplay: MembershipTier, onTap: @escaping () -> Void @@ -22,9 +23,18 @@ final class MembershipTierViewModel: ObservableObject { self.onTap = onTap let storage = Container.shared.membershipStatusStorage.resolve() - storage.statusPublisher.receiveOnMain().assign(to: &$userMembership) + statusTask = Task { [weak self] in + guard let self else { return } + for await status in storage.statusStream() { + self.userMembership = status + } + } } + deinit { + statusTask?.cancel() + } + func updateState() { Task { state = await tierMetadataProvider.owningState(tier: tierToDisplay) diff --git a/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift b/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift index e5228745fa..87260c31f8 100644 --- a/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift +++ b/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift @@ -15,7 +15,9 @@ extension MembershipTier { Loc.Membership.Feature.viewers(3) ], paymentType: nil, - color: .green + color: .green, + isTest: false, + iosProductID: "" ) } @@ -32,7 +34,9 @@ extension MembershipTier { Loc.Membership.Feature.viewers(999) ], paymentType: .mockExternal, - color: .blue + color: .blue, + isTest: false, + iosProductID: "io.anytype.membership.builder" ) } @@ -49,7 +53,9 @@ extension MembershipTier { Loc.Membership.Feature.viewers(999) ], paymentType: .mockExternal, - color: .red + color: .red, + isTest: false, + iosProductID: "io.anytype.membership.cocreator" ) } @@ -66,7 +72,9 @@ extension MembershipTier { Loc.Membership.Feature.viewers(999) ], paymentType: .mockExternal, - color: .purple + color: .purple, + isTest: false, + iosProductID: "io.anytype.membership.custom" ) } @@ -83,7 +91,9 @@ extension MembershipTier { Loc.Membership.Feature.viewers(999) ], paymentType: .mockExternal, - color: .blue + color: .blue, + isTest: true, + iosProductID: "io.anytype.membership.builder.test" ) } } diff --git a/Anytype/Sources/PresentationLayer/Modules/Membership/TierSelection/OwnershipView/MembershipOwnerInfoSheetView.swift b/Anytype/Sources/PresentationLayer/Modules/Membership/TierSelection/OwnershipView/MembershipOwnerInfoSheetView.swift index 85ce8c8f14..a955e42a9c 100644 --- a/Anytype/Sources/PresentationLayer/Modules/Membership/TierSelection/OwnershipView/MembershipOwnerInfoSheetView.swift +++ b/Anytype/Sources/PresentationLayer/Modules/Membership/TierSelection/OwnershipView/MembershipOwnerInfoSheetView.swift @@ -148,7 +148,7 @@ struct MembershipOwnerInfoSheetView: View { #Preview("Starter without email") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: .mockStarter, email: "") + MembershipStatusStorageMock.shared.setStatus(.mock(tier: .mockStarter, email: "")) } content: { MembershipOwnerInfoSheetView() } @@ -158,7 +158,7 @@ struct MembershipOwnerInfoSheetView: View { #Preview("Starter with email") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: .mockStarter, email: "vo@va.com") + MembershipStatusStorageMock.shared.setStatus(.mock(tier: .mockStarter, email: "vo@va.com")) } content: { MembershipOwnerInfoSheetView() } @@ -169,7 +169,7 @@ struct MembershipOwnerInfoSheetView: View { #Preview("Stripe builder") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: .mockBuilder, paymentMethod: .methodStripe) + MembershipStatusStorageMock.shared.setStatus(.mock(tier: .mockBuilder, paymentMethod: .methodStripe)) } content: { MembershipOwnerInfoSheetView() } @@ -179,7 +179,7 @@ struct MembershipOwnerInfoSheetView: View { #Preview("InApp CockReator") { ScrollView(.horizontal) { MockView { - MembershipStatusStorageMock.shared._status = .mock(tier: .mockCoCreator, paymentMethod: .methodInappApple) + MembershipStatusStorageMock.shared.setStatus(.mock(tier: .mockCoCreator, paymentMethod: .methodInappApple)) } content: { MembershipOwnerInfoSheetView() } diff --git a/Anytype/Sources/PresentationLayer/Modules/Membership/TierSelection/OwnershipView/MembershipOwnerInfoSheetViewModel.swift b/Anytype/Sources/PresentationLayer/Modules/Membership/TierSelection/OwnershipView/MembershipOwnerInfoSheetViewModel.swift index 55a20af236..e2bbb4a1db 100644 --- a/Anytype/Sources/PresentationLayer/Modules/Membership/TierSelection/OwnershipView/MembershipOwnerInfoSheetViewModel.swift +++ b/Anytype/Sources/PresentationLayer/Modules/Membership/TierSelection/OwnershipView/MembershipOwnerInfoSheetViewModel.swift @@ -26,10 +26,20 @@ final class MembershipOwnerInfoSheetViewModel: ObservableObject { private var membershipService: any MembershipServiceProtocol @Injected(\.membershipMetadataProvider) private var metadataProvider: any MembershipMetadataProviderProtocol - + private var statusTask: Task? + init() { let storage = Container.shared.membershipStatusStorage.resolve() - storage.statusPublisher.receiveOnMain().assign(to: &$membership) + statusTask = Task { [weak self] in + guard let self else { return } + for await status in storage.statusStream() { + self.membership = status + } + } + } + + deinit { + statusTask?.cancel() } func updateState() { diff --git a/Anytype/Sources/PresentationLayer/Settings/Settings/SettingsViewModel.swift b/Anytype/Sources/PresentationLayer/Settings/Settings/SettingsViewModel.swift index 3bab86009f..9e44323833 100644 --- a/Anytype/Sources/PresentationLayer/Settings/Settings/SettingsViewModel.swift +++ b/Anytype/Sources/PresentationLayer/Settings/Settings/SettingsViewModel.swift @@ -114,7 +114,7 @@ final class SettingsViewModel: ObservableObject { // MARK: - Private private func membershipSubscriotion() async { - for await newMembership in membershipStatusStorage.statusPublisher.values { + for await newMembership in membershipStatusStorage.statusStream() { membership = newMembership } } diff --git a/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift b/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift index 335b824943..74dd4d9f92 100644 --- a/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift +++ b/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift @@ -1,27 +1,122 @@ import Services import Foundation -import Combine -final class MembershipStatusStorageMock: MembershipStatusStorageProtocol { +actor MembershipStatusStorageMock: MembershipStatusStorageProtocol { nonisolated static let shared = MembershipStatusStorageMock() nonisolated init() {} - @Published var _status: MembershipStatus = .empty - var statusPublisher: AnyPublisher { $_status.eraseToAnyPublisher() } - var currentStatus: MembershipStatus { _status } - + private var _status: MembershipStatus = .empty + private var _tiers: [MembershipTier] = [] + + private var statusContinuations: [UUID: AsyncStream.Continuation] = [:] + private var tiersContinuations: [UUID: AsyncStream<[MembershipTier]>.Continuation] = [:] + + nonisolated func statusStream() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + Task { + await self.addStatusContinuation(id: id, continuation: continuation) + continuation.yield(await self._status) + } + + continuation.onTermination = { _ in + Task { + await self.removeStatusContinuation(id: id) + } + } + } + } + + func currentStatus() async -> MembershipStatus { + _status + } + + nonisolated func tiersStream() -> AsyncStream<[MembershipTier]> { + AsyncStream { continuation in + let id = UUID() + Task { + await self.addTiersContinuation(id: id, continuation: continuation) + continuation.yield(await self._tiers) + } + + continuation.onTermination = { _ in + Task { + await self.removeTiersContinuation(id: id) + } + } + } + } + + func currentTiers() async -> [MembershipTier] { + _tiers + } + func owningState(tier: Services.MembershipTier) -> MembershipTierOwningState { .owned(.purchasedElsewhere(.desktop)) } - + func startSubscription() async { - + } - + + func refreshMembership() async throws { + + } + func stopSubscriptionAndClean() async { - + + } + + nonisolated func setStatus(_ status: MembershipStatus) { + Task { + await _setStatus(status) + } + } + + private func _setStatus(_ status: MembershipStatus) { + _status = status + yieldStatus(status) + } + + nonisolated func setTiers(_ tiers: [MembershipTier]) { + Task { + await _setTiers(tiers) + } + } + + private func _setTiers(_ tiers: [MembershipTier]) { + _tiers = tiers + yieldTiers(tiers) + } + + private func addStatusContinuation(id: UUID, continuation: AsyncStream.Continuation) { + statusContinuations[id] = continuation + } + + private func removeStatusContinuation(id: UUID) { + statusContinuations.removeValue(forKey: id) + } + + private func addTiersContinuation(id: UUID, continuation: AsyncStream<[MembershipTier]>.Continuation) { + tiersContinuations[id] = continuation + } + + private func removeTiersContinuation(id: UUID) { + tiersContinuations.removeValue(forKey: id) + } + + private func yieldStatus(_ status: MembershipStatus) { + for continuation in statusContinuations.values { + continuation.yield(status) + } + } + + private func yieldTiers(_ tiers: [MembershipTier]) { + for continuation in tiersContinuations.values { + continuation.yield(tiers) + } } } diff --git a/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift b/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift index a1cfc4ecce..11e9c6be18 100644 --- a/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift +++ b/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift @@ -2,53 +2,147 @@ import Foundation import ProtobufMessages import Combine import Services +import AnytypeCore -@MainActor -protocol MembershipStatusStorageProtocol: Sendable { - var statusPublisher: AnyPublisher { get } - var currentStatus: MembershipStatus { get } - +protocol MembershipStatusStorageProtocol: Sendable, Actor { + nonisolated func statusStream() -> AsyncStream + func currentStatus() async -> MembershipStatus + nonisolated func tiersStream() -> AsyncStream<[MembershipTier]> + func currentTiers() async -> [MembershipTier] + func startSubscription() async func stopSubscriptionAndClean() async + func refreshMembership() async throws } -@MainActor -final class MembershipStatusStorage: MembershipStatusStorageProtocol { +actor MembershipStatusStorage: MembershipStatusStorageProtocol { @Injected(\.membershipService) private var membershipService: any MembershipServiceProtocol @Injected(\.membershipModelBuilder) private var builder: any MembershipModelBuilderProtocol - - - var statusPublisher: AnyPublisher { $_status.eraseToAnyPublisher() } - var currentStatus: MembershipStatus { _status } - @Published private var _status: MembershipStatus = .empty - + + private var _status: MembershipStatus = .empty + private var _tiers: [MembershipTier] = [] + + private var statusContinuations: [UUID: AsyncStream.Continuation] = [:] + private var tiersContinuations: [UUID: AsyncStream<[MembershipTier]>.Continuation] = [:] + private var subscription: AnyCancellable? - + private var tiersUpdateTask: Task? + private var membershipUpdateTask: Task? + nonisolated init() { } + + nonisolated func statusStream() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + Task { + await self.addStatusContinuation(id: id, continuation: continuation) + continuation.yield(await self._status) + } + + continuation.onTermination = { _ in + Task { + await self.removeStatusContinuation(id: id) + } + } + } + } + + func currentStatus() async -> MembershipStatus { + _status + } + + nonisolated func tiersStream() -> AsyncStream<[MembershipTier]> { + AsyncStream { continuation in + let id = UUID() + Task { + await self.addTiersContinuation(id: id, continuation: continuation) + continuation.yield(await self._tiers) + } + + continuation.onTermination = { _ in + Task { + await self.removeTiersContinuation(id: id) + } + } + } + } + + func currentTiers() async -> [MembershipTier] { + _tiers + } + + private func addStatusContinuation(id: UUID, continuation: AsyncStream.Continuation) { + statusContinuations[id] = continuation + } + + private func removeStatusContinuation(id: UUID) { + statusContinuations.removeValue(forKey: id) + } + + private func addTiersContinuation(id: UUID, continuation: AsyncStream<[MembershipTier]>.Continuation) { + tiersContinuations[id] = continuation + } + + private func removeTiersContinuation(id: UUID) { + tiersContinuations.removeValue(forKey: id) + } + + private func yieldStatus(_ status: MembershipStatus) { + for continuation in statusContinuations.values { + continuation.yield(status) + } + } + + private func yieldTiers(_ tiers: [MembershipTier]) { + for continuation in tiersContinuations.values { + continuation.yield(tiers) + } + } func startSubscription() async { - _status = (try? await membershipService.getMembership(noCache: true)) ?? .empty + _status = (try? await membershipService.getMembership(noCache: false)) ?? .empty + _tiers = (try? await membershipService.getTiers(noCache: false)) ?? [] AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier) - + + yieldStatus(_status) + yieldTiers(_tiers) + setupSubscription() } - + func stopSubscriptionAndClean() async { subscription = nil + tiersUpdateTask?.cancel() + tiersUpdateTask = nil + membershipUpdateTask?.cancel() + membershipUpdateTask = nil _status = .empty + _tiers = [] + + yieldStatus(_status) + yieldTiers(_tiers) + AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier) } - + + func refreshMembership() async throws { + _status = try await membershipService.getMembership(noCache: true) + _tiers = try await membershipService.getTiers(noCache: true) + + yieldStatus(_status) + yieldTiers(_tiers) + + AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier) + } + // MARK: - Private - - private func setupSubscription() { + + private func setupSubscription() { subscription = EventBunchSubscribtion.default.addHandler { [weak self] events in - Task { @MainActor [weak self] in - self?.handle(events: events) - } + await self?.handle(events: events) } } @@ -56,17 +150,60 @@ final class MembershipStatusStorage: MembershipStatusStorageProtocol { for event in events.middlewareEvents { switch event.value { case .membershipUpdate(let update): - Task { - let allTiers = try await membershipService.getTiers() - - _status = try builder.buildMembershipStatus(membership: update.data, allTiers: allTiers) - _status.tier.flatMap { AnytypeAnalytics.instance().logChangePlan(tier: $0) } - - AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier) + membershipUpdateTask?.cancel() + membershipUpdateTask = Task { [weak self, builder] in + guard let self else { return } + guard await !self._tiers.isEmpty else { + return + } + + if Task.isCancelled { return } + + do { + let status = try await builder.buildMembershipStatus( + membership: update.data, + allTiers: await self._tiers + ) + + if Task.isCancelled { return } + + await self.updateStatus(status) + status.tier.flatMap { AnytypeAnalytics.instance().logChangePlan(tier: $0) } + AnytypeAnalytics.instance().setMembershipTier(tier: status.tier) + } catch { + print("[Membership] Failed to build status: \(error)") + } } + + case .membershipTiersUpdate(let update): + tiersUpdateTask?.cancel() + tiersUpdateTask = Task { [weak self, builder] in + var built: [MembershipTier] = [] + for tier in update.tiers { + if Task.isCancelled { return } + if let builtTier = await builder.buildMembershipTier(tier: tier) { + built.append(builtTier) + } + } + + if Task.isCancelled { return } + + await self?.updateTiers(built) + } + default: break } } } + + private func updateStatus(_ status: MembershipStatus) { + _status = status + yieldStatus(status) + } + + private func updateTiers(_ tiers: [MembershipTier]) { + _tiers = tiers + yieldTiers(tiers) + } } diff --git a/Anytype/Sources/ServiceLayer/MembershipTierInfoProvider/MembershipMetadataProvider.swift b/Anytype/Sources/ServiceLayer/MembershipTierInfoProvider/MembershipMetadataProvider.swift index c66cc9cdd7..8897f87ec1 100644 --- a/Anytype/Sources/ServiceLayer/MembershipTierInfoProvider/MembershipMetadataProvider.swift +++ b/Anytype/Sources/ServiceLayer/MembershipTierInfoProvider/MembershipMetadataProvider.swift @@ -15,8 +15,8 @@ final class MembershipMetadataProvider: MembershipMetadataProviderProtocol, Send private let storeKitService: any StoreKitServiceProtocol = Container.shared.storeKitService() func owningState(tier: MembershipTier) async -> MembershipTierOwningState { - let status = await storage.currentStatus + let status = await storage.currentStatus() if status.tier?.type == tier.type { if status.status == .active { let purchaseType = await purchaseType(status: status) diff --git a/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift b/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift index 60265f1887..e2caf09ae8 100644 --- a/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift +++ b/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift @@ -62,7 +62,9 @@ final class MembershipModelBuilder: MembershipModelBuilderProtocol { anyName: anyName, features: tier.features, paymentType: paymentType, - color: MembershipColor(string: tier.colorStr) + color: MembershipColor(string: tier.colorStr), + isTest: tier.isTest, + iosProductID: tier.iosProductID ) } diff --git a/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift b/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift index 1c4253c839..e5ad53b94f 100644 --- a/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift +++ b/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift @@ -102,7 +102,9 @@ public struct MembershipTier: Hashable, Identifiable, Equatable, Sendable { public let features: [String] public let paymentType: MembershipTierPaymentType? public let color: MembershipColor - + public let isTest: Bool + public let iosProductID: String + public var id: MembershipTierType { type } public init( @@ -112,7 +114,9 @@ public struct MembershipTier: Hashable, Identifiable, Equatable, Sendable { anyName: MembershipAnyName, features: [String], paymentType: MembershipTierPaymentType?, - color: MembershipColor + color: MembershipColor, + isTest: Bool, + iosProductID: String ) { self.type = type self.name = name @@ -121,5 +125,7 @@ public struct MembershipTier: Hashable, Identifiable, Equatable, Sendable { self.features = features self.paymentType = paymentType self.color = color + self.isTest = isTest + self.iosProductID = iosProductID } }