From 142bf39eca3aa1fa0a5a9d64619123b62f396efd Mon Sep 17 00:00:00 2001 From: Roman Khafizianov Date: Fri, 17 Oct 2025 15:27:46 +0200 Subject: [PATCH 1/2] IOS-5365 implement the async membership handling refactor the membership storage and event handling --- .../MembershipCoordinator.swift | 2 +- .../MembershipCoordinatorModel.swift | 45 ++++++------ .../Models/Mocks/MembershipTier+Mocks.swift | 28 +++++--- .../MembershipStatusStorageMock.swift | 20 ++++-- .../MembershipStatusStorage.swift | 65 +++++++++++++---- Libraryfile | 3 +- ...Anytype_Event.Membership.TiersUpdate.swift | 71 +++++++++++++++++++ .../Anytype_Event.Message.OneOf_Value.swift | 1 + .../Events/Anytype_Event.Message.swift | 26 +++++++ .../Membership/MembershipModelBuilder.swift | 8 ++- .../Membership/Model/MembershipTier.swift | 12 +++- 11 files changed, 221 insertions(+), 60 deletions(-) create mode 100644 Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Membership.TiersUpdate.swift 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..8bbf5e281a 100644 --- a/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift +++ b/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift @@ -6,32 +6,36 @@ import AnytypeCore @MainActor final class MembershipCoordinatorModel: ObservableObject { @Published var userMembership: MembershipStatus = .empty - @Published var tiers: [MembershipTier] = [] - + @Published private var allTiers: [MembershipTier] = [] + @Published var showTiersLoadingError = false @Published var showTier: MembershipTier? @Published var showSuccess: MembershipTier? @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? - + + var tiers: [MembershipTier] { + let currentTierId = userMembership.tier?.type.id ?? 0 + return allTiers + .filter { FeatureFlags.membershipTestTiers || !$0.isTest } + .filter { !$0.iosProductID.isEmpty || $0.type.id == currentTierId } + } + init(initialTierId: Int?) { self.initialTierId = initialTierId membershipStatusStorage.statusPublisher.receiveOnMain().assign(to: &$userMembership) + membershipStatusStorage.tiersPublisher.receiveOnMain().assign(to: &$allTiers) } 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 +44,11 @@ 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) + + func retryLoadTiers() { + Task { + await membershipStatusStorage.refreshMembership() showTiersLoadingError = false - } catch { - showTiersLoadingError = true } } @@ -64,14 +62,15 @@ final class MembershipCoordinatorModel: ObservableObject { private func showSuccessScreen(tier: MembershipTier) { showTier = nil - loadTiers(noCache: true) - + Task { + 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/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift b/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift index e5228745fa..98030a432f 100644 --- a/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift +++ b/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift @@ -15,10 +15,12 @@ extension MembershipTier { Loc.Membership.Feature.viewers(3) ], paymentType: nil, - color: .green + color: .green, + isTest: false, + iosProductID: "" ) } - + static var mockBuilder: MembershipTier { MembershipTier( type: .builder, @@ -32,10 +34,12 @@ extension MembershipTier { Loc.Membership.Feature.viewers(999) ], paymentType: .mockExternal, - color: .blue + color: .blue, + isTest: false, + iosProductID: "io.anytype.membership.builder" ) } - + static var mockCoCreator: MembershipTier { MembershipTier( type: .coCreator, @@ -49,10 +53,12 @@ extension MembershipTier { Loc.Membership.Feature.viewers(999) ], paymentType: .mockExternal, - color: .red + color: .red, + isTest: false, + iosProductID: "io.anytype.membership.cocreator" ) } - + static var mockCustom: MembershipTier { MembershipTier( type: .custom(id: 228), @@ -66,10 +72,12 @@ extension MembershipTier { Loc.Membership.Feature.viewers(999) ], paymentType: .mockExternal, - color: .purple + color: .purple, + isTest: false, + iosProductID: "io.anytype.membership.custom" ) } - + static var mockBuilderTest: MembershipTier { MembershipTier( type: .custom(id: 1337), @@ -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/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift b/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift index 335b824943..6640a5391b 100644 --- a/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift +++ b/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift @@ -4,21 +4,29 @@ import Combine final class MembershipStatusStorageMock: MembershipStatusStorageProtocol { - + nonisolated static let shared = MembershipStatusStorageMock() - + nonisolated init() {} - + @Published var _status: MembershipStatus = .empty var statusPublisher: AnyPublisher { $_status.eraseToAnyPublisher() } var currentStatus: MembershipStatus { _status } - + + @Published var _tiers: [MembershipTier] = [] + var tiersPublisher: AnyPublisher<[MembershipTier], Never> { $_tiers.eraseToAnyPublisher() } + var currentTiers: [MembershipTier] { _tiers } + func owningState(tier: Services.MembershipTier) -> MembershipTierOwningState { .owned(.purchasedElsewhere(.desktop)) } - + func startSubscription() async { - + + } + + func refreshMembership() async { + } func stopSubscriptionAndClean() async { diff --git a/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift b/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift index a1cfc4ecce..846028a280 100644 --- a/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift +++ b/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift @@ -2,15 +2,19 @@ import Foundation import ProtobufMessages import Combine import Services +import AnytypeCore @MainActor protocol MembershipStatusStorageProtocol: Sendable { var statusPublisher: AnyPublisher { get } var currentStatus: MembershipStatus { get } - + var tiersPublisher: AnyPublisher<[MembershipTier], Never> { get } + var currentTiers: [MembershipTier] { get } + func startSubscription() async func stopSubscriptionAndClean() async + func refreshMembership() async } @MainActor @@ -24,46 +28,79 @@ final class MembershipStatusStorage: MembershipStatusStorageProtocol { var statusPublisher: AnyPublisher { $_status.eraseToAnyPublisher() } var currentStatus: MembershipStatus { _status } @Published private var _status: MembershipStatus = .empty - + + var tiersPublisher: AnyPublisher<[MembershipTier], Never> { $_tiers.eraseToAnyPublisher() } + var currentTiers: [MembershipTier] { _tiers } + @Published private var _tiers: [MembershipTier] = [] + private var subscription: AnyCancellable? - + nonisolated init() { } 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) - + setupSubscription() } func stopSubscriptionAndClean() async { subscription = nil _status = .empty + _tiers = [] AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier) } - + + func refreshMembership() async { + _status = (try? await membershipService.getMembership(noCache: true)) ?? _status + 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) } } } - + private func handle(events: EventsBunch) { 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) + guard !_tiers.isEmpty else { + print("[Membership] Skipping membershipUpdate - no tiers available yet") + return + } + + do { + _status = try builder.buildMembershipStatus( + membership: update.data, + allTiers: _tiers + ) + _status.tier.flatMap { AnytypeAnalytics.instance().logChangePlan(tier: $0) } + AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier) + print("[Membership] Updated membership status - tier: \(_status.tier?.name ?? "none")") + } catch { + print("[Membership] Failed to build status: \(error)") + } } + + case .membershipTiersUpdate(let update): + Task { + var built: [MembershipTier] = [] + for tier in update.tiers { + if let builtTier = await builder.buildMembershipTier(tier: tier) { + built.append(builtTier) + } + } + _tiers = built + } + default: break } diff --git a/Libraryfile b/Libraryfile index 4d71f6d83d..0d5dd3c38c 100644 --- a/Libraryfile +++ b/Libraryfile @@ -1 +1,2 @@ -MIDDLE_VERSION=v0.44.0-nightly.20251016.1 +MIDDLE_VERSION=go-6337-make-tiersmembership-fetching-async + diff --git a/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Membership.TiersUpdate.swift b/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Membership.TiersUpdate.swift new file mode 100644 index 0000000000..9f7430e93a --- /dev/null +++ b/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Membership.TiersUpdate.swift @@ -0,0 +1,71 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: pb/protos/events.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import SwiftProtobuf + +extension Anytype_Event.Membership { + public struct TiersUpdate: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var tiers: [Anytype_Model_MembershipTierData] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + } +} + +extension Anytype_Event.Membership.TiersUpdate: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = Anytype_Event.Membership.protoMessageName + ".TiersUpdate" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "tiers"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &self.tiers) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.tiers.isEmpty { + try visitor.visitRepeatedMessageField(value: self.tiers, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Anytype_Event.Membership.TiersUpdate, rhs: Anytype_Event.Membership.TiersUpdate) -> Bool { + if lhs.tiers != rhs.tiers {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate let _protobuf_package = "anytype" diff --git a/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.OneOf_Value.swift b/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.OneOf_Value.swift index d472c852d8..b34b1af086 100644 --- a/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.OneOf_Value.swift +++ b/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.OneOf_Value.swift @@ -84,6 +84,7 @@ extension Anytype_Event.Message { case notificationUpdate(Anytype_Event.Notification.Update) case payloadBroadcast(Anytype_Event.Payload.Broadcast) case membershipUpdate(Anytype_Event.Membership.Update) + case membershipTiersUpdate(Anytype_Event.Membership.TiersUpdate) case spaceSyncStatusUpdate(Anytype_Event.Space.SyncStatus.Update) case p2PStatusUpdate(Anytype_Event.P2PStatus.Update) case importFinish(Anytype_Event.Import.Finish) diff --git a/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.swift b/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.swift index c94a16ccaa..35df469eb5 100644 --- a/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.swift +++ b/Modules/ProtobufMessages/Sources/Protocol/Events/Anytype_Event.Message.swift @@ -567,6 +567,14 @@ extension Anytype_Event { set {value = .membershipUpdate(newValue)} } + public var membershipTiersUpdate: Anytype_Event.Membership.TiersUpdate { + get { + if case .membershipTiersUpdate(let v)? = value {return v} + return Anytype_Event.Membership.TiersUpdate() + } + set {value = .membershipTiersUpdate(newValue)} + } + public var spaceSyncStatusUpdate: Anytype_Event.Space.SyncStatus.Update { get { if case .spaceSyncStatusUpdate(let v)? = value {return v} @@ -737,6 +745,7 @@ extension Anytype_Event.Message: SwiftProtobuf.Message, SwiftProtobuf._MessageIm 115: .same(proto: "notificationUpdate"), 116: .same(proto: "payloadBroadcast"), 117: .same(proto: "membershipUpdate"), + 137: .same(proto: "membershipTiersUpdate"), 119: .same(proto: "spaceSyncStatusUpdate"), 120: .same(proto: "p2pStatusUpdate"), 121: .same(proto: "importFinish"), @@ -1719,6 +1728,19 @@ extension Anytype_Event.Message: SwiftProtobuf.Message, SwiftProtobuf._MessageIm self.value = .chatUpdateMessageSyncStatus(v) } }() + case 137: try { + var v: Anytype_Event.Membership.TiersUpdate? + var hadOneofValue = false + if let current = self.value { + hadOneofValue = true + if case .membershipTiersUpdate(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.value = .membershipTiersUpdate(v) + } + }() case 201: try { var v: Anytype_Event.Account.Details? var hadOneofValue = false @@ -2097,6 +2119,10 @@ extension Anytype_Event.Message: SwiftProtobuf.Message, SwiftProtobuf._MessageIm guard case .chatUpdateMessageSyncStatus(let v)? = self.value else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 136) }() + case .membershipTiersUpdate?: try { + guard case .membershipTiersUpdate(let v)? = self.value else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 137) + }() case .accountDetails?: try { guard case .accountDetails(let v)? = self.value else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 201) diff --git a/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift b/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift index 60265f1887..65de82b8c6 100644 --- a/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift +++ b/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift @@ -51,10 +51,10 @@ final class MembershipModelBuilder: MembershipModelBuilderProtocol { func buildMembershipTier(tier: Anytype_Model_MembershipTierData) async -> MembershipTier? { guard let type = MembershipTierType(intId: tier.id) else { return nil } // ignore 0 tier - + let paymentType = await buildMembershipPaymentType(type: type, tier: tier) let anyName: MembershipAnyName = tier.anyNamesCountIncluded > 0 ? .some(minLenght: tier.anyNameMinLength) : .none - + return MembershipTier( type: type, name: tier.name, @@ -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..9dffb73877 100644 --- a/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift +++ b/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift @@ -102,9 +102,11 @@ 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( type: MembershipTierType, name: String, @@ -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 } } From c74952e9192007da1cb861938e62150ee8923c09 Mon Sep 17 00:00:00 2001 From: Roman Khafizianov Date: Wed, 29 Oct 2025 17:41:11 +0100 Subject: [PATCH 2/2] IOS-5365 address pr review comments --- .../Debug/Views/MembershipDebugView.swift | 15 +- .../MembershipCoordinatorModel.swift | 86 +++++++-- .../SpaceHubCoordinatorViewModel.swift | 2 +- .../MembershipUpgradeViewModifier.swift | 12 +- .../Views/MembershipPricingView.swift | 4 +- .../MembershipTierView.swift | 10 +- .../MembershipTierViewModel.swift | 14 +- .../Models/Mocks/MembershipTier+Mocks.swift | 8 +- .../MembershipOwnerInfoSheetView.swift | 8 +- .../MembershipOwnerInfoSheetViewModel.swift | 14 +- .../Settings/Settings/SettingsViewModel.swift | 2 +- .../MembershipStatusStorageMock.swift | 113 ++++++++++-- .../MembershipStatusStorage.swift | 170 ++++++++++++++---- .../MembershipMetadataProvider.swift | 2 +- .../Membership/MembershipModelBuilder.swift | 4 +- .../Membership/Model/MembershipTier.swift | 2 +- 16 files changed, 366 insertions(+), 100 deletions(-) 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/MembershipCoordinatorModel.swift b/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift index 8bbf5e281a..a3909b29e4 100644 --- a/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift +++ b/Anytype/Sources/PresentationLayer/Flows/MembersipCoordinator/MembershipCoordinatorModel.swift @@ -6,32 +6,79 @@ import AnytypeCore @MainActor final class MembershipCoordinatorModel: ObservableObject { @Published var userMembership: MembershipStatus = .empty - @Published private var allTiers: [MembershipTier] = [] - + @Published var tiers: [MembershipTier] = [] + @Published var showTiersLoadingError = false @Published var showTier: MembershipTier? @Published var showSuccess: MembershipTier? @Published var fireConfetti = false @Published var emailUrl: URL? - + @Injected(\.membershipStatusStorage) private var membershipStatusStorage: any MembershipStatusStorageProtocol @Injected(\.accountManager) private var accountManager: any AccountManagerProtocol - + private let initialTierId: Int? - - var tiers: [MembershipTier] { - let currentTierId = userMembership.tier?.type.id ?? 0 - return allTiers - .filter { FeatureFlags.membershipTestTiers || !$0.isTest } - .filter { !$0.iosProductID.isEmpty || $0.type.id == currentTierId } - } + private var statusTask: Task? + private var tiersTask: Task? init(initialTierId: Int?) { self.initialTierId = initialTierId - membershipStatusStorage.statusPublisher.receiveOnMain().assign(to: &$userMembership) - membershipStatusStorage.tiersPublisher.receiveOnMain().assign(to: &$allTiers) + + 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() { @@ -47,8 +94,12 @@ final class MembershipCoordinatorModel: ObservableObject { func retryLoadTiers() { Task { - await membershipStatusStorage.refreshMembership() - showTiersLoadingError = false + do { + try await membershipStatusStorage.refreshMembership() + showTiersLoadingError = false + } catch { + showTiersLoadingError = true + } } } @@ -62,14 +113,13 @@ final class MembershipCoordinatorModel: ObservableObject { private func showSuccessScreen(tier: MembershipTier) { showTier = nil - Task { - await membershipStatusStorage.refreshMembership() + 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) 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 98030a432f..87260c31f8 100644 --- a/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift +++ b/Anytype/Sources/PresentationLayer/Modules/Membership/Models/Mocks/MembershipTier+Mocks.swift @@ -20,7 +20,7 @@ extension MembershipTier { iosProductID: "" ) } - + static var mockBuilder: MembershipTier { MembershipTier( type: .builder, @@ -39,7 +39,7 @@ extension MembershipTier { iosProductID: "io.anytype.membership.builder" ) } - + static var mockCoCreator: MembershipTier { MembershipTier( type: .coCreator, @@ -58,7 +58,7 @@ extension MembershipTier { iosProductID: "io.anytype.membership.cocreator" ) } - + static var mockCustom: MembershipTier { MembershipTier( type: .custom(id: 228), @@ -77,7 +77,7 @@ extension MembershipTier { iosProductID: "io.anytype.membership.custom" ) } - + static var mockBuilderTest: MembershipTier { MembershipTier( type: .custom(id: 1337), 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 6640a5391b..74dd4d9f92 100644 --- a/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift +++ b/Anytype/Sources/PreviewMocks/Mocks/Services/MembershipStatusStorageMock.swift @@ -1,21 +1,58 @@ import Services import Foundation -import Combine -final class MembershipStatusStorageMock: MembershipStatusStorageProtocol { - + +actor MembershipStatusStorageMock: MembershipStatusStorageProtocol { nonisolated static let shared = MembershipStatusStorageMock() - + nonisolated init() {} + + private var _status: MembershipStatus = .empty + private var _tiers: [MembershipTier] = [] - @Published var _status: MembershipStatus = .empty - var statusPublisher: AnyPublisher { $_status.eraseToAnyPublisher() } - var currentStatus: MembershipStatus { _status } + 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 + } - @Published var _tiers: [MembershipTier] = [] - var tiersPublisher: AnyPublisher<[MembershipTier], Never> { $_tiers.eraseToAnyPublisher() } - var currentTiers: [MembershipTier] { _tiers } + 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)) @@ -25,11 +62,61 @@ final class MembershipStatusStorageMock: MembershipStatusStorageProtocol { } - func refreshMembership() 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 846028a280..11e9c6be18 100644 --- a/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift +++ b/Anytype/Sources/ServiceLayer/MembershipStatusStorage/MembershipStatusStorage.swift @@ -5,100 +5,190 @@ import Services import AnytypeCore -@MainActor -protocol MembershipStatusStorageProtocol: Sendable { - var statusPublisher: AnyPublisher { get } - var currentStatus: MembershipStatus { get } - var tiersPublisher: AnyPublisher<[MembershipTier], Never> { get } - var currentTiers: [MembershipTier] { 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 + 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 - var tiersPublisher: AnyPublisher<[MembershipTier], Never> { $_tiers.eraseToAnyPublisher() } - var currentTiers: [MembershipTier] { _tiers } - @Published private var _tiers: [MembershipTier] = [] + 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: 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 { - _status = (try? await membershipService.getMembership(noCache: true)) ?? _status + 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() { subscription = EventBunchSubscribtion.default.addHandler { [weak self] events in - Task { @MainActor [weak self] in - self?.handle(events: events) - } + await self?.handle(events: events) } } - + private func handle(events: EventsBunch) { for event in events.middlewareEvents { switch event.value { case .membershipUpdate(let update): - Task { - guard !_tiers.isEmpty else { - print("[Membership] Skipping membershipUpdate - no tiers available yet") + 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 { - _status = try builder.buildMembershipStatus( + let status = try await builder.buildMembershipStatus( membership: update.data, - allTiers: _tiers + allTiers: await self._tiers ) - _status.tier.flatMap { AnytypeAnalytics.instance().logChangePlan(tier: $0) } - AnytypeAnalytics.instance().setMembershipTier(tier: _status.tier) - print("[Membership] Updated membership status - tier: \(_status.tier?.name ?? "none")") + + 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): - Task { + 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) } } - _tiers = built + + if Task.isCancelled { return } + + await self?.updateTiers(built) } default: @@ -106,4 +196,14 @@ final class MembershipStatusStorage: MembershipStatusStorageProtocol { } } } + + 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 65de82b8c6..e2caf09ae8 100644 --- a/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift +++ b/Modules/Services/Sources/Services/Membership/MembershipModelBuilder.swift @@ -51,10 +51,10 @@ final class MembershipModelBuilder: MembershipModelBuilderProtocol { func buildMembershipTier(tier: Anytype_Model_MembershipTierData) async -> MembershipTier? { guard let type = MembershipTierType(intId: tier.id) else { return nil } // ignore 0 tier - + let paymentType = await buildMembershipPaymentType(type: type, tier: tier) let anyName: MembershipAnyName = tier.anyNamesCountIncluded > 0 ? .some(minLenght: tier.anyNameMinLength) : .none - + return MembershipTier( type: type, name: tier.name, diff --git a/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift b/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift index 9dffb73877..e5ad53b94f 100644 --- a/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift +++ b/Modules/Services/Sources/Services/Membership/Model/MembershipTier.swift @@ -106,7 +106,7 @@ public struct MembershipTier: Hashable, Identifiable, Equatable, Sendable { public let iosProductID: String public var id: MembershipTierType { type } - + public init( type: MembershipTierType, name: String,