Skip to content

Commit

Permalink
IOS-8236 Token selector view and buy from action buttons
Browse files Browse the repository at this point in the history
  • Loading branch information
Vyachesl0ve committed Nov 4, 2024
1 parent 9836302 commit ebbb504
Show file tree
Hide file tree
Showing 28 changed files with 846 additions and 120 deletions.
6 changes: 6 additions & 0 deletions Tangem/Common/Extensions/Array+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ extension Array {
}
}
}

extension Array {
var isNotEmpty: Bool {
return !isEmpty
}
}
77 changes: 50 additions & 27 deletions Tangem/Common/UI/GroupedScrollView/GroupedSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,65 +22,88 @@ import SwiftUI
`- footerSpacing`
`Footer`
*/
struct GroupedSection<Model: Identifiable, Content: View, Footer: View, Header: View>: View {
struct GroupedSection<Model: Identifiable, Content: View, Footer: View, Header: View, EmptyContent: View>: View {
private let models: [Model]
private let content: (Model) -> Content
private let header: () -> Header
private let footer: () -> Footer
private let emptyContent: () -> EmptyContent

private var settings: Settings = .init()

private var isEmptyContentRequired: Bool {
EmptyContent.self != EmptyView.self
}

init(
_ models: [Model],
@ViewBuilder content: @escaping (Model) -> Content,
@ViewBuilder header: @escaping () -> Header = { EmptyView() },
@ViewBuilder footer: @escaping () -> Footer = { EmptyView() }
@ViewBuilder footer: @escaping () -> Footer = { EmptyView() },
@ViewBuilder emptyContent: @escaping () -> EmptyContent = { EmptyView() }
) {
self.models = models
self.content = content
self.header = header
self.footer = footer
self.emptyContent = emptyContent
}

init(
_ model: Model?,
@ViewBuilder content: @escaping (Model) -> Content,
@ViewBuilder header: @escaping () -> Header = { EmptyView() },
@ViewBuilder footer: @escaping () -> Footer = { EmptyView() }
@ViewBuilder footer: @escaping () -> Footer = { EmptyView() },
@ViewBuilder emptyContent: @escaping () -> EmptyContent = { EmptyView() }
) {
models = model.map { [$0] } ?? []
self.content = content
self.header = header
self.footer = footer
self.emptyContent = emptyContent
}

var body: some View {
if !models.isEmpty {
VStack(alignment: .leading, spacing: GroupedSectionConstants.footerSpacing) {
VStack(alignment: settings.contentAlignment, spacing: settings.interItemSpacing) {
header()
.padding(.horizontal, settings.horizontalPadding)

ForEach(models) { model in
content(model)
.padding(.horizontal, settings.horizontalPadding)

if models.last?.id != model.id {
separator
.matchedGeometryEffect(settings.separatorGeometryEffect(model))
}
}
}
.padding(.vertical, settings.innerContentPadding)
.background(
settings.backgroundColor
.matchedGeometryEffect(settings.backgroundGeometryEffect)
)
.cornerRadiusContinuous(GroupedSectionConstants.defaultCornerRadius)

footer()
if models.isNotEmpty || isEmptyContentRequired {
groupedContent
}
}

private var groupedContent: some View {
VStack(alignment: .leading, spacing: GroupedSectionConstants.footerSpacing) {
VStack(alignment: settings.contentAlignment, spacing: settings.interItemSpacing) {
header()
.padding(.horizontal, settings.horizontalPadding)

modelsList
}
.padding(.vertical, settings.innerContentPadding)
.background(
settings.backgroundColor
.matchedGeometryEffect(settings.backgroundGeometryEffect)
)
.cornerRadiusContinuous(GroupedSectionConstants.defaultCornerRadius)

footer()
.padding(.horizontal, settings.horizontalPadding)
}
}

@ViewBuilder
private var modelsList: some View {
if models.isNotEmpty {
ForEach(models) { model in
content(model)
.padding(.horizontal, settings.horizontalPadding)

if models.last?.id != model.id {
separator
.matchedGeometryEffect(settings.separatorGeometryEffect(model))
}
}
} else {
emptyContent()
.padding(.horizontal, settings.horizontalPadding)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// ActionButtonsTokenSelectorItemBuilder.swift
// TangemApp
//
// Created by GuitarKitty on 01.11.2024.
// Copyright © 2024 Tangem AG. All rights reserved.
//

struct ActionButtonsTokenSelectorItemBuilder: TokenSelectorItemBuilder {
func map(from walletModel: WalletModel, isDisabled: Bool) -> ActionButtonsTokenSelectorItem {
let tokenIconInfo = TokenIconInfoBuilder().build(from: walletModel.tokenItem, isCustom: walletModel.isCustom)

return ActionButtonsTokenSelectorItem(
id: walletModel.id,
tokenIconInfo: tokenIconInfo,
name: walletModel.tokenItem.name,
symbol: walletModel.tokenItem.currencySymbol,
balance: walletModel.balance,
fiatBalance: walletModel.fiatBalance,
isDisabled: isDisabled,
walletModel: walletModel
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// BuyTokenSelectorLocalizable.swift
// TangemApp
//
// Created by GuitarKitty on 05.11.2024.
// Copyright © 2024 Tangem AG. All rights reserved.
//

struct BuyTokenSelectorStrings: TokenSelectorLocalizable {
let availableTokensListTitle = "My tokens"
let unavailableTokensListTitle = "Unavailable to purchase"
let emptySearchMessage = "Token not found in your portfolio? Check the Markets to find and add it for purchase"
let emptyTokensMessage: String? = nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// ActionButtonsBuyCoordinator.swift
// TangemApp
//
// Created by GuitarKitty on 01.11.2024.
// Copyright © 2024 Tangem AG. All rights reserved.
//

import SwiftUI

final class ActionButtonsBuyCoordinator<
Builder: TokenSelectorItemBuilder
>: ObservableObject, Identifiable where Builder.TokenModel == ActionButtonsTokenSelectorItem {
@Injected(\.exchangeService) private var exchangeService: ExchangeService

private(set) var tokenSelectorViewModel: TokenSelectorViewModel<
ActionButtonsTokenSelectorItem,
ActionButtonsTokenSelectorItemBuilder
>?

private let expressTokensListAdapter: ExpressTokensListAdapter
private let tokenSorter: BuyTokenAvailabilitySorter
private let tokenSelectorItemBuilder: Builder
private let openBuy: (URL) -> Void

init(
expressTokensListAdapter: some ExpressTokensListAdapter,
tokenSorter: some BuyTokenAvailabilitySorter = CommonBuyTokenAvailabilitySorter(),
tokenSelectorItemBuilder: Builder = ActionButtonsTokenSelectorItemBuilder(),
openBuy: @escaping (URL) -> Void
) {
self.expressTokensListAdapter = expressTokensListAdapter
self.tokenSorter = tokenSorter
self.tokenSelectorItemBuilder = tokenSelectorItemBuilder
self.openBuy = openBuy

tokenSelectorViewModel = makeTokenSelectorViewModel()
}

func openBuy(for token: ActionButtonsTokenSelectorItem) {
guard
let buyUrl = exchangeService.getBuyUrl(
currencySymbol: token.symbol,
amountType: token.walletModel.amountType,
blockchain: token.walletModel.blockchainNetwork.blockchain,
walletAddress: token.walletModel.defaultAddress
)
else {
return
}

openBuy(buyUrl)
}

private func makeTokenSelectorViewModel() -> TokenSelectorViewModel<
ActionButtonsTokenSelectorItem,
ActionButtonsTokenSelectorItemBuilder
> {
TokenSelectorViewModel(
tokenSelectorItemBuilder: ActionButtonsTokenSelectorItemBuilder(),
strings: BuyTokenSelectorStrings(),
expressTokensListAdapter: expressTokensListAdapter,
sortModels: tokenSorter.sortModels(walletModels:)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// ActionButtonsBuyCoordinatorView.swift
// TangemApp
//
// Created by GuitarKitty on 01.11.2024.
// Copyright © 2024 Tangem AG. All rights reserved.
//

import SwiftUI

struct ActionButtonsBuyView: View {
@ObservedObject var coordinator: ActionButtonsBuyCoordinator<ActionButtonsTokenSelectorItemBuilder>
@Environment(\.dismiss) private var dismiss

var body: some View {
NavigationView {
content
.navigationTitle(Localization.commonBuy)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
CloseButton(dismiss: dismiss.callAsFunction)
}
}
}
}

@ViewBuilder
private var content: some View {
if let tokenSelectorViewModel = coordinator.tokenSelectorViewModel {
TokenSelectorView(
viewModel: tokenSelectorViewModel,
tokenCellContent: { token in
ActionButtonsTokenSelectItemView(model: token)
.onTapGesture {
coordinator.openBuy(for: token)
}
},
emptySearchContent: {
Text(tokenSelectorViewModel.strings.emptySearchMessage)
.font(Fonts.Regular.caption2)
.foregroundStyle(Colors.Text.tertiary)
.multilineTextAlignment(.center)
.animation(.default, value: tokenSelectorViewModel.searchText)
}
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// CommonBuyTokenAvailabilitySorter.swift
// TangemApp
//
// Created by GuitarKitty on 01.11.2024.
// Copyright © 2024 Tangem AG. All rights reserved.
//

protocol BuyTokenAvailabilitySorter {
func sortModels(walletModels: [WalletModel]) -> (availableModels: [WalletModel], unavailableModels: [WalletModel])
}

struct CommonBuyTokenAvailabilitySorter: BuyTokenAvailabilitySorter {
@Injected(\.exchangeService) private var exchangeService: ExchangeService

func sortModels(walletModels: [WalletModel]) -> (availableModels: [WalletModel], unavailableModels: [WalletModel]) {
walletModels.reduce(into: (availableModels: [WalletModel](), unavailableModels: [WalletModel]())) { result, walletModel in
if exchangeService.canBuy(
walletModel.tokenItem.currencySymbol,
amountType: walletModel.amountType,
blockchain: walletModel.blockchainNetwork.blockchain
) {
result.availableModels.append(walletModel)
} else {
result.unavailableModels.append(walletModel)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import Foundation

protocol ActionButtonsRoutable {
func openBuy()
func openBuy(userWalletModel: UserWalletModel)
func openSwap()
func openSell()
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,26 @@ protocol ActionButtonsFactory {
final class CommonActionButtonsFactory: ActionButtonsFactory {
private let coordinator: ActionButtonsRoutable
private let actionButtons: [ActionButtonModel]
private let userWalletModel: UserWalletModel

init(coordinator: some ActionButtonsRoutable, actionButtons: [ActionButtonModel]) {
init(coordinator: some ActionButtonsRoutable, actionButtons: [ActionButtonModel], userWalletModel: UserWalletModel) {
self.coordinator = coordinator
self.actionButtons = actionButtons
self.userWalletModel = userWalletModel
}

func makeActionButtonViewModels() -> [ActionButtonViewModel] {
actionButtons.map { dataModel in
.init(from: dataModel, coordinator: coordinator)
.init(from: dataModel, coordinator: coordinator, userWalletModel: userWalletModel)
}
}
}

private extension ActionButtonViewModel {
convenience init(from dataModel: ActionButtonModel, coordinator: ActionButtonsRoutable) {
convenience init(from dataModel: ActionButtonModel, coordinator: ActionButtonsRoutable, userWalletModel: UserWalletModel) {
let didTapAction: () -> Void = {
switch dataModel {
case .buy: coordinator.openBuy
case .buy: { coordinator.openBuy(userWalletModel: userWalletModel) }
case .swap: coordinator.openSwap
case .sell: coordinator.openSell
}
Expand Down
Loading

0 comments on commit ebbb504

Please sign in to comment.