diff --git a/Package.swift b/Package.swift index 27e9ed1f84..18be7950ea 100644 --- a/Package.swift +++ b/Package.swift @@ -41,6 +41,7 @@ package.addModules([ "CreateAuthKeyFeature", "ShowQRFeature", "OverlayWindowClient", + "OnLedgerEntitiesClient", ], tests: .yes() ), @@ -464,6 +465,15 @@ package.addModules([ ], tests: .yes() ), + .client( + name: "OnLedgerEntitiesClient", + dependencies: [ + "GatewayAPI", + "CacheClient", + "EngineKit", + ], + tests: .no + ), .client( name: "AppPreferencesClient", dependencies: [ diff --git a/Sources/Clients/AccountPortfoliosClient/AccountPortfoliosClient+Live.swift b/Sources/Clients/AccountPortfoliosClient/AccountPortfoliosClient+Live.swift index 0c8c9f0105..32cfde6015 100644 --- a/Sources/Clients/AccountPortfoliosClient/AccountPortfoliosClient+Live.swift +++ b/Sources/Clients/AccountPortfoliosClient/AccountPortfoliosClient+Live.swift @@ -132,9 +132,11 @@ extension AccountPortfoliosClient { _ rawAccountDetails: GatewayAPI.StateEntityDetailsResponseItem, ledgerState: GatewayAPI.LedgerState ) async throws -> AccountPortfolio { + @Dependency(\.gatewayAPIClient) var gatewayAPIClient + let (rawFungibleResources, rawNonFungibleResources) = try await ( - fetchAllFungibleResources(rawAccountDetails, ledgerState: ledgerState), - fetchAllNonFungibleResources(rawAccountDetails, ledgerState: ledgerState) + gatewayAPIClient.fetchAllFungibleResources(rawAccountDetails, ledgerState: ledgerState), + gatewayAPIClient.fetchAllNonFungibleResources(rawAccountDetails, ledgerState: ledgerState) ) let poolUnitResources = try await createPoolUnitResources( @@ -215,8 +217,8 @@ extension AccountPortfoliosClient { let resourceAddress = try ResourceAddress(validatingAddress: resource.resourceAddress) let divisibility = details?.divisibility - let behaviors = (details?.roleAssignments).map(extractBehaviors) ?? [] - let tags = extractTags(item: item) + let behaviors = details?.roleAssignments.extractBehaviors() ?? [] + let tags = item.extractTags() let totalSupply = details.flatMap { try? BigDecimal(fromString: $0.totalSupply) } let metadata = resource.explicitMetadata @@ -266,9 +268,9 @@ extension AccountPortfoliosClient { return firstPageItems } - let additionalItems = try await fetchAllPaginatedItems( - cursor: PageCursor(ledgerState: ledgerState, nextPageCursor: nextPageCursor), - fetchEntityNonFungibleResourceIdsPage( + let additionalItems = try await gatewayAPIClient.fetchAllPaginatedItems( + cursor: GatewayAPIClient.PageCursor(ledgerState: ledgerState, nextPageCursor: nextPageCursor), + gatewayAPIClient.fetchEntityNonFungibleResourceIdsPage( accountAddress, resourceAddress: resource.resourceAddress, vaultAddress: vault.vaultAddress @@ -321,8 +323,8 @@ extension AccountPortfoliosClient { let item = try await gatewayAPIClient.getSingleEntityDetails(resource.resourceAddress) let details = item.details?.nonFungible - let behaviors = (details?.roleAssignments).map(extractBehaviors) ?? [] - let tags = extractTags(item: item) + let behaviors = details?.roleAssignments.extractBehaviors() ?? [] + let tags = item.extractTags() let totalSupply = details.flatMap { try? BigDecimal(fromString: $0.totalSupply) } // Load the nftIds from the resource vault @@ -507,7 +509,7 @@ extension AccountPortfoliosClient { return nil } - let allFungibleResources = try await fetchAllFungibleResources(item, ledgerState: ledgerState) + let allFungibleResources = try await gatewayAPIClient.fetchAllFungibleResources(item, ledgerState: ledgerState) guard !allFungibleResources.isEmpty else { assertionFailure("Empty Pool Unit!!!") @@ -535,279 +537,6 @@ extension AccountPortfoliosClient { } // MARK: - Endpoints -extension AccountPortfoliosClient { - @Sendable - static func fetchAllFungibleResources( - _ entityDetails: GatewayAPI.StateEntityDetailsResponseItem, - ledgerState: GatewayAPI.LedgerState - ) async throws -> [GatewayAPI.FungibleResourcesCollectionItem] { - guard let firstPage = entityDetails.fungibleResources else { - return [GatewayAPI.FungibleResourcesCollectionItem]() - } - - guard let nextPageCursor = firstPage.nextCursor else { - return firstPage.items - } - - let additionalItems = try await fetchAllPaginatedItems( - cursor: PageCursor(ledgerState: ledgerState, nextPageCursor: nextPageCursor), - fetchFungibleResourcePage(entityDetails.address) - ) - - return firstPage.items + additionalItems - } - - // FIXME: Similar function to the above, maybe worth extracting in a single function? - @Sendable - static func fetchAllNonFungibleResources( - _ entityDetails: GatewayAPI.StateEntityDetailsResponseItem, - ledgerState: GatewayAPI.LedgerState - ) async throws -> [GatewayAPI.NonFungibleResourcesCollectionItem] { - guard let firstPage = entityDetails.nonFungibleResources else { - return [GatewayAPI.NonFungibleResourcesCollectionItem]() - } - - guard let nextPageCursor = firstPage.nextCursor else { - return firstPage.items - } - - let additionalItems = try await fetchAllPaginatedItems( - cursor: PageCursor(ledgerState: ledgerState, nextPageCursor: nextPageCursor), - fetchNonFungibleResourcePage(entityDetails.address) - ) - - return firstPage.items + additionalItems - } - - static func fetchFungibleResourcePage( - _ entityAddress: String - ) -> @Sendable (PageCursor?) async throws -> PaginatedResourceResponse { - @Dependency(\.gatewayAPIClient) var gatewayAPIClient - - return { pageCursor in - let request = GatewayAPI.StateEntityFungiblesPageRequest( - atLedgerState: pageCursor?.ledgerState.selector, - cursor: pageCursor?.nextPageCursor, - address: entityAddress, - aggregationLevel: .vault - ) - let response = try await gatewayAPIClient.getEntityFungiblesPage(request) - - return .init( - loadedItems: response.items, - totalCount: response.totalCount, - cursor: response.nextCursor.map { - PageCursor(ledgerState: response.ledgerState, nextPageCursor: $0) - } - ) - } - } - - static func fetchNonFungibleResourcePage( - _ accountAddress: String - ) -> @Sendable (PageCursor?) async throws -> PaginatedResourceResponse { - @Dependency(\.gatewayAPIClient) var gatewayAPIClient - - return { pageCursor in - let request = GatewayAPI.StateEntityNonFungiblesPageRequest( - atLedgerState: pageCursor?.ledgerState.selector, - cursor: pageCursor?.nextPageCursor, - address: accountAddress, - aggregationLevel: .vault - ) - let response = try await gatewayAPIClient.getEntityNonFungiblesPage(request) - - return .init( - loadedItems: response.items, - totalCount: response.totalCount, - cursor: response.nextCursor.map { - PageCursor(ledgerState: response.ledgerState, nextPageCursor: $0) - } - ) - } - } - - static func fetchEntityNonFungibleResourceIdsPage( - _ accountAddress: String, - resourceAddress: String, - vaultAddress: String - ) -> @Sendable (PageCursor?) async throws -> PaginatedResourceResponse { - @Dependency(\.gatewayAPIClient) var gatewayAPIClient - - return { pageCursor in - let request = GatewayAPI.StateEntityNonFungibleIdsPageRequest( - atLedgerState: pageCursor?.ledgerState.selector, - cursor: pageCursor?.nextPageCursor, - address: accountAddress, - vaultAddress: vaultAddress, - resourceAddress: resourceAddress - ) - let response = try await gatewayAPIClient.getEntityNonFungibleIdsPage(request) - - return .init( - loadedItems: response.items, - totalCount: response.totalCount, - cursor: response.nextCursor.map { - PageCursor(ledgerState: response.ledgerState, nextPageCursor: $0) - } - ) - } - } -} - -// MARK: - Pagination -extension AccountPortfoliosClient { - /// A page cursor is required to have the `nextPageCurosr` itself, as well the `ledgerState` of the previous page. - struct PageCursor: Hashable, Sendable { - let ledgerState: GatewayAPI.LedgerState - let nextPageCursor: String - } - - struct PaginatedResourceResponse: Sendable { - let loadedItems: [Resource] - let totalCount: Int64? - let cursor: PageCursor? - } - - /// Recursively fetches all of the pages for a given paginated request. - /// - /// Provide an initial page cursor if needed to load the all the items starting with a given page - @Sendable - static func fetchAllPaginatedItems( - cursor: PageCursor?, - _ paginatedRequest: @Sendable @escaping (_ cursor: PageCursor?) async throws -> PaginatedResourceResponse - ) async throws -> [Item] { - @Sendable - func fetchAllPaginatedItems( - collectedResources: PaginatedResourceResponse? - ) async throws -> [Item] { - /// Finish when some items where loaded and the nextPageCursor is nil. - if let collectedResources, collectedResources.cursor == nil { - return collectedResources.loadedItems - } - - /// We can request here with nil nextPageCursor, as the first page will not have a cursor. - let response = try await paginatedRequest(collectedResources?.cursor) - let oldItems = collectedResources?.loadedItems ?? [] - let allItems = oldItems + response.loadedItems - - let nextPageCursor: PageCursor? = { - // Safeguard: Don't rely only on the gateway returning nil for the next page cursor, - // if happened to load an empty page, or all items were loaded - next page cursor is nil. - if response.loadedItems.isEmpty || allItems.count == response.totalCount.map(Int.init) { - return nil - } - - return response.cursor - }() - - let result = PaginatedResourceResponse(loadedItems: allItems, totalCount: response.totalCount, cursor: nextPageCursor) - return try await fetchAllPaginatedItems(collectedResources: result) - } - - return try await fetchAllPaginatedItems( - collectedResources: cursor.map { - PaginatedResourceResponse(loadedItems: [], totalCount: nil, cursor: $0) - } - ) - } -} - -extension AccountPortfoliosClient { - @Sendable static func extractBehaviors(assignments: GatewayAPI.ComponentEntityRoleAssignments) -> [AssetBehavior] { - typealias ParsedName = GatewayAPI.RoleKey.ParsedName - - enum Assigned { - case none, someone, anyone, unknown - } - - func findEntry(_ name: GatewayAPI.RoleKey.ParsedName) -> GatewayAPI.ComponentEntityRoleAssignmentEntry? { - assignments.entries.first { $0.roleKey.parsedName == name } - } - - func performer(_ name: GatewayAPI.RoleKey.ParsedName) -> Assigned { - guard let assignment = findEntry(name)?.parsedAssignment else { return .unknown } - switch assignment { - case .allowAll: return .anyone - case .denyAll: return .none - case .protected, .otherExplicit, .owner: return .someone - } - } - - func updaters(_ name: GatewayAPI.RoleKey.ParsedName) -> Assigned { - guard let updaters = findEntry(name)?.updaterRoles, !updaters.isEmpty else { return .none } - - // Lookup the corresponding assignments, ignoring unknown and empty values - let updaterAssignments = Set(updaters.compactMap(\.parsedName).compactMap(findEntry).compactMap(\.parsedAssignment)) - - if updaterAssignments.isEmpty { - return .unknown - } else if updaterAssignments == [.denyAll] { - return .none - } else if updaterAssignments.contains(.allowAll) { - return .anyone - } else { - return .someone - } - } - - var result: Set = [] - - // Withdrawer and depositor areas are checked together, but we look at the performer and updater role types separately - let movers: Set = [performer(.withdrawer), performer(.depositor)] - if movers != [.anyone] { - result.insert(.movementRestricted) - } else { - let moverUpdaters: Set = [updaters(.withdrawer), updaters(.depositor)] - if moverUpdaters.contains(.anyone) { - result.insert(.movementRestrictableInFutureByAnyone) - } else if moverUpdaters.contains(.someone) { - result.insert(.movementRestrictableInFuture) - } - } - - // Other names are checked individually, but without distinguishing between the role types - func addBehavior(for name: GatewayAPI.RoleKey.ParsedName, ifSomeone: AssetBehavior, ifAnyone: AssetBehavior) { - let either: Set = [performer(name), updaters(name)] - if either.contains(.anyone) { - result.insert(ifAnyone) - } else if either.contains(.someone) { - result.insert(ifSomeone) - } - } - - addBehavior(for: .minter, ifSomeone: .supplyIncreasable, ifAnyone: .supplyIncreasableByAnyone) - addBehavior(for: .burner, ifSomeone: .supplyDecreasable, ifAnyone: .supplyDecreasableByAnyone) - addBehavior(for: .recaller, ifSomeone: .removableByThirdParty, ifAnyone: .removableByAnyone) - addBehavior(for: .freezer, ifSomeone: .freezableByThirdParty, ifAnyone: .freezableByAnyone) - addBehavior(for: .nonFungibleDataUpdater, ifSomeone: .nftDataChangeable, ifAnyone: .nftDataChangeableByAnyone) - - // If there are no special behaviors, that means it's a "simple asset" - if result.isEmpty { - return [.simpleAsset] - } - - // Finally we make some simplifying substitutions - func substitute(_ source: Set, with target: AssetBehavior) { - if result.isSuperset(of: source) { - result.subtract(source) - result.insert(target) - } - } - - // If supply is both increasable and decreasable, then it's "flexible" - substitute([.supplyIncreasableByAnyone, .supplyDecreasableByAnyone], with: .supplyFlexibleByAnyone) - substitute([.supplyIncreasable, .supplyDecreasable], with: .supplyFlexible) - - return result.sorted() - } -} - -extension AccountPortfoliosClient { - @Sendable static func extractTags(item: GatewayAPI.StateEntityDetailsResponseItem) -> [AssetTag] { - item.metadata.tags?.compactMap(NonEmptyString.init(rawValue:)).map(AssetTag.init) ?? [] - } -} extension Array where Element == AccountPortfolio.FungibleResource { func sorted() async -> AccountPortfolio.FungibleResources { @@ -884,7 +613,6 @@ extension AccountPortfolio.PoolUnitResources { } } -// FIXME: Temporary hack to extract the key_image_url, until we have a proper schema extension GatewayAPI.StateNonFungibleDetailsResponseItem { public typealias NFTData = AccountPortfolio.NonFungibleResource.NonFungibleToken.NFTData public var details: [NFTData] { diff --git a/Sources/Clients/CacheClient/CacheClient+Interface.swift b/Sources/Clients/CacheClient/CacheClient+Interface.swift index c14bb2c260..b661f176f6 100644 --- a/Sources/Clients/CacheClient/CacheClient+Interface.swift +++ b/Sources/Clients/CacheClient/CacheClient+Interface.swift @@ -75,6 +75,7 @@ extension CacheClient { extension CacheClient { public enum Entry: Equatable { case accountPortfolio(AccountQuantifier) + case onLedgerEntity(address: String) case networkName(_ url: String) case dAppMetadata(_ definitionAddress: String) case dAppRequestMetadata(_ definitionAddress: String) @@ -90,6 +91,8 @@ extension CacheClient { switch self { case let .accountPortfolio(address): return "\(filesystemFolderPath)/accountPortfolio-\(address)" + case let .onLedgerEntity(address): + return "\(filesystemFolderPath)/onLedgerEntity-\(address)" case let .networkName(url): return "\(filesystemFolderPath)/networkName-\(url)" case let .dAppMetadata(definitionAddress): @@ -107,6 +110,8 @@ extension CacheClient { switch self { case .accountPortfolio: return "AccountPortfolio" + case .onLedgerEntity: + return "OnLedgerEntity" case .networkName: return "NetworkName" case .dAppMetadata: @@ -126,7 +131,7 @@ extension CacheClient { var lifetime: TimeInterval { switch self { - case .accountPortfolio, .networkName: + case .accountPortfolio, .networkName, .onLedgerEntity: return 300 case .dAppMetadata, .dAppRequestMetadata, .rolaDappVerificationMetadata, .rolaWellKnownFileVerification: return 60 diff --git a/Sources/Clients/GatewayAPI/GatewayAPIClient+Extension.swift b/Sources/Clients/GatewayAPI/GatewayAPIClient+Extension.swift new file mode 100644 index 0000000000..aa76a90f85 --- /dev/null +++ b/Sources/Clients/GatewayAPI/GatewayAPIClient+Extension.swift @@ -0,0 +1,291 @@ +import AnyCodable +import EngineKit +import Foundation +import Prelude +import SharedModels + +extension GatewayAPIClient { + @Sendable + public func fetchAllFungibleResources( + _ entityDetails: GatewayAPI.StateEntityDetailsResponseItem, + ledgerState: GatewayAPI.LedgerState + ) async throws -> [GatewayAPI.FungibleResourcesCollectionItem] { + guard let firstPage = entityDetails.fungibleResources else { + return [GatewayAPI.FungibleResourcesCollectionItem]() + } + + guard let nextPageCursor = firstPage.nextCursor else { + return firstPage.items + } + + let additionalItems = try await fetchAllPaginatedItems( + cursor: PageCursor(ledgerState: ledgerState, nextPageCursor: nextPageCursor), + fetchFungibleResourcePage(entityDetails.address) + ) + + return firstPage.items + additionalItems + } + + // FIXME: Similar function to the above, maybe worth extracting in a single function? + @Sendable + public func fetchAllNonFungibleResources( + _ entityDetails: GatewayAPI.StateEntityDetailsResponseItem, + ledgerState: GatewayAPI.LedgerState + ) async throws -> [GatewayAPI.NonFungibleResourcesCollectionItem] { + guard let firstPage = entityDetails.nonFungibleResources else { + return [GatewayAPI.NonFungibleResourcesCollectionItem]() + } + + guard let nextPageCursor = firstPage.nextCursor else { + return firstPage.items + } + + let additionalItems = try await fetchAllPaginatedItems( + cursor: PageCursor(ledgerState: ledgerState, nextPageCursor: nextPageCursor), + fetchNonFungibleResourcePage(entityDetails.address) + ) + + return firstPage.items + additionalItems + } + + public func fetchFungibleResourcePage( + _ entityAddress: String + ) -> @Sendable (PageCursor?) async throws -> PaginatedResourceResponse { + @Dependency(\.gatewayAPIClient) var gatewayAPIClient + + return { pageCursor in + let request = GatewayAPI.StateEntityFungiblesPageRequest( + atLedgerState: pageCursor?.ledgerState.selector, + cursor: pageCursor?.nextPageCursor, + address: entityAddress, + aggregationLevel: .vault + ) + let response = try await gatewayAPIClient.getEntityFungiblesPage(request) + + return .init( + loadedItems: response.items, + totalCount: response.totalCount, + cursor: response.nextCursor.map { + PageCursor(ledgerState: response.ledgerState, nextPageCursor: $0) + } + ) + } + } + + public func fetchNonFungibleResourcePage( + _ accountAddress: String + ) -> @Sendable (PageCursor?) async throws -> PaginatedResourceResponse { + @Dependency(\.gatewayAPIClient) var gatewayAPIClient + + return { pageCursor in + let request = GatewayAPI.StateEntityNonFungiblesPageRequest( + atLedgerState: pageCursor?.ledgerState.selector, + cursor: pageCursor?.nextPageCursor, + address: accountAddress, + aggregationLevel: .vault + ) + let response = try await gatewayAPIClient.getEntityNonFungiblesPage(request) + + return .init( + loadedItems: response.items, + totalCount: response.totalCount, + cursor: response.nextCursor.map { + PageCursor(ledgerState: response.ledgerState, nextPageCursor: $0) + } + ) + } + } + + public func fetchEntityNonFungibleResourceIdsPage( + _ accountAddress: String, + resourceAddress: String, + vaultAddress: String + ) -> @Sendable (PageCursor?) async throws -> PaginatedResourceResponse { + @Dependency(\.gatewayAPIClient) var gatewayAPIClient + + return { pageCursor in + let request = GatewayAPI.StateEntityNonFungibleIdsPageRequest( + atLedgerState: pageCursor?.ledgerState.selector, + cursor: pageCursor?.nextPageCursor, + address: accountAddress, + vaultAddress: vaultAddress, + resourceAddress: resourceAddress + ) + let response = try await gatewayAPIClient.getEntityNonFungibleIdsPage(request) + + return .init( + loadedItems: response.items, + totalCount: response.totalCount, + cursor: response.nextCursor.map { + PageCursor(ledgerState: response.ledgerState, nextPageCursor: $0) + } + ) + } + } +} + +// MARK: - Pagination +extension GatewayAPIClient { + /// A page cursor is required to have the `nextPageCurosr` itself, as well the `ledgerState` of the previous page. + public struct PageCursor: Hashable, Sendable { + public let ledgerState: GatewayAPI.LedgerState + public let nextPageCursor: String + + public init(ledgerState: GatewayAPI.LedgerState, nextPageCursor: String) { + self.ledgerState = ledgerState + self.nextPageCursor = nextPageCursor + } + } + + public struct PaginatedResourceResponse: Sendable { + public let loadedItems: [Resource] + public let totalCount: Int64? + public let cursor: PageCursor? + + public init(loadedItems: [Resource], totalCount: Int64?, cursor: PageCursor?) { + self.loadedItems = loadedItems + self.totalCount = totalCount + self.cursor = cursor + } + } + + /// Recursively fetches all of the pages for a given paginated request. + /// + /// Provide an initial page cursor if needed to load the all the items starting with a given page + @Sendable + public func fetchAllPaginatedItems( + cursor: PageCursor?, + _ paginatedRequest: @Sendable @escaping (_ cursor: PageCursor?) async throws -> PaginatedResourceResponse + ) async throws -> [Item] { + @Sendable + func fetchAllPaginatedItems( + collectedResources: PaginatedResourceResponse? + ) async throws -> [Item] { + /// Finish when some items where loaded and the nextPageCursor is nil. + if let collectedResources, collectedResources.cursor == nil { + return collectedResources.loadedItems + } + + /// We can request here with nil nextPageCursor, as the first page will not have a cursor. + let response = try await paginatedRequest(collectedResources?.cursor) + let oldItems = collectedResources?.loadedItems ?? [] + let allItems = oldItems + response.loadedItems + + let nextPageCursor: PageCursor? = { + // Safeguard: Don't rely only on the gateway returning nil for the next page cursor, + // if happened to load an empty page, or all items were loaded - next page cursor is nil. + if response.loadedItems.isEmpty || allItems.count == response.totalCount.map(Int.init) { + return nil + } + + return response.cursor + }() + + let result = PaginatedResourceResponse(loadedItems: allItems, totalCount: response.totalCount, cursor: nextPageCursor) + return try await fetchAllPaginatedItems(collectedResources: result) + } + + return try await fetchAllPaginatedItems( + collectedResources: cursor.map { + PaginatedResourceResponse(loadedItems: [], totalCount: nil, cursor: $0) + } + ) + } +} + +extension GatewayAPI.StateEntityDetailsResponseItem { + @Sendable public func extractTags() -> [AssetTag] { + metadata.tags?.compactMap(NonEmptyString.init(rawValue:)).map(AssetTag.init) ?? [] + } +} + +extension GatewayAPI.ComponentEntityRoleAssignments { + // FIXME: This logic should not be here, will probably move to OnLedgerEntitiesClient. + @Sendable public func extractBehaviors() -> [AssetBehavior] { + typealias ParsedName = GatewayAPI.RoleKey.ParsedName + + enum Assigned { + case none, someone, anyone, unknown + } + + func findEntry(_ name: GatewayAPI.RoleKey.ParsedName) -> GatewayAPI.ComponentEntityRoleAssignmentEntry? { + entries.first { $0.roleKey.parsedName == name } + } + + func performer(_ name: GatewayAPI.RoleKey.ParsedName) -> Assigned { + guard let assignment = findEntry(name)?.parsedAssignment else { return .unknown } + switch assignment { + case .allowAll: return .anyone + case .denyAll: return .none + case .protected, .otherExplicit, .owner: return .someone + } + } + + func updaters(_ name: GatewayAPI.RoleKey.ParsedName) -> Assigned { + guard let updaters = findEntry(name)?.updaterRoles, !updaters.isEmpty else { return .none } + + // Lookup the corresponding assignments, ignoring unknown and empty values + let updaterAssignments = Set(updaters.compactMap(\.parsedName).compactMap(findEntry).compactMap(\.parsedAssignment)) + + if updaterAssignments.isEmpty { + return .unknown + } else if updaterAssignments == [.denyAll] { + return .none + } else if updaterAssignments.contains(.allowAll) { + return .anyone + } else { + return .someone + } + } + + var result: Set = [] + + // Withdrawer and depositor areas are checked together, but we look at the performer and updater role types separately + let movers: Set = [performer(.withdrawer), performer(.depositor)] + if movers != [.anyone] { + result.insert(.movementRestricted) + } else { + let moverUpdaters: Set = [updaters(.withdrawer), updaters(.depositor)] + if moverUpdaters.contains(.anyone) { + result.insert(.movementRestrictableInFutureByAnyone) + } else if moverUpdaters.contains(.someone) { + result.insert(.movementRestrictableInFuture) + } + } + + // Other names are checked individually, but without distinguishing between the role types + func addBehavior(for name: GatewayAPI.RoleKey.ParsedName, ifSomeone: AssetBehavior, ifAnyone: AssetBehavior) { + let either: Set = [performer(name), updaters(name)] + if either.contains(.anyone) { + result.insert(ifAnyone) + } else if either.contains(.someone) { + result.insert(ifSomeone) + } + } + + addBehavior(for: .minter, ifSomeone: .supplyIncreasable, ifAnyone: .supplyIncreasableByAnyone) + addBehavior(for: .burner, ifSomeone: .supplyDecreasable, ifAnyone: .supplyDecreasableByAnyone) + addBehavior(for: .recaller, ifSomeone: .removableByThirdParty, ifAnyone: .removableByAnyone) + addBehavior(for: .freezer, ifSomeone: .freezableByThirdParty, ifAnyone: .freezableByAnyone) + addBehavior(for: .nonFungibleDataUpdater, ifSomeone: .nftDataChangeable, ifAnyone: .nftDataChangeableByAnyone) + + // If there are no special behaviors, that means it's a "simple asset" + if result.isEmpty { + return [.simpleAsset] + } + + // Finally we make some simplifying substitutions + func substitute(_ source: Set, with target: AssetBehavior) { + if result.isSuperset(of: source) { + result.subtract(source) + result.insert(target) + } + } + + // If supply is both increasable and decreasable, then it's "flexible" + substitute([.supplyIncreasableByAnyone, .supplyDecreasableByAnyone], with: .supplyFlexibleByAnyone) + substitute([.supplyIncreasable, .supplyDecreasable], with: .supplyFlexible) + + return result.sorted() + } +} diff --git a/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Interface.swift b/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Interface.swift new file mode 100644 index 0000000000..bc28311766 --- /dev/null +++ b/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Interface.swift @@ -0,0 +1,18 @@ +import EngineKit +import Prelude +import SharedModels + +// MARK: - OnLedgerEntitiesClient +/// A client that manages loading Entities from the Ledger. +/// Compared to AccountPortfolio it loads the general info about the entities not related to any account. +/// With a refactor, this can potentially also load the Accounts and then link its resources to the general info about resources. +public struct OnLedgerEntitiesClient: Sendable { + public let getResources: GetResources + public let getResource: GetResource +} + +// MARK: - OnLedgerEntitiesClient.GetResources +extension OnLedgerEntitiesClient { + public typealias GetResources = @Sendable ([ResourceAddress]) async throws -> [OnLedgerEntity.Resource] + public typealias GetResource = @Sendable (ResourceAddress) async throws -> OnLedgerEntity.Resource +} diff --git a/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Live.swift b/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Live.swift new file mode 100644 index 0000000000..5d8ab92390 --- /dev/null +++ b/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Live.swift @@ -0,0 +1,97 @@ +import CacheClient +import EngineKit +import GatewayAPI +import Prelude +import SharedModels + +// MARK: - OnLedgerEntitiesClient + DependencyKey +extension OnLedgerEntitiesClient: DependencyKey { + enum Error: Swift.Error { + case emptyResponse + } + + public static let liveValue = Self.live() + + public static func live( + ) -> Self { + Self( + getResources: getResources, + getResource: { + guard let resource = try await getResources(for: [$0]).first else { + throw Error.emptyResponse + } + return resource + } + ) + } +} + +extension OnLedgerEntitiesClient { + @Sendable + static func getResources(for resources: [ResourceAddress]) async throws -> [OnLedgerEntity.Resource] { + try await fetchEntitiesWithCaching(for: resources.map { $0.asGeneral() }).compactMap(\.resource) + } + + @Sendable + static func fetchEntitiesWithCaching(for addresses: [Address]) async throws -> [OnLedgerEntity] { + @Dependency(\.cacheClient) var cacheClient + + let cachedEntities = addresses.compactMap { + try? cacheClient.load(OnLedgerEntity.self, .onLedgerEntity(address: $0.address)) as? OnLedgerEntity + } + + let notCachedEntities = Set(addresses).subtracting(Set(cachedEntities.map(\.address))) + + guard !notCachedEntities.isEmpty else { + return cachedEntities + } + + let freshEntities = try await OnLedgerEntitiesClient.fetchEntites(for: Array(notCachedEntities)) + freshEntities.forEach { + cacheClient.save($0, .onLedgerEntity(address: $0.address.address)) + } + + return cachedEntities + freshEntities + } + + @Sendable + static func fetchEntites(for addresses: [Address]) async throws -> [OnLedgerEntity] { + guard !addresses.isEmpty else { + return [] + } + + @Dependency(\.gatewayAPIClient) var gatewayAPIClient + + let details = try await gatewayAPIClient.getEntityDetails(addresses.map(\.address), .resourceMetadataKeys, nil) + return try details.items.compactMap { + switch $0.details { + case let .fungibleResource(fungibleDetails): + return try .resource(.init( + resourceAddress: .init(validatingAddress: $0.address), + divisibility: fungibleDetails.divisibility, + name: $0.explicitMetadata?.name, + symbol: $0.explicitMetadata?.symbol, + description: $0.explicitMetadata?.description, + iconURL: $0.explicitMetadata?.iconURL, + behaviors: $0.details?.fungible?.roleAssignments.extractBehaviors() ?? [], + tags: $0.extractTags(), + totalSupply: try? BigDecimal(fromString: fungibleDetails.totalSupply) + )) + case let .nonFungibleResource(nonFungibleDetails): + return try .resource(.init( + resourceAddress: .init(validatingAddress: $0.address), + divisibility: nil, + name: $0.explicitMetadata?.name, + symbol: nil, + description: $0.explicitMetadata?.description, + iconURL: $0.explicitMetadata?.iconURL, + behaviors: $0.details?.nonFungible?.roleAssignments.extractBehaviors() ?? [], + tags: $0.extractTags(), + totalSupply: try? BigDecimal(fromString: nonFungibleDetails.totalSupply) + )) + default: + return nil + } + } + } +} diff --git a/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Test.swift b/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Test.swift new file mode 100644 index 0000000000..f1ce4c247f --- /dev/null +++ b/Sources/Clients/OnLedgerEntitiesClient/OnLedgerEntitiesClient+Test.swift @@ -0,0 +1,8 @@ +import Prelude + +extension DependencyValues { + public var onLedgerEntitiesClient: OnLedgerEntitiesClient { + get { self[OnLedgerEntitiesClient.self] } + set { self[OnLedgerEntitiesClient.self] = newValue } + } +} diff --git a/Sources/Core/DesignSystem/Components/PlainListRow.swift b/Sources/Core/DesignSystem/Components/PlainListRow.swift index c13154519e..1b2b26291b 100644 --- a/Sources/Core/DesignSystem/Components/PlainListRow.swift +++ b/Sources/Core/DesignSystem/Components/PlainListRow.swift @@ -3,48 +3,48 @@ import SwiftUI // MARK: - PlainListRow public struct PlainListRow: View { - let isShowingChevron: Bool + let accessory: ImageAsset? let title: String let subtitle: String? - let icon: Icon + let icon: Icon? public init( - _ content: AssetIcon.Content, title: String, subtitle: String? = nil, - showChevron: Bool = true - ) where Icon == AssetIcon { - self.init( - title: title, - subtitle: subtitle, - showChevron: showChevron, - icon: { AssetIcon(content) } - ) + accessory: ImageAsset? = AssetResource.chevronRight, + @ViewBuilder icon: () -> Icon + ) { + self.accessory = accessory + self.title = title + self.subtitle = subtitle + self.icon = icon() } public init( + _ content: AssetIcon.Content?, title: String, subtitle: String? = nil, - showChevron: Bool = true, - @ViewBuilder icon: () -> Icon - ) { - self.isShowingChevron = showChevron + accessory: ImageAsset? = AssetResource.chevronRight + ) where Icon == AssetIcon { + self.accessory = accessory self.title = title self.subtitle = subtitle - self.icon = icon() + self.icon = content.map { AssetIcon($0) } } public var body: some View { HStack(spacing: .zero) { - icon - .padding(.trailing, .medium3) + if let icon { + icon + .padding(.trailing, .medium3) + } PlainListRowCore(title: title, subtitle: subtitle) Spacer(minLength: 0) - if isShowingChevron { - Image(asset: AssetResource.chevronRight) + if let accessory { + Image(asset: accessory) } } .frame(minHeight: .settingsRowHeight) @@ -54,11 +54,16 @@ public struct PlainListRow: View { } // MARK: - PlainListRowCore -struct PlainListRowCore: View { +public struct PlainListRowCore: View { let title: String let subtitle: String? - var body: some View { + public init(title: String, subtitle: String?) { + self.title = title + self.subtitle = subtitle + } + + public var body: some View { VStack(alignment: .leading, spacing: .zero) { Text(title) .lineSpacing(-6) @@ -101,6 +106,12 @@ extension View { .padding(.horizontal, horizontalPadding) } } + + public func tappable(_ action: @escaping () -> Void) -> some View { + Button(action: action) { + self + } + } } // MARK: - PlainListRow_Previews @@ -108,8 +119,7 @@ struct PlainListRow_Previews: PreviewProvider { static var previews: some View { PlainListRow( .asset(AssetResource.appSettings), - title: "A title", - showChevron: true + title: "A title" ) } } diff --git a/Sources/Core/FeaturePrelude/FeatureReducer.swift b/Sources/Core/FeaturePrelude/FeatureReducer.swift index 00751e18f5..e6d4eca96b 100644 --- a/Sources/Core/FeaturePrelude/FeatureReducer.swift +++ b/Sources/Core/FeaturePrelude/FeatureReducer.swift @@ -76,3 +76,19 @@ public typealias PresentationStoreOf = Store = ViewStore public typealias StackActionOf = StackAction + +// MARK: - FeatureAction + Hashable +extension FeatureAction: Hashable where Feature.ViewAction: Hashable, Feature.ChildAction: Hashable, Feature.InternalAction: Hashable, Feature.DelegateAction: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case let .view(action): + hasher.combine(action) + case let .internal(action): + hasher.combine(action) + case let .child(action): + hasher.combine(action) + case let .delegate(action): + hasher.combine(action) + } + } +} diff --git a/Sources/Core/Resources/Generated/AssetResource.generated.swift b/Sources/Core/Resources/Generated/AssetResource.generated.swift index 6b3ac08fd3..23b9a01163 100644 --- a/Sources/Core/Resources/Generated/AssetResource.generated.swift +++ b/Sources/Core/Resources/Generated/AssetResource.generated.swift @@ -42,6 +42,7 @@ public enum AssetResource { public static let supplyIncreasableByAnyone = ImageAsset(name: "supply-increasable-by-anyone") public static let supplyIncreasable = ImageAsset(name: "supply-increasable") public static let arrowBack = ImageAsset(name: "arrow-back") + public static let check = ImageAsset(name: "check") public static let checkmarkBig = ImageAsset(name: "checkmark-big") public static let checkmarkDarkSelected = ImageAsset(name: "checkmark-dark-selected") public static let checkmarkDarkUnselected = ImageAsset(name: "checkmark-dark-unselected") @@ -58,6 +59,8 @@ public enum AssetResource { public static let ellipsis = ImageAsset(name: "ellipsis") public static let error = ImageAsset(name: "error") public static let iconAcceptAirdrop = ImageAsset(name: "icon-accept-airdrop") + public static let iconAcceptKnownAirdrop = ImageAsset(name: "icon-accept-known-airdrop") + public static let iconDeclineAirdrop = ImageAsset(name: "icon-decline-airdrop") public static let iconHardwareLedger = ImageAsset(name: "icon-hardware-ledger") public static let iconLinkOut = ImageAsset(name: "icon-link-out") public static let info = ImageAsset(name: "info") diff --git a/Sources/Core/Resources/Resources/Assets.xcassets/Common/check.imageset/Contents.json b/Sources/Core/Resources/Resources/Assets.xcassets/Common/check.imageset/Contents.json new file mode 100644 index 0000000000..37accc5389 --- /dev/null +++ b/Sources/Core/Resources/Resources/Assets.xcassets/Common/check.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "check.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Core/Resources/Resources/Assets.xcassets/Common/check.imageset/check.pdf b/Sources/Core/Resources/Resources/Assets.xcassets/Common/check.imageset/check.pdf new file mode 100644 index 0000000000..0bb11dc759 Binary files /dev/null and b/Sources/Core/Resources/Resources/Assets.xcassets/Common/check.imageset/check.pdf differ diff --git a/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-accept-known-airdrop.imageset/Contents.json b/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-accept-known-airdrop.imageset/Contents.json new file mode 100644 index 0000000000..58714ef4f3 --- /dev/null +++ b/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-accept-known-airdrop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon-accept-known-airdrop.pdf", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-accept-known-airdrop.imageset/icon-accept-known-airdrop.pdf b/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-accept-known-airdrop.imageset/icon-accept-known-airdrop.pdf new file mode 100644 index 0000000000..4a48be6b6c Binary files /dev/null and b/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-accept-known-airdrop.imageset/icon-accept-known-airdrop.pdf differ diff --git a/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-decline-airdrop.imageset/Contents.json b/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-decline-airdrop.imageset/Contents.json new file mode 100644 index 0000000000..1af019f707 --- /dev/null +++ b/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-decline-airdrop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon-decline-airdrop.pdf", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-decline-airdrop.imageset/icon-decline-airdrop.pdf b/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-decline-airdrop.imageset/icon-decline-airdrop.pdf new file mode 100644 index 0000000000..0c7ca2b768 Binary files /dev/null and b/Sources/Core/Resources/Resources/Assets.xcassets/Common/icon-decline-airdrop.imageset/icon-decline-airdrop.pdf differ diff --git a/Sources/Core/SharedModels/Assets/OnLedgerEntity.swift b/Sources/Core/SharedModels/Assets/OnLedgerEntity.swift new file mode 100644 index 0000000000..10c83dcd1a --- /dev/null +++ b/Sources/Core/SharedModels/Assets/OnLedgerEntity.swift @@ -0,0 +1,59 @@ +import EngineKit +import Foundation +import Prelude + +// MARK: - OnLedgerEntity +public enum OnLedgerEntity: Sendable, Hashable, Codable { + case resource(Resource) + + public var address: Address { + switch self { + case let .resource(resource): + return resource.resourceAddress.asGeneral() + } + } + + public var resource: Resource? { + guard case let .resource(resource) = self else { + return nil + } + return resource + } +} + +// MARK: OnLedgerEntity.Resource +extension OnLedgerEntity { + public struct Resource: Sendable, Hashable, Codable { + public let resourceAddress: ResourceAddress + public let divisibility: Int? + public let name: String? + public let symbol: String? + public let description: String? + public let iconURL: URL? + public let behaviors: [AssetBehavior] + public let tags: [AssetTag] + public let totalSupply: BigDecimal? + + public init( + resourceAddress: ResourceAddress, + divisibility: Int?, + name: String?, + symbol: String?, + description: String?, + iconURL: URL?, + behaviors: [AssetBehavior], + tags: [AssetTag], + totalSupply: BigDecimal? + ) { + self.resourceAddress = resourceAddress + self.divisibility = divisibility + self.name = name + self.symbol = symbol + self.description = description + self.iconURL = iconURL + self.behaviors = behaviors + self.tags = tags + self.totalSupply = totalSupply + } + } +} diff --git a/Sources/Core/SharedModels/LedgerIdentifiable.swift b/Sources/Core/SharedModels/LedgerIdentifiable.swift index 445ae656c6..40b80a6ca6 100644 --- a/Sources/Core/SharedModels/LedgerIdentifiable.swift +++ b/Sources/Core/SharedModels/LedgerIdentifiable.swift @@ -55,6 +55,8 @@ extension LedgerIdentifiable { case resource(ResourceAddress) case component(ComponentAddress) case validator(ValidatorAddress) + // Will be displayd with full ResourceAddress+NFTLocalID + case nonFungibleGlobalID(NonFungibleGlobalId) public var address: String { switch self { @@ -68,6 +70,8 @@ extension LedgerIdentifiable { return componentAddress.address case let .validator(validatorAddress): return validatorAddress.address + case let .nonFungibleGlobalID(id): + return id.asStr() } } @@ -83,6 +87,8 @@ extension LedgerIdentifiable { return "component" case .validator: return "component" + case .nonFungibleGlobalID: + return "resource" } } } diff --git a/Sources/Features/AccountDetailsFeature/Coordinator/AccountDetails+Reducer.swift b/Sources/Features/AccountDetailsFeature/Coordinator/AccountDetails+Reducer.swift index eeb5784258..b9000d07ac 100644 --- a/Sources/Features/AccountDetailsFeature/Coordinator/AccountDetails+Reducer.swift +++ b/Sources/Features/AccountDetailsFeature/Coordinator/AccountDetails+Reducer.swift @@ -101,10 +101,6 @@ public struct AccountDetails: Sendable, FeatureReducer { public func reduce(into state: inout State, childAction: ChildAction) -> EffectTask { switch childAction { - case .destination(.presented(.preferences(.delegate(.dismiss)))): - state.destination = nil - return .none - case .destination(.presented(.transfer(.delegate(.dismissed)))): state.destination = nil return .none diff --git a/Sources/Features/AccountPreferencesFeature/AccountPreferences+Reducer.swift b/Sources/Features/AccountPreferencesFeature/AccountPreferences+Reducer.swift index d4ab250f13..ec6211d498 100644 --- a/Sources/Features/AccountPreferencesFeature/AccountPreferences+Reducer.swift +++ b/Sources/Features/AccountPreferencesFeature/AccountPreferences+Reducer.swift @@ -3,17 +3,13 @@ import FeaturePrelude // MARK: - AccountPreferences public struct AccountPreferences: Sendable, FeatureReducer { - // MARK: - State - public struct State: Sendable, Hashable { public var account: Profile.Network.Account @PresentationState var destinations: Destinations.State? = nil - public init( - account: Profile.Network.Account - ) { + public init(account: Profile.Network.Account) { self.account = account } } @@ -21,8 +17,8 @@ public struct AccountPreferences: Sendable, FeatureReducer { // MARK: - Action public enum ViewAction: Sendable, Equatable { - case task - case rowTapped(AccountPreferences.Section.Row.Kind) + case viewAppeared + case rowTapped(AccountPreferences.Section.SectionRow) } public enum InternalAction: Sendable, Equatable { @@ -33,20 +29,17 @@ public struct AccountPreferences: Sendable, FeatureReducer { case destinations(PresentationAction) } - public enum DelegateAction: Sendable, Equatable { - case dismiss - } - // MARK: - Destination - - public struct Destinations: ReducerProtocol { + public struct Destinations: ReducerProtocol, Sendable { public enum State: Equatable, Hashable { case updateAccountLabel(UpdateAccountLabel.State) + case thirdPartyDeposits(ManageThirdPartyDeposits.State) case devPreferences(DevAccountPreferences.State) } public enum Action: Equatable { case updateAccountLabel(UpdateAccountLabel.Action) + case thirdPartyDeposits(ManageThirdPartyDeposits.Action) case devPreferences(DevAccountPreferences.Action) } @@ -54,6 +47,9 @@ public struct AccountPreferences: Sendable, FeatureReducer { Scope(state: /State.updateAccountLabel, action: /Action.updateAccountLabel) { UpdateAccountLabel() } + Scope(state: /State.thirdPartyDeposits, action: /Action.thirdPartyDeposits) { + ManageThirdPartyDeposits() + } Scope(state: /State.devPreferences, action: /Action.devPreferences) { DevAccountPreferences() } @@ -73,7 +69,7 @@ public struct AccountPreferences: Sendable, FeatureReducer { public func reduce(into state: inout State, viewAction: ViewAction) -> EffectTask { switch viewAction { - case .task: + case .viewAppeared: return .run { [address = state.account.address] send in for try await accountUpdate in await accountsClient.accountUpdates(address) { guard !Task.isCancelled else { return } @@ -81,32 +77,15 @@ public struct AccountPreferences: Sendable, FeatureReducer { } } - case .rowTapped(.accountLabel): - state.destinations = .updateAccountLabel(.init(account: state.account)) - return .none - - case .rowTapped(.devPreferences): - state.destinations = .devPreferences(.init(address: state.account.address)) - return .none - - case .rowTapped: - return .none + case let .rowTapped(row): + return destination(for: row, &state) } } public func reduce(into state: inout State, childAction: ChildAction) -> EffectTask { switch childAction { case let .destinations(.presented(action)): - switch action { - case .updateAccountLabel(.delegate(.accountLabelUpdated)): - state.destinations = nil - return .none - case .updateAccountLabel: - return .none - case .devPreferences: - return .none - } - + return onDestinationAction(action, &state) default: return .none } @@ -120,3 +99,45 @@ public struct AccountPreferences: Sendable, FeatureReducer { } } } + +extension AccountPreferences { + func destination(for row: AccountPreferences.Section.SectionRow, _ state: inout State) -> EffectTask { + switch row { + case .personalize(.accountLabel): + state.destinations = .updateAccountLabel(.init(account: state.account)) + return .none + + case .personalize(.accountColor): + return .none + + case .personalize(.tags): + return .none + + case .onLedger(.thirdPartyDeposits): + state.destinations = .thirdPartyDeposits(.init(account: state.account)) + return .none + + case .onLedger(.accountSecurity): + return .none + + case .dev(.devPreferences): + state.destinations = .devPreferences(.init(address: state.account.address)) + return .none + } + } + + func onDestinationAction(_ action: AccountPreferences.Destinations.Action, _ state: inout State) -> EffectTask { + switch action { + case .updateAccountLabel(.delegate(.accountLabelUpdated)), + .thirdPartyDeposits(.delegate(.accountUpdated)): + state.destinations = nil + return .none + case .updateAccountLabel: + return .none + case .thirdPartyDeposits: + return .none + case .devPreferences: + return .none + } + } +} diff --git a/Sources/Features/AccountPreferencesFeature/AccountPreferences+View.swift b/Sources/Features/AccountPreferencesFeature/AccountPreferences+View.swift index dbb269ea32..ec41df1b9e 100644 --- a/Sources/Features/AccountPreferencesFeature/AccountPreferences+View.swift +++ b/Sources/Features/AccountPreferencesFeature/AccountPreferences+View.swift @@ -6,12 +6,17 @@ extension AccountPreferences.State { .init( id: .personalize, title: "Personalize this account", // FIXME: strings - rows: .init(uncheckedUniqueElements: [.accountLabel(account)]) + rows: [.accountLabel(account)] + ), + .init( + id: .ledgerBehaviour, + title: "Set how you want this account to work", // FIXME: strings + rows: [.thirdPartyDeposits()] ), .init( id: .development, title: "Set development preferences", // FIXME: strings - rows: .init(uncheckedUniqueElements: [.devAccountPreferneces()]) + rows: [.devAccountPreferneces()] ), ]) } @@ -20,7 +25,7 @@ extension AccountPreferences.State { // MARK: - AccountPreferences.View extension AccountPreferences { public struct ViewState: Equatable { - public var sections: [Section] + var sections: [PreferenceSection.ViewState] } @MainActor @@ -33,42 +38,15 @@ extension AccountPreferences { public var body: some SwiftUI.View { WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in - List { - ForEach(viewStore.sections) { section in - SwiftUI.Section { - ForEach(section.rows) { row in - PlainListRow( - row.icon, - title: row.title, - subtitle: row.subtitle - ) - .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + .medium3 } - .alignmentGuide(.listRowSeparatorTrailing) { $0[.trailing] - .medium3 } - .listRowInsets(EdgeInsets()) - .onTapGesture { - viewStore.send(.rowTapped(row.id)) - } - } - } header: { - Text(section.title) - .textStyle(.body1HighImportance) - .foregroundColor(.app.gray2) - .listRowInsets(.init(top: .small1, leading: .medium3, bottom: .medium3, trailing: .medium3)) - } footer: { - Rectangle().fill().frame(height: 0) - .listRowInsets(EdgeInsets()) - } - .listSectionSeparator(.hidden) - .textCase(nil) - } - } + PreferencesList( + viewState: .init(sections: viewStore.sections), + onRowSelected: { _, rowId in viewStore.send(.rowTapped(rowId)) } + ) .task { - viewStore.send(.task) + viewStore.send(.viewAppeared) } .destination(store: store) - .listStyle(.grouped) - .environment(\.defaultMinListHeaderHeight, 0) - .background(.app.gray4) + .background(.app.gray5) .navigationTitle(L10n.AccountSettings.title) #if os(iOS) .navigationBarTitleColor(.app.gray1) @@ -87,6 +65,7 @@ extension View { func destination(store: StoreOf) -> some View { let destinationStore = store.scope(state: \.$destinations, action: { .child(.destinations($0)) }) return updateAccountLabel(with: destinationStore) + .thirdPartyDeposits(with: destinationStore) .devAccountPreferences(with: destinationStore) } @@ -100,6 +79,16 @@ extension View { ) } + @MainActor + func thirdPartyDeposits(with destinationStore: PresentationStoreOf) -> some View { + navigationDestination( + store: destinationStore, + state: /AccountPreferences.Destinations.State.thirdPartyDeposits, + action: AccountPreferences.Destinations.Action.thirdPartyDeposits, + destination: { ManageThirdPartyDeposits.View(store: $0) } + ) + } + @MainActor func devAccountPreferences(with destinationStore: PresentationStoreOf) -> some View { navigationDestination( @@ -113,39 +102,38 @@ extension View { // MARK: - AccountPreferences.Section extension AccountPreferences { - public struct Section: Identifiable, Equatable { - public enum Kind: Equatable { - case personalize - case ledgerBehaviour - case development + public enum Section: Hashable, Sendable { + case personalize + case ledgerBehaviour + case development + + public enum SectionRow: Hashable, Sendable { + case personalize(PersonalizeRow) + case onLedger(OnLedgerBehaviourRow) + case dev(DevelopmentRow) } - public struct Row: Identifiable, Equatable { - public enum Kind: Equatable, Sendable { - case accountLabel - case accountColor - case tags - case accountSecurity - case thirdPartyDeposits - case devPreferences - } + public enum PersonalizeRow: Hashable, Sendable { + case accountLabel + case accountColor + case tags + } - public let id: Kind - let title: String - let subtitle: String? - let icon: AssetIcon.Content + public enum OnLedgerBehaviourRow: Hashable, Sendable { + case accountSecurity + case thirdPartyDeposits } - public let id: Kind - let title: String - let rows: IdentifiedArrayOf + public enum DevelopmentRow: Hashable, Sendable { + case devPreferences + } } } -extension AccountPreferences.Section.Row { - public static func accountLabel(_ account: Profile.Network.Account) -> Self { +extension PreferenceSection.Row where RowId == AccountPreferences.Section.SectionRow { + static func accountLabel(_ account: Profile.Network.Account) -> Self { .init( - id: .accountLabel, + id: .personalize(.accountLabel), title: "Account Label", // FIXME: strings subtitle: account.displayName.rawValue, icon: .asset(AssetResource.create) @@ -154,7 +142,7 @@ extension AccountPreferences.Section.Row { static func devAccountPreferneces() -> Self { .init( - id: .devPreferences, + id: .dev(.devPreferences), title: "Dev Preferences", // FIXME: strings subtitle: nil, icon: .asset(AssetResource.appSettings) @@ -164,7 +152,7 @@ extension AccountPreferences.Section.Row { // TODO: Pass the deposit mode static func thirdPartyDeposits() -> Self { .init( - id: .thirdPartyDeposits, + id: .onLedger(.thirdPartyDeposits), title: "Third-Party Deposits", // FIXME: strings subtitle: "Accept all deposits", // FIXME: strings icon: .asset(AssetResource.iconAcceptAirdrop) diff --git a/Sources/Features/AccountPreferencesFeature/DevAccountPreferences+Reducer.swift b/Sources/Features/AccountPreferencesFeature/Children/DevAccountPreferences+Reducer.swift similarity index 100% rename from Sources/Features/AccountPreferencesFeature/DevAccountPreferences+Reducer.swift rename to Sources/Features/AccountPreferencesFeature/Children/DevAccountPreferences+Reducer.swift diff --git a/Sources/Features/AccountPreferencesFeature/DevAccountPreferences+View.swift b/Sources/Features/AccountPreferencesFeature/Children/DevAccountPreferences+View.swift similarity index 100% rename from Sources/Features/AccountPreferencesFeature/DevAccountPreferences+View.swift rename to Sources/Features/AccountPreferencesFeature/Children/DevAccountPreferences+View.swift diff --git a/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/AddAsset+Reducer.swift b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/AddAsset+Reducer.swift new file mode 100644 index 0000000000..ddac3a6fd7 --- /dev/null +++ b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/AddAsset+Reducer.swift @@ -0,0 +1,80 @@ +import EngineKit +import FeaturePrelude + +// MARK: - AddAsset +public struct AddAsset: FeatureReducer, Sendable { + public struct State: Hashable, Sendable { + var mode: ResourcesListMode + let alreadyAddedResources: OrderedSet + + var resourceAddress: String = "" + var resourceAddressFieldFocused: Bool = false + + var validatedResourceAddress: ResourceViewState.Address? { + guard !resourceAddress.isEmpty else { + return nil + } + + switch mode { + case let .allowDenyAssets(exceptionRule): + return try? .assetException(.init(address: .init(validatingAddress: resourceAddress), exceptionRule: exceptionRule)) + case .allowDepositors: + return ThirdPartyDeposits.DepositorAddress(raw: resourceAddress).map { .allowedDepositor($0) } + } + } + } + + public enum ViewAction: Hashable, Sendable { + case addAssetTapped(ResourceViewState.Address) + case resourceAddressChanged(String) + case exceptionRuleChanged(ResourcesListMode.ExceptionRule) + case focusChanged(Bool) + case closeTapped + } + + public enum DelegateAction: Hashable, Sendable { + case addAddress(ResourcesListMode, ResourceViewState.Address) + } + + @Dependency(\.dismiss) var dismiss + + public func reduce(into state: inout State, viewAction: ViewAction) -> EffectTask { + switch viewAction { + case let .addAssetTapped(resourceAddress): + return .send(.delegate(.addAddress(state.mode, resourceAddress))) + + case let .resourceAddressChanged(address): + state.resourceAddress = address + return .none + + case let .exceptionRuleChanged(rule): + state.mode = .allowDenyAssets(rule) + return .none + + case let .focusChanged(focus): + state.resourceAddressFieldFocused = focus + return .none + + case .closeTapped: + return .run { _ in + await dismiss() + } + } + } +} + +extension ThirdPartyDeposits.DepositorAddress { + init?(raw: String) { + if let asResourceAddress = try? ResourceAddress(validatingAddress: raw) { + self = .resourceAddress(asResourceAddress) + return + } + + if let asNFTId = try? NonFungibleGlobalId(nonFungibleGlobalId: raw) { + self = .nonFungibleGlobalID(asNFTId) + return + } + + return nil + } +} diff --git a/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/AddAssets+View.swift b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/AddAssets+View.swift new file mode 100644 index 0000000000..0313acb1ec --- /dev/null +++ b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/AddAssets+View.swift @@ -0,0 +1,200 @@ +import EngineKit +import FeaturePrelude + +extension AddAsset.State { + var viewState: AddAsset.ViewState { + .init( + resourceAddress: resourceAddress, + validatedResourceAddress: { + if let validatedResourceAddress, + !alreadyAddedResources.contains(validatedResourceAddress) + { + return validatedResourceAddress + } + return nil + }(), + addressHint: { + guard !resourceAddressFieldFocused, !resourceAddress.isEmpty else { + return .none + } + + guard let validatedAddress = validatedResourceAddress else { + return .error("Invalid Address") // FIXME: Strings + } + + if alreadyAddedResources.contains(validatedAddress) { + return .error("Resource already added") // FIXME: Strings + } + + return .none + }(), + resourceAddressFieldFocused: resourceAddressFieldFocused, + mode: mode + ) + } +} + +extension AddAsset { + public struct ViewState: Equatable { + let resourceAddress: String + let validatedResourceAddress: ResourceViewState.Address? + let addressHint: Hint? + let resourceAddressFieldFocused: Bool + let mode: ResourcesListMode + } + + @MainActor + public struct View: SwiftUI.View { + let store: StoreOf + + @FocusState private var resourceAddressFieldFocus: Bool + + init(store: StoreOf) { + self.store = store + } + + public var body: some SwiftUI.View { + WithViewStore(store, observe: \.viewState, send: Action.view) { viewStore in + VStack { + CloseButton { viewStore.send(.closeTapped) } + .flushedLeft + + ScrollView { + VStack(spacing: .medium1) { + titleView(viewStore.mode.title) + instructionsView(viewStore.mode.instructions) + + resourceAddressView(viewStore) + if case .allowDenyAssets = viewStore.mode { + depositListSelectionView(viewStore) + } + addAssetButton(viewStore) + } + .padding([.horizontal, .bottom], .medium1) + } + .scrollIndicators(.hidden) + } + } + .presentationDetents([.fraction(0.75)]) + .presentationDragIndicator(.visible) + #if os(iOS) + .presentationBackground(.blur) + #endif + } + } +} + +extension AddAsset.View { + @ViewBuilder + func titleView(_ text: String) -> some SwiftUI.View { + Text(text) + .multilineTextAlignment(.center) + .textStyle(.sheetTitle) + .foregroundColor(.app.gray1) + } + + @ViewBuilder + func instructionsView(_ text: String) -> some SwiftUI.View { + Text(text) + .lineLimit(nil) + .textStyle(.body1Regular) + .foregroundColor(.app.gray1) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + func resourceAddressView(_ viewStore: ViewStoreOf) -> some SwiftUI.View { + AppTextField( + placeholder: "Resource Address", // FIXME: Strings + text: viewStore.binding( + get: \.resourceAddress, + send: { .resourceAddressChanged($0) } + ), + hint: viewStore.addressHint, + focus: .on( + true, + binding: viewStore.binding( + get: \.resourceAddressFieldFocused, + send: { .focusChanged($0) } + ), + to: $resourceAddressFieldFocus + ), + showClearButton: true + ) + } + + func depositListSelectionView(_ viewStore: ViewStoreOf) -> some SwiftUI.View { + FlowLayout { + ForEach(ResourcesListMode.ExceptionRule.allCases, id: \.self) { + depositExceptionSelectionView($0, viewStore) + } + } + } + + @ViewBuilder + func depositExceptionSelectionView(_ exception: ResourcesListMode.ExceptionRule, _ viewStore: ViewStoreOf) -> some SwiftUI.View { + HStack { + RadioButton( + appearance: .dark, + state: viewStore.mode.allowDenyAssets == exception ? .selected : .unselected + ) + Text(exception.selectionText) + } + .onTapGesture { + viewStore.send(.exceptionRuleChanged(exception)) + } + } + + @ViewBuilder + func addAssetButton(_ viewStore: ViewStoreOf) -> some SwiftUI.View { + WithControlRequirements( + viewStore.validatedResourceAddress, + forAction: { + viewStore.send(.addAssetTapped($0)) + }, + control: { action in + Button(viewStore.mode.addButtonTitle, action: action) + .buttonStyle(.primaryRectangular) + } + ) + } +} + +extension ResourcesListMode.ExceptionRule { + var selectionText: String { + switch self { + case .allow: + return "Allow Deposits" // FIXME: Strings + case .deny: + return "Deny Deposits" // FIXME: Strings + } + } +} + +extension ResourcesListMode { + var allowDenyAssets: ResourcesListMode.ExceptionRule? { + guard case let .allowDenyAssets(type) = self else { + return nil + } + return type + } + + var title: String { + switch self { + case .allowDenyAssets: + return "Add an Asset" // FIXME: Strings + case .allowDepositors: + return "Add a Depositor Badge" // FIXME: Strings + } + } + + var instructions: String { + switch self { + case .allowDenyAssets: + return "Enter the asset’s resource address (starting with “reso”)" // FIXME: Strings + case .allowDepositors: + return "Enter the badge’s resource address (starting with “reso”)" // FIXME: Strings + } + } +} diff --git a/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ResourcesList+Reducer.swift b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ResourcesList+Reducer.swift new file mode 100644 index 0000000000..474aa08d5d --- /dev/null +++ b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ResourcesList+Reducer.swift @@ -0,0 +1,271 @@ +import EngineKit +import FeaturePrelude +import OnLedgerEntitiesClient + +// MARK: - ResourcesListMode +public enum ResourcesListMode: Hashable, Sendable { + public typealias ExceptionRule = ThirdPartyDeposits.DepositAddressExceptionRule + case allowDenyAssets(ExceptionRule) + case allowDepositors +} + +// MARK: - ResourceViewState +public struct ResourceViewState: Hashable, Sendable, Identifiable { + public enum Address: Hashable, Sendable { + case assetException(ThirdPartyDeposits.AssetException) + case allowedDepositor(ThirdPartyDeposits.DepositorAddress) + } + + public var id: Address { address } + + let iconURL: URL? + let name: String? + let address: Address +} + +// MARK: - ResourcesList +public struct ResourcesList: FeatureReducer, Sendable { + public struct State: Hashable, Sendable { + var allDepositorAddresses: OrderedSet { + switch mode { + case .allowDenyAssets: + return OrderedSet(thirdPartyDeposits.assetsExceptionList.map { .assetException($0) }) + case .allowDepositors: + return OrderedSet(thirdPartyDeposits.depositorsAllowList.map { .allowedDepositor($0) }) + } + } + + var resourcesForDisplay: [ResourceViewState] { + switch mode { + case let .allowDenyAssets(exception): + let addresses: [ResourceViewState.Address] = thirdPartyDeposits.assetsExceptionList + .filter { $0.exceptionRule == exception } + .map { .assetException($0) } + + return loadedResources.filter { addresses.contains($0.address) } + case .allowDepositors: + return loadedResources + } + } + + var mode: ResourcesListMode + var thirdPartyDeposits: ThirdPartyDeposits + var loadedResources: [ResourceViewState] = [] + + @PresentationState + var destinations: Destinations.State? = nil + } + + public enum ViewAction: Equatable, Sendable { + case task + case addAssetTapped + case assetRemove(ResourceViewState.Address) + case exceptionListChanged(ThirdPartyDeposits.DepositAddressExceptionRule) + } + + public enum ChildAction: Equatable, Sendable { + case destinations(PresentationAction) + } + + public enum DelegateAction: Equatable, Sendable { + case updated(ThirdPartyDeposits) + } + + public enum InternalAction: Equatable, Sendable { + case resourceLoaded(OnLedgerEntity.Resource?, ResourceViewState.Address) + case resourcesLoaded([OnLedgerEntity.Resource]?) + } + + public struct Destinations: ReducerProtocol, Sendable { + public enum State: Equatable, Hashable, Sendable { + case addAsset(AddAsset.State) + case confirmAssetDeletion(AlertState) + } + + public enum Action: Hashable, Sendable { + case addAsset(AddAsset.Action) + case confirmAssetDeletion(ConfirmDeletionAlert) + + public enum ConfirmDeletionAlert: Hashable, Sendable { + case confirmTapped(ResourceViewState.Address) + case cancelTapped + } + } + + public var body: some ReducerProtocolOf { + Scope(state: /State.addAsset, action: /Action.addAsset) { + AddAsset() + } + } + } + + @Dependency(\.onLedgerEntitiesClient) var onLedgerEntitiesClient + + public var body: some ReducerProtocolOf { + Reduce(core) + .ifLet(\.$destinations, action: /Action.child .. ChildAction.destinations) { + Destinations() + } + } + + public func reduce(into state: inout State, viewAction: ViewAction) -> EffectTask { + switch viewAction { + case .task: + let addresses: [ResourceAddress] = state.allDepositorAddresses.map(\.resourceAddress) + return .run { send in + let loadResourcesResult = try? await onLedgerEntitiesClient.getResources(addresses) + await send(.internal(.resourcesLoaded(loadResourcesResult))) + } + + case .addAssetTapped: + state.destinations = .addAsset(.init( + mode: state.mode, + alreadyAddedResources: state.allDepositorAddresses + )) + return .none + + case let .assetRemove(resource): + state.destinations = .confirmAssetDeletion(.confirmAssetDeletion( + state.mode.removeTitle, + state.mode.removeConfirmationMessage, + resourceAddress: resource + )) + return .none + + case let .exceptionListChanged(exception): + state.mode = .allowDenyAssets(exception) + return .none + } + } + + public func reduce(into state: inout State, childAction: ChildAction) -> EffectTask { + switch childAction { + case let .destinations(.presented(.addAsset(.delegate(.addAddress(mode, newAsset))))): + state.mode = mode + state.destinations = nil + + return .run { send in + let loadResourceResult = try? await onLedgerEntitiesClient.getResource(newAsset.resourceAddress) + await send(.internal(.resourceLoaded(loadResourceResult, newAsset))) + } + + case let .destinations(.presented(.confirmAssetDeletion(.confirmTapped(resource)))): + state.loadedResources.removeAll(where: { $0.address == resource }) + switch resource { + case let .assetException(resource): + state.thirdPartyDeposits.assetsExceptionList.removeAll(where: { $0.address == resource.address }) + case let .allowedDepositor(depositorAddress): + state.thirdPartyDeposits.depositorsAllowList.remove(depositorAddress) + } + + return .send(.delegate(.updated(state.thirdPartyDeposits))) + case .destinations: + return .none + } + } + + public func reduce(into state: inout State, internalAction: InternalAction) -> EffectTask { + switch internalAction { + case let .resourceLoaded(resource, newAsset): + state.loadedResources.append(.init(iconURL: resource?.iconURL, name: resource?.name, address: newAsset)) + + switch newAsset { + case let .assetException(resource): + state.thirdPartyDeposits.assetsExceptionList.updateOrAppend(resource) + case let .allowedDepositor(depositorAddress): + state.thirdPartyDeposits.depositorsAllowList.updateOrAppend(depositorAddress) + } + + return .send(.delegate(.updated(state.thirdPartyDeposits))) + + case let .resourcesLoaded(resources): + guard let resources else { + state.loadedResources = state.allDepositorAddresses.map { + ResourceViewState(iconURL: nil, name: nil, address: $0) + } + return .none + } + + state.loadedResources = state.allDepositorAddresses.map { address in + let resourceDetails = resources.first { $0.resourceAddress == address.resourceAddress } + return .init( + iconURL: resourceDetails?.iconURL, + name: resourceDetails?.name, + address: address + ) + } + return .none + } + } +} + +extension AlertState { + static func confirmAssetDeletion( + _ title: String, + _ message: String, + resourceAddress: ResourceViewState.Address + ) -> AlertState { + AlertState { + TextState(title) + } actions: { + ButtonState(role: .destructive, action: .confirmTapped(resourceAddress)) { + TextState("Remove") // FIXME: Strings + } + ButtonState(role: .cancel, action: .cancelTapped) { + TextState("Cancel") // FIXME: Strings + } + } message: { + TextState(message) + } + } +} + +extension ResourceViewState.Address { + var resourceAddress: ResourceAddress { + switch self { + case let .assetException(resource): + return resource.address + case let .allowedDepositor(depositorAddress): + return depositorAddress.resourceAddress + } + } +} + +extension ResourcesListMode { + var removeTitle: String { + switch self { + case .allowDenyAssets: + return "Remove Asset" // FIXME: Strings + case .allowDepositors: + return "Remove Depositor Badge" // FIXME: Strings + } + } + + var removeConfirmationMessage: String { + switch self { + case .allowDenyAssets(.allow): + return "The asset will be removed from the allow list" // FIXME: Strings + case .allowDenyAssets(.deny): + return "The asset will be removed from the deny list" // FIXME: Strings + case .allowDepositors: + return "The badge will be removed from the list" // FIXME: Strings + } + } +} + +extension ThirdPartyDeposits.DepositorAddress { + var resourceAddress: ResourceAddress { + switch self { + case let .resourceAddress(address): + return address + case let .nonFungibleGlobalID(nonFungibleGlobalID): + return try! nonFungibleGlobalID.resourceAddress().asSpecific() + } + } +} + +extension ThirdPartyDeposits.AssetException { + func updateExceptionRule(_ rule: ThirdPartyDeposits.DepositAddressExceptionRule) -> Self { + .init(address: address, exceptionRule: rule) + } +} diff --git a/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ResourcesList+View.swift b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ResourcesList+View.swift new file mode 100644 index 0000000000..de9725db57 --- /dev/null +++ b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ResourcesList+View.swift @@ -0,0 +1,212 @@ +import EngineKit +import FeaturePrelude + +extension ResourcesList.State { + var viewState: ResourcesList.ViewState { + .init( + resources: .init(uncheckedUniqueElements: resourcesForDisplay), + info: { + switch mode { + case .allowDenyAssets(.allow) where resourcesForDisplay.isEmpty: + return "Add a specific asset by its resource address to allow all third-party deposits" // FIXME: Strings + case .allowDenyAssets(.allow): + return "The following resource addresses may always be deposited to this account by third parties." // FIXME: Strings + case .allowDenyAssets(.deny) where resourcesForDisplay.isEmpty: + return "Add a specific asset by its resource address to deny all third-party deposits" // FIXME: Strings + case .allowDenyAssets(.deny): + return "The following resource addresses may never be deposited to this account by third parties." // FIXME: Strings + case .allowDepositors where resourcesForDisplay.isEmpty: + return "Add a specific badge by its resource address to allow all deposits from its holder." // FIXME: Strings + case .allowDepositors: + return "The holder of the following badges may always deposit accounts to this account." // FIXME: Strings + } + }(), + mode: mode + ) + } +} + +extension ResourcesList { + public struct ViewState: Equatable { + let resources: IdentifiedArrayOf + let info: String + let mode: ResourcesListMode + } + + @MainActor + public struct View: SwiftUI.View { + let store: StoreOf + init(store: StoreOf) { + self.store = store + } + + public var body: some SwiftUI.View { + WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in + VStack(spacing: .medium1) { + headerView(viewStore) + if !viewStore.resources.isEmpty { + listView(viewStore) + } + Spacer(minLength: 0) + } + .onFirstTask { @MainActor in + await viewStore.send(.task).finish() + } + .padding(.top, .medium1) + .background(.app.gray5) + .destination(store: store) + .navigationTitle(viewStore.mode.navigationTitle) + .defaultNavBarConfig() + .footer { + Button(viewStore.mode.addButtonTitle) { + viewStore.send(.addAssetTapped) + } + .buttonStyle(.primaryRectangular) + } + } + } + } +} + +extension ResourcesList.View { + @ViewBuilder + func headerView(_ viewStore: ViewStoreOf) -> some SwiftUI.View { + Group { + if case let .allowDenyAssets(exceptionRule) = viewStore.mode { + Picker( + "Select expcetion list", + selection: viewStore.binding( + get: { _ in exceptionRule }, + send: { .exceptionListChanged($0) } + ) + ) { + ForEach(ThirdPartyDeposits.DepositAddressExceptionRule.allCases, id: \.self) { + Text($0.text) + } + } + .pickerStyle(.segmented) + } + + Text(viewStore.info) + .textStyle(.body1HighImportance) + .foregroundColor(.app.gray2) + .multilineTextAlignment(.center) + } + .padding(.horizontal, .medium1) + } + + @ViewBuilder + func listView(_ viewStore: ViewStoreOf) -> some SwiftUI.View { + List { + ForEach(viewStore.resources) { row in + resourceRowView(row, viewStore) + } + } + .scrollContentBackground(.hidden) + .listStyle(.grouped) + } + + @ViewBuilder + func resourceRowView(_ viewState: ResourceViewState, _ viewStore: ViewStoreOf) -> some SwiftUI.View { + HStack { + if case .globalNonFungibleResourceManager = viewState.address.resourceAddress.decodedKind { + NFTThumbnail(viewState.iconURL) + } else { + TokenThumbnail(.known(viewState.iconURL)) + } + + VStack(alignment: .leading, spacing: .zero) { + Text(viewState.name ?? "") + .textStyle(.body1HighImportance) + .foregroundColor(.app.gray1) + AddressView( + viewState.address.ledgerIdentifiable, + isTappable: false + ) + .foregroundColor(.app.gray2) + } + .padding(.leading, .medium3) + + Spacer(minLength: 0) + AssetIcon(.asset(AssetResource.trash)) + .onTapGesture { + viewStore.send(.assetRemove(viewState.address)) + } + } + .frame(minHeight: .largeButtonHeight) + } +} + +private extension View { + @MainActor + func destination(store: StoreOf) -> some View { + let destinationStore = store.scope(state: \.$destinations, action: { .child(.destinations($0)) }) + return addAsset(with: destinationStore) + .confirmDeletionAlert(with: destinationStore) + } + + @MainActor + func addAsset(with destinationStore: PresentationStoreOf) -> some View { + sheet( + store: destinationStore, + state: /ResourcesList.Destinations.State.addAsset, + action: ResourcesList.Destinations.Action.addAsset, + content: { AddAsset.View(store: $0) } + ) + } + + @MainActor + func confirmDeletionAlert(with destinationStore: PresentationStoreOf) -> some View { + alert( + store: destinationStore, + state: /ResourcesList.Destinations.State.confirmAssetDeletion, + action: ResourcesList.Destinations.Action.confirmAssetDeletion + ) + } +} + +extension ThirdPartyDeposits.DepositAddressExceptionRule { + var text: String { + switch self { + case .allow: + return "Allow" // FIXME: Strings + case .deny: + return "Deny" // FIXME: Strings + } + } +} + +extension ResourcesListMode { + var addButtonTitle: String { + switch self { + case .allowDenyAssets: + return "Add Asset" // FIXME: Strings + case .allowDepositors: + return "Add Depositor Badge" // FIXME: Strings + } + } + + var navigationTitle: String { + switch self { + case .allowDenyAssets: + return "Allow/Deny Specific Assets" // FIXME: Strings + case .allowDepositors: + return "Allow Specific Depositors" // FIXME: Strings + } + } +} + +extension ResourceViewState.Address { + var ledgerIdentifiable: LedgerIdentifiable { + switch self { + case let .assetException(exception): + return .address(.resource(exception.address)) + + case let .allowedDepositor(.resourceAddress(resourceAddress)): + return .address(.resource(resourceAddress)) + + case let .allowedDepositor(.nonFungibleGlobalID(nonFungibleGlobalID)): + return .address(.nonFungibleGlobalID(nonFungibleGlobalID)) + } + } +} diff --git a/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ThirdPartyDeposits+Reducer.swift b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ThirdPartyDeposits+Reducer.swift new file mode 100644 index 0000000000..f10092065a --- /dev/null +++ b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ThirdPartyDeposits+Reducer.swift @@ -0,0 +1,114 @@ +import AccountsClient +import EngineKit +import FeaturePrelude +import OverlayWindowClient + +public typealias ThirdPartyDeposits = Profile.Network.Account.OnLedgerSettings.ThirdPartyDeposits + +// MARK: - ManageThirdPartyDeposits +public struct ManageThirdPartyDeposits: FeatureReducer, Sendable { + public struct State: Hashable, Sendable { + var account: Profile.Network.Account + + var depositRule: ThirdPartyDeposits.DepositRule { + account.onLedgerSettings.thirdPartyDeposits.depositRule + } + + @PresentationState + var destinations: Destinations.State? = nil + + init(account: Profile.Network.Account) { + self.account = account + } + } + + public enum ViewAction: Equatable, Sendable { + case updateTapped + case rowTapped(ManageThirdPartyDeposits.Section.Row) + } + + public enum DelegateAction: Equatable, Sendable { + case accountUpdated + } + + public enum ChildAction: Equatable, Sendable { + case destinations(PresentationAction) + } + + public struct Destinations: ReducerProtocol, Sendable { + public enum State: Equatable, Hashable, Sendable { + case allowDenyAssets(ResourcesList.State) + case allowDepositors(ResourcesList.State) + } + + public enum Action: Equatable, Sendable { + case allowDenyAssets(ResourcesList.Action) + case allowDepositors(ResourcesList.Action) + } + + public var body: some ReducerProtocolOf { + Scope(state: /State.allowDenyAssets, action: /Action.allowDenyAssets) { + ResourcesList() + } + + Scope(state: /State.allowDepositors, action: /Action.allowDepositors) { + ResourcesList() + } + } + } + + public var body: some ReducerProtocolOf { + Reduce(core) + .ifLet(\.$destinations, action: /Action.child .. ChildAction.destinations) { + Destinations() + } + } + + public func reduce(into state: inout State, viewAction: ViewAction) -> EffectTask { + switch viewAction { + case let .rowTapped(row): + switch row { + case let .depositRule(rule): + state.account.onLedgerSettings.thirdPartyDeposits.depositRule = rule + + case .allowDenyAssets: + state.destinations = .allowDenyAssets(.init( + mode: .allowDenyAssets(.allow), + thirdPartyDeposits: state.account.onLedgerSettings.thirdPartyDeposits + )) + + case .allowDepositors: + state.destinations = .allowDepositors(.init( + mode: .allowDepositors, + thirdPartyDeposits: state.account.onLedgerSettings.thirdPartyDeposits + )) + } + return .none + + case .updateTapped: + @Dependency(\.accountsClient) var accountsClient + @Dependency(\.errorQueue) var errorQueue + + return .run { [account = state.account] send in + do { + try await accountsClient.updateAccount(account) + // TODO: schedule TX + await send(.delegate(.accountUpdated)) + } catch { + errorQueue.schedule(error) + } + } + } + } + + public func reduce(into state: inout State, childAction: ChildAction) -> EffectTask { + switch childAction { + case let .destinations(.presented(.allowDenyAssets(.delegate(.updated(thirdPartyDeposits))))), + let .destinations(.presented(.allowDepositors(.delegate(.updated(thirdPartyDeposits))))): + state.account.onLedgerSettings.thirdPartyDeposits = thirdPartyDeposits + return .none + case .destinations: + return .none + } + } +} diff --git a/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ThirdPartyDeposits+View.swift b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ThirdPartyDeposits+View.swift new file mode 100644 index 0000000000..403d17203f --- /dev/null +++ b/Sources/Features/AccountPreferencesFeature/Children/ThirdPartyDeposits/ThirdPartyDeposits+View.swift @@ -0,0 +1,153 @@ +import FeaturePrelude + +extension ManageThirdPartyDeposits.State { + var viewState: ManageThirdPartyDeposits.ViewState { + .init(sections: [ + .init( + id: .depositRules, + title: "Choose if you want to allow third-parties to directly deposit assets into your account. Deposits that you approve yourself in your Radix Wallet are always accepted.", + rows: [ + .acceptAllMode(), + .acceptKnownMode(), + .denyAllMode(), + ], + mode: .selection(.depositRule(depositRule)) + ), + .init( + id: .allowDenyAssets, + title: "", + rows: [.allowDenyAssets()] + ), + .init(id: .allowDepositors, title: nil, rows: [.allowDepositors()]), + ]) + } +} + +extension ManageThirdPartyDeposits { + public struct ViewState: Equatable { + let sections: [PreferenceSection.ViewState] + } + + @MainActor + public struct View: SwiftUI.View { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + public var body: some SwiftUI.View { + WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in + PreferencesList( + viewState: .init(sections: viewStore.sections), + onRowSelected: { _, row in viewStore.send(.rowTapped(row)) } + ) + .background(.app.gray5) + .navigationTitle("Third-party Deposits") // FIXME: strings + .defaultNavBarConfig() + .destination(store: store) + .footer { + Button("Update", action: { viewStore.send(.updateTapped) }).buttonStyle(.primaryRectangular) + } + } + } + } +} + +// MARK: - ManageThirdPartyDeposits.Section +extension ManageThirdPartyDeposits { + public enum Section: Hashable, Sendable { + case depositRules + case allowDenyAssets + case allowDepositors + + public enum Row: Hashable, Sendable { + case depositRule(ThirdPartyDeposits.DepositRule) + case allowDenyAssets(AllowDenyAssetsRow) + case allowDepositors(AllowDepositorsRow) + } + + public enum AllowDenyAssetsRow: Hashable, Sendable { + case allowDenyAssets + } + + public enum AllowDepositorsRow: Hashable, Sendable { + case allowDepositors + } + } +} + +extension PreferenceSection.Row where SectionId == ManageThirdPartyDeposits.Section, RowId == ManageThirdPartyDeposits.Section.Row { + static func acceptAllMode() -> Self { + .init( + id: .depositRule(.acceptAll), + title: "Accept All", // FIXME: strings + subtitle: "Allow third parties to deposit any asset", + icon: .asset(AssetResource.iconAcceptAirdrop) + ) + } + + static func acceptKnownMode() -> Self { + .init( + id: .depositRule(.acceptKnown), + title: "Only accept known", // FIXME: strings + subtitle: "Allow third parties to deposit only assets this account has held", + icon: .asset(AssetResource.iconAcceptKnownAirdrop) + ) + } + + static func denyAllMode() -> Self { + .init( + id: .depositRule(.denyAll), + title: "Deny all", // FIXME: strings + subtitle: "Deny all third parties deposits", // FIXME: strings + hint: "This account will not be able to receive “air drops” or be used by a trusted contact to assist with account recovery.", // FIXME: strings + icon: .asset(AssetResource.iconDeclineAirdrop) + ) + } + + static func allowDenyAssets() -> Self { + .init( + id: .allowDenyAssets(.allowDenyAssets), + title: "Allow/Deny specific assets", // FIXME: strings + subtitle: "Deny or allow third-party deposits of specific assets, ignoring the setting above" + ) + } + + static func allowDepositors() -> Self { + .init( + id: .allowDepositors(.allowDepositors), + title: "Allow specific depositors", // FIXME: strings + subtitle: "Allow certain third party depositors to deposit assets freely" + ) + } +} + +extension View { + @MainActor + func destination(store: StoreOf) -> some View { + let destinationStore = store.scope(state: \.$destinations, action: { .child(.destinations($0)) }) + return allowDenyAssets(with: destinationStore) + .allowDepositors(with: destinationStore) + } + + @MainActor + func allowDenyAssets(with destinationStore: PresentationStoreOf) -> some View { + navigationDestination( + store: destinationStore, + state: /ManageThirdPartyDeposits.Destinations.State.allowDenyAssets, + action: ManageThirdPartyDeposits.Destinations.Action.allowDenyAssets, + destination: { ResourcesList.View(store: $0) } + ) + } + + @MainActor + func allowDepositors(with destinationStore: PresentationStoreOf) -> some View { + navigationDestination( + store: destinationStore, + state: /ManageThirdPartyDeposits.Destinations.State.allowDepositors, + action: ManageThirdPartyDeposits.Destinations.Action.allowDepositors, + destination: { ResourcesList.View(store: $0) } + ) + } +} diff --git a/Sources/Features/AccountPreferencesFeature/UpdateAccountLabel+Reducer.swift b/Sources/Features/AccountPreferencesFeature/Children/UpdateAccountLabel+Reducer.swift similarity index 87% rename from Sources/Features/AccountPreferencesFeature/UpdateAccountLabel+Reducer.swift rename to Sources/Features/AccountPreferencesFeature/Children/UpdateAccountLabel+Reducer.swift index da25bba83c..7693518aa8 100644 --- a/Sources/Features/AccountPreferencesFeature/UpdateAccountLabel+Reducer.swift +++ b/Sources/Features/AccountPreferencesFeature/Children/UpdateAccountLabel+Reducer.swift @@ -7,16 +7,18 @@ public struct UpdateAccountLabel: FeatureReducer { public struct State: Hashable, Sendable { var account: Profile.Network.Account var accountLabel: String + var sanitizedName: NonEmptyString? init(account: Profile.Network.Account) { self.account = account self.accountLabel = account.displayName.rawValue + self.sanitizedName = account.displayName } } public enum ViewAction: Equatable { case accountLabelChanged(String) - case updateTapped(NonEmpty) + case updateTapped(NonEmptyString) } public enum DelegateAction: Equatable { @@ -31,9 +33,12 @@ public struct UpdateAccountLabel: FeatureReducer { switch viewAction { case let .accountLabelChanged(label): state.accountLabel = label + state.sanitizedName = NonEmpty(rawValue: label.trimmingWhitespace()) return .none + case let .updateTapped(newLabel): state.account.displayName = newLabel + return .run { [account = state.account] send in do { try await accountsClient.updateAccount(account) diff --git a/Sources/Features/AccountPreferencesFeature/Children/UpdateAccountLabel+View.swift b/Sources/Features/AccountPreferencesFeature/Children/UpdateAccountLabel+View.swift new file mode 100644 index 0000000000..bfc0170473 --- /dev/null +++ b/Sources/Features/AccountPreferencesFeature/Children/UpdateAccountLabel+View.swift @@ -0,0 +1,82 @@ +import FeaturePrelude + +extension UpdateAccountLabel.State { + var viewState: UpdateAccountLabel.ViewState { + .init( + accountLabel: accountLabel, + sanitizedName: sanitizedName, + updateButtonControlState: sanitizedName == nil ? .disabled : .enabled, + hint: accountLabel.isEmpty ? .error("Account label required") : nil // FIXME: strings + ) + } +} + +extension UpdateAccountLabel { + public struct ViewState: Equatable { + let accountLabel: String + let sanitizedName: NonEmptyString? + let updateButtonControlState: ControlState + let hint: Hint? + } + + @MainActor + public struct View: SwiftUI.View { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + public var body: some SwiftUI.View { + WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in + VStack { + VStack(alignment: .center, spacing: .medium1) { + let nameBinding = viewStore.binding( + get: \.accountLabel, + send: { .accountLabelChanged($0) } + ) + AppTextField( + primaryHeading: "Enter a new label for this account", // FIXME: strings + placeholder: "Your account label", // FIXME: strings + text: nameBinding, + hint: viewStore.hint + ) + #if os(iOS) + .textFieldCharacterLimit(Profile.Network.Account.nameMaxLength, forText: nameBinding) + #endif + .keyboardType(.asciiCapable) + .autocorrectionDisabled() + + WithControlRequirements( + viewStore.sanitizedName, + forAction: { viewStore.send(.updateTapped($0)) } + ) { action in + Button("Update") { // FIXME: strings + action() + } + .buttonStyle(.primaryRectangular) + .controlState(viewStore.updateButtonControlState) + } + } + .padding(.large3) + .background(.app.background) + + Spacer(minLength: 0) + } + .background(.app.gray5) + .navigationTitle("Rename Account") // FIXME: strings + .defaultNavBarConfig() + } + } + } +} + +extension View { + func defaultNavBarConfig() -> some View { + navigationBarTitleColor(.app.gray1) + .navigationBarTitleDisplayMode(.inline) + .navigationBarInlineTitleFont(.app.secondaryHeader) + .toolbarBackground(.app.background, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + } +} diff --git a/Sources/Features/AccountPreferencesFeature/Common/PreferenceList.swift b/Sources/Features/AccountPreferencesFeature/Common/PreferenceList.swift new file mode 100644 index 0000000000..d0490e06c7 --- /dev/null +++ b/Sources/Features/AccountPreferencesFeature/Common/PreferenceList.swift @@ -0,0 +1,138 @@ +import FeaturePrelude + +// MARK: - PreferenceSection +struct PreferenceSection: View { + struct Row: Equatable { + var id: RowId + let title: String + let subtitle: String? + let hint: String? + let icon: AssetIcon.Content? + + init( + id: RowId, + title: String, + subtitle: String? = nil, + hint: String? = nil, + icon: AssetIcon.Content? = nil + ) { + self.id = id + self.title = title + self.subtitle = subtitle + self.hint = hint + self.icon = icon + } + } + + enum Mode: Equatable { + typealias SelectedRow = RowId + case selection(SelectedRow) + case disclosure + + func accessory(rowId: RowId) -> ImageAsset? { + switch self { + case let .selection(selection): + return rowId == selection ? AssetResource.check : nil + case .disclosure: + return AssetResource.chevronRight + } + } + } + + struct ViewState: Equatable { + var id: SectionId + let title: String? + let rows: [Row] + let mode: Mode + + init(id: SectionId, title: String?, rows: [Row], mode: Mode = .disclosure) { + self.id = id + self.title = title + self.rows = rows + self.mode = mode + } + } + + let viewState: ViewState + + var onRowSelected: (SectionId, RowId) -> Void + + var body: some View { + SwiftUI.Section { + ForEach(viewState.rows, id: \.id) { row in + HStack { + VStack(alignment: .leading) { + HStack(spacing: .medium3) { + if let icon = row.icon { + AssetIcon(icon) + } + PlainListRowCore(title: row.title, subtitle: row.subtitle) + } + + if let hint = row.hint { + // Align hint with the PlainListRowCore + Text(hint) + .textStyle(.body2Regular) + .foregroundColor(.app.alert) + .lineSpacing(-4) + .padding(.leading, HitTargetSize.verySmall.frame.width + .medium3) + .padding(.top, .medium3) + } + } + .frame(minHeight: .settingsRowHeight) + + Group { + Spacer() + if case let .selection(selection) = viewState.mode { + if row.id == selection { + Image(asset: AssetResource.check) + } else { + /// Put a placeholder for unselected items. + /// Note: `Spacer(minLength:)` does not work. + FixedSpacer(width: .medium1) + } + } else { + Image(asset: AssetResource.chevronRight) + } + } + } + .padding(.horizontal, .medium3) + .padding(.vertical, .small1) + .contentShape(Rectangle()) + .tappable { + onRowSelected(viewState.id, row.id) + } + .listRowInsets(EdgeInsets()) + } + } header: { + if let title = viewState.title { + Text(title) + .textStyle(.body1HighImportance) + .foregroundColor(.app.gray2) + } + } + .textCase(nil) + } +} + +// MARK: - PreferencesList +struct PreferencesList: View { + struct ViewState: Equatable { + let sections: [PreferenceSection.ViewState] + } + + let viewState: ViewState + + var onRowSelected: (SectionId, RowId) -> Void + + var body: some View { + List { + ForEach(viewState.sections, id: \.id) { section in + PreferenceSection(viewState: section, onRowSelected: onRowSelected) + } + } + .scrollContentBackground(.hidden) + .listStyle(.grouped) + .environment(\.defaultMinListHeaderHeight, 0) + } +} diff --git a/Sources/Features/AccountPreferencesFeature/UpdateAccountLabel+View.swift b/Sources/Features/AccountPreferencesFeature/UpdateAccountLabel+View.swift deleted file mode 100644 index ccd4e0a250..0000000000 --- a/Sources/Features/AccountPreferencesFeature/UpdateAccountLabel+View.swift +++ /dev/null @@ -1,64 +0,0 @@ -import FeaturePrelude - -extension UpdateAccountLabel.State { - var viewState: UpdateAccountLabel.ViewState { - .init( - accountLabel: accountLabel, - updateButtonControlState: accountLabel.isEmpty ? .disabled : .enabled, - hint: accountLabel.isEmpty ? .error("Account label required") : nil // FIXME: strings - ) - } -} - -extension UpdateAccountLabel { - public struct ViewState: Equatable { - let accountLabel: String - let updateButtonControlState: ControlState - let hint: Hint? - } - - @MainActor - public struct View: SwiftUI.View { - let store: StoreOf - - init(store: StoreOf) { - self.store = store - } - - public var body: some SwiftUI.View { - WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in - VStack(alignment: .center, spacing: .medium1) { - AppTextField( - primaryHeading: "Enter a new label for this account", // FIXME: strings - placeholder: "Your account label", // FIXME: strings - text: viewStore.binding( - get: \.accountLabel, - send: { .accountLabelChanged($0) } - ), - hint: viewStore.hint - ) - - WithControlRequirements( - NonEmpty(viewStore.accountLabel), - forAction: { viewStore.send(.updateTapped($0)) } - ) { action in - Button("Update") { // FIXME: strings - action() - } - .buttonStyle(.primaryRectangular) - .controlState(viewStore.updateButtonControlState) - } - - Spacer() - } - .padding(.large3) - .navigationTitle("Rename Account") // FIXME: strings - .navigationBarTitleColor(.app.gray1) - .navigationBarTitleDisplayMode(.inline) - .navigationBarInlineTitleFont(.app.secondaryHeader) - .toolbarBackground(.app.background, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - } - } - } -} diff --git a/Sources/Features/AuthorizedDAppsFeature/DappDetails/DappDetails+View.swift b/Sources/Features/AuthorizedDAppsFeature/DappDetails/DappDetails+View.swift index e00c8d0b37..1ffdb01480 100644 --- a/Sources/Features/AuthorizedDAppsFeature/DappDetails/DappDetails+View.swift +++ b/Sources/Features/AuthorizedDAppsFeature/DappDetails/DappDetails+View.swift @@ -191,7 +191,7 @@ extension DappDetails.View { Card { action(element.id) } contents: { - PlainListRow(title: title(element), showChevron: false) { + PlainListRow(title: title(element), accessory: nil) { icon(element) } } diff --git a/Sources/Features/PersonaDetailsFeature/PersonaDetails+View.swift b/Sources/Features/PersonaDetailsFeature/PersonaDetails+View.swift index 06792a788b..fa262386da 100644 --- a/Sources/Features/PersonaDetailsFeature/PersonaDetails+View.swift +++ b/Sources/Features/PersonaDetailsFeature/PersonaDetails+View.swift @@ -557,7 +557,7 @@ extension SimpleAuthDappDetails.View { ForEach(elements) { element in Card { - PlainListRow(title: title(element), showChevron: false) { + PlainListRow(title: title(element), accessory: nil) { icon(element) } } @@ -592,7 +592,7 @@ extension SimpleAuthDappDetails.View { ForEach(personas) { persona in Card { - PlainListRow(title: persona.displayName, showChevron: false) { + PlainListRow(title: persona.displayName, accessory: nil) { PersonaThumbnail(persona.thumbnail) } } diff --git a/Sources/Features/TransactionReviewFeature/TransactionReview+View.swift b/Sources/Features/TransactionReviewFeature/TransactionReview+View.swift index f2d4f8a53f..99c7ed49cf 100644 --- a/Sources/Features/TransactionReviewFeature/TransactionReview+View.swift +++ b/Sources/Features/TransactionReviewFeature/TransactionReview+View.swift @@ -590,7 +590,7 @@ extension SimpleDappDetails.View { ForEach(elements) { element in Card { - PlainListRow(title: title(element), showChevron: false) { + PlainListRow(title: title(element), accessory: nil) { icon(element) } }