Split fungible token view controller tabs to separate view controllers, add fungible token coordinator #5852
parent
264ee4ebd6
commit
e2ac16d3a3
@ -0,0 +1,172 @@ |
|||||||
|
// |
||||||
|
// PriceAlertsViewController.swift |
||||||
|
// AlphaWallet |
||||||
|
// |
||||||
|
// Created by Vladyslav Shepitko on 12.05.2021. |
||||||
|
// |
||||||
|
|
||||||
|
import UIKit |
||||||
|
import AlphaWalletFoundation |
||||||
|
import StatefulViewController |
||||||
|
import Combine |
||||||
|
|
||||||
|
protocol PriceAlertsViewControllerDelegate: class { |
||||||
|
func editAlertSelected(in viewController: PriceAlertsViewController, alert: PriceAlert) |
||||||
|
func addAlertSelected(in viewController: PriceAlertsViewController) |
||||||
|
} |
||||||
|
|
||||||
|
class PriceAlertsViewController: UIViewController { |
||||||
|
private let viewModel: PriceAlertsViewModel |
||||||
|
private lazy var dataSource = makeDataSource() |
||||||
|
private lazy var tableView: UITableView = { |
||||||
|
var tableView = UITableView.grouped |
||||||
|
tableView.register(PriceAlertTableViewCell.self) |
||||||
|
tableView.delegate = self |
||||||
|
|
||||||
|
return tableView |
||||||
|
}() |
||||||
|
private let updateAlert = PassthroughSubject<(value: Bool, indexPath: IndexPath), Never>() |
||||||
|
private let removeAlert = PassthroughSubject<IndexPath, Never>() |
||||||
|
private var cancelable = Set<AnyCancellable>() |
||||||
|
|
||||||
|
private lazy var addNotificationView: AddHideTokensView = { |
||||||
|
let view = AddHideTokensView() |
||||||
|
view.delegate = self |
||||||
|
|
||||||
|
return view |
||||||
|
}() |
||||||
|
weak var delegate: PriceAlertsViewControllerDelegate? |
||||||
|
|
||||||
|
init(viewModel: PriceAlertsViewModel) { |
||||||
|
self.viewModel = viewModel |
||||||
|
super.init(nibName: nil, bundle: nil) |
||||||
|
|
||||||
|
let stackView = [ |
||||||
|
addNotificationView, |
||||||
|
UIView.separator(), |
||||||
|
tableView |
||||||
|
].asStackView(axis: .vertical) |
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||||
|
|
||||||
|
view.addSubview(stackView) |
||||||
|
|
||||||
|
NSLayoutConstraint.activate([ |
||||||
|
stackView.anchorsConstraint(to: view), |
||||||
|
addNotificationView.heightAnchor.constraint(equalToConstant: DataEntry.Metric.Tokens.Filter.height) |
||||||
|
]) |
||||||
|
|
||||||
|
emptyView = EmptyView.priceAlertsEmptyView() |
||||||
|
} |
||||||
|
|
||||||
|
required init?(coder: NSCoder) { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
override func viewDidLoad() { |
||||||
|
super.viewDidLoad() |
||||||
|
|
||||||
|
view.backgroundColor = Configuration.Color.Semantic.defaultViewBackground |
||||||
|
bind(viewModel: viewModel) |
||||||
|
} |
||||||
|
|
||||||
|
private func bind(viewModel: PriceAlertsViewModel) { |
||||||
|
let input = PriceAlertsViewModelInput( |
||||||
|
updateAlert: updateAlert.eraseToAnyPublisher(), |
||||||
|
removeAlert: removeAlert.eraseToAnyPublisher()) |
||||||
|
|
||||||
|
let outout = viewModel.transform(input: input) |
||||||
|
outout.viewState |
||||||
|
.sink { [weak self, dataSource, addNotificationView] viewState in |
||||||
|
dataSource.apply(viewState.snapshot, animatingDifferences: viewState.animatingDifferences) |
||||||
|
addNotificationView.configure(viewModel: viewState.addNewAlertViewModel) |
||||||
|
self?.endLoading() |
||||||
|
}.store(in: &cancelable) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension PriceAlertsViewController: StatefulViewController { |
||||||
|
func hasContent() -> Bool { |
||||||
|
return dataSource.snapshot().numberOfItems > 0 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension PriceAlertsViewController { |
||||||
|
private func makeDataSource() -> PriceAlertsViewModel.DataSource { |
||||||
|
PriceAlertsViewModel.DataSource(tableView: tableView) { tableView, indexPath, viewModel -> PriceAlertTableViewCell in |
||||||
|
let cell: PriceAlertTableViewCell = tableView.dequeueReusableCell(for: indexPath) |
||||||
|
cell.delegate = self |
||||||
|
cell.configure(viewModel: viewModel) |
||||||
|
|
||||||
|
return cell |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension PriceAlertsViewController: PriceAlertTableViewCellDelegate { |
||||||
|
func cell(_ cell: PriceAlertTableViewCell, didToggle value: Bool, indexPath: IndexPath) { |
||||||
|
updateAlert.send((value, indexPath)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension PriceAlertsViewController: UITableViewDelegate { |
||||||
|
|
||||||
|
//Hide the footer |
||||||
|
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { |
||||||
|
.leastNormalMagnitude |
||||||
|
} |
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { |
||||||
|
nil |
||||||
|
} |
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { |
||||||
|
.leastNormalMagnitude |
||||||
|
} |
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { |
||||||
|
nil |
||||||
|
} |
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { |
||||||
|
tableView.deselectRow(at: indexPath, animated: true) |
||||||
|
|
||||||
|
delegate?.editAlertSelected(in: self, alert: dataSource.item(at: indexPath).alert) |
||||||
|
} |
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { |
||||||
|
return trailingSwipeActionsConfiguration(forRowAt: indexPath) |
||||||
|
} |
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { |
||||||
|
return .delete |
||||||
|
} |
||||||
|
|
||||||
|
private func trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { |
||||||
|
let title = R.string.localizable.delete() |
||||||
|
let hideAction = UIContextualAction(style: .destructive, title: title) { [removeAlert, dataSource] (_, _, completion) in |
||||||
|
var snapshot = dataSource.snapshot() |
||||||
|
|
||||||
|
let item = snapshot.itemIdentifiers(inSection: snapshot.sectionIdentifiers[indexPath.section])[indexPath.row] |
||||||
|
snapshot.deleteItems([item]) |
||||||
|
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true) |
||||||
|
|
||||||
|
removeAlert.send(indexPath) |
||||||
|
|
||||||
|
completion(true) |
||||||
|
} |
||||||
|
|
||||||
|
hideAction.backgroundColor = R.color.danger() |
||||||
|
hideAction.image = R.image.hideToken() |
||||||
|
let configuration = UISwipeActionsConfiguration(actions: [hideAction]) |
||||||
|
configuration.performsFirstActionWithFullSwipe = true |
||||||
|
|
||||||
|
return configuration |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension PriceAlertsViewController: AddHideTokensViewDelegate { |
||||||
|
func view(_ view: AddHideTokensView, didSelectAddHideTokensButton sender: UIButton) { |
||||||
|
delegate?.addAlertSelected(in: self) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
// |
||||||
|
// PriceAlertTableViewCellViewModel.swift |
||||||
|
// AlphaWallet |
||||||
|
// |
||||||
|
// Created by Vladyslav Shepitko on 19.11.2022. |
||||||
|
// |
||||||
|
|
||||||
|
import UIKit |
||||||
|
import AlphaWalletFoundation |
||||||
|
|
||||||
|
struct PriceAlertTableViewCellViewModel: Hashable { |
||||||
|
let alert: PriceAlert |
||||||
|
|
||||||
|
let titleAttributedString: NSAttributedString |
||||||
|
let icon: UIImage? |
||||||
|
let isSelected: Bool |
||||||
|
|
||||||
|
init(alert: PriceAlert) { |
||||||
|
self.alert = alert |
||||||
|
titleAttributedString = .init(string: alert.title, attributes: [ |
||||||
|
.font: Fonts.regular(size: 17), |
||||||
|
.foregroundColor: Configuration.Color.Semantic.defaultForegroundText |
||||||
|
]) |
||||||
|
icon = alert.icon |
||||||
|
isSelected = alert.isEnabled |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,77 @@ |
|||||||
|
// |
||||||
|
// PriceAlertTableViewCell.swift |
||||||
|
// AlphaWallet |
||||||
|
// |
||||||
|
// Created by Vladyslav Shepitko on 19.11.2022. |
||||||
|
// |
||||||
|
|
||||||
|
import UIKit |
||||||
|
|
||||||
|
protocol PriceAlertTableViewCellDelegate: class { |
||||||
|
func cell(_ cell: PriceAlertTableViewCell, didToggle value: Bool, indexPath: IndexPath) |
||||||
|
} |
||||||
|
|
||||||
|
class PriceAlertTableViewCell: UITableViewCell { |
||||||
|
private lazy var iconImageView: UIImageView = { |
||||||
|
let imageView = UIImageView() |
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false |
||||||
|
|
||||||
|
return imageView |
||||||
|
}() |
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = { |
||||||
|
let label = UILabel() |
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false |
||||||
|
|
||||||
|
return label |
||||||
|
}() |
||||||
|
|
||||||
|
private lazy var switchButton: UISwitch = { |
||||||
|
let button = UISwitch() |
||||||
|
button.translatesAutoresizingMaskIntoConstraints = false |
||||||
|
|
||||||
|
return button |
||||||
|
}() |
||||||
|
|
||||||
|
weak var delegate: PriceAlertTableViewCellDelegate? |
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { |
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier) |
||||||
|
separatorInset = .zero |
||||||
|
|
||||||
|
let stackView = [ |
||||||
|
.spacerWidth(16), |
||||||
|
iconImageView, |
||||||
|
.spacerWidth(16), |
||||||
|
titleLabel, |
||||||
|
.spacerWidth(16, flexible: true), |
||||||
|
switchButton, |
||||||
|
.spacerWidth(16), |
||||||
|
].asStackView(axis: .horizontal, alignment: .center) |
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||||
|
contentView.addSubview(stackView) |
||||||
|
|
||||||
|
NSLayoutConstraint.activate([ |
||||||
|
iconImageView.heightAnchor.constraint(equalToConstant: 18), |
||||||
|
iconImageView.widthAnchor.constraint(equalToConstant: 18), |
||||||
|
stackView.anchorsConstraint(to: contentView, edgeInsets: .init(top: 14, left: 0, bottom: 14, right: 0)) |
||||||
|
]) |
||||||
|
|
||||||
|
switchButton.addTarget(self, action: #selector(toggleSelectionState), for: .valueChanged) |
||||||
|
} |
||||||
|
|
||||||
|
required init?(coder: NSCoder) { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func configure(viewModel: PriceAlertTableViewCellViewModel) { |
||||||
|
iconImageView.image = viewModel.icon |
||||||
|
titleLabel.attributedText = viewModel.titleAttributedString |
||||||
|
switchButton.isEnabled = viewModel.isSelected |
||||||
|
} |
||||||
|
|
||||||
|
@objc private func toggleSelectionState(_ sender: UISwitch) { |
||||||
|
guard let indexPath = indexPath else { return } |
||||||
|
delegate?.cell(self, didToggle: sender.isEnabled, indexPath: indexPath) |
||||||
|
} |
||||||
|
} |
@ -1,260 +0,0 @@ |
|||||||
// |
|
||||||
// PriceAlertsPageView.swift |
|
||||||
// AlphaWallet |
|
||||||
// |
|
||||||
// Created by Vladyslav Shepitko on 12.05.2021. |
|
||||||
// |
|
||||||
|
|
||||||
import UIKit |
|
||||||
import AlphaWalletFoundation |
|
||||||
|
|
||||||
protocol PriceAlertsPageViewDelegate: class { |
|
||||||
func editAlertSelected(in view: PriceAlertsPageView, alert: PriceAlert) |
|
||||||
func addAlertSelected(in view: PriceAlertsPageView) |
|
||||||
func removeAlert(in view: PriceAlertsPageView, indexPath: IndexPath) |
|
||||||
func updateAlert(in view: PriceAlertsPageView, value: Bool, indexPath: IndexPath) |
|
||||||
} |
|
||||||
|
|
||||||
class PriceAlertsPageView: UIView, PageViewType { |
|
||||||
var title: String { viewModel.title } |
|
||||||
|
|
||||||
var rightBarButtonItem: UIBarButtonItem? |
|
||||||
|
|
||||||
private var viewModel: PriceAlertsPageViewModel |
|
||||||
|
|
||||||
private lazy var tableView: UITableView = { |
|
||||||
var tableView = UITableView.grouped |
|
||||||
tableView.register(PriceAlertTableViewCell.self) |
|
||||||
tableView.delegate = self |
|
||||||
tableView.dataSource = self |
|
||||||
|
|
||||||
return tableView |
|
||||||
}() |
|
||||||
|
|
||||||
private lazy var statefulView: StatefulView<UITableView> = { |
|
||||||
return .init(subview: tableView) |
|
||||||
}() |
|
||||||
|
|
||||||
private lazy var addNotificationView: AddHideTokensView = { |
|
||||||
let view = AddHideTokensView() |
|
||||||
view.delegate = self |
|
||||||
view.configure(viewModel: viewModel.addNewAlertViewModel) |
|
||||||
|
|
||||||
return view |
|
||||||
}() |
|
||||||
weak var delegate: PriceAlertsPageViewDelegate? |
|
||||||
|
|
||||||
init(viewModel: PriceAlertsPageViewModel) { |
|
||||||
self.viewModel = viewModel |
|
||||||
super.init(frame: .zero) |
|
||||||
|
|
||||||
translatesAutoresizingMaskIntoConstraints = false |
|
||||||
let stackView = [ |
|
||||||
addNotificationView.embededWithSeparator(top: 0), |
|
||||||
statefulView |
|
||||||
].asStackView(axis: .vertical) |
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
|
||||||
|
|
||||||
addSubview(stackView) |
|
||||||
|
|
||||||
NSLayoutConstraint.activate([ |
|
||||||
stackView.anchorsConstraintSafeArea(to: self), |
|
||||||
addNotificationView.heightAnchor.constraint(equalToConstant: DataEntry.Metric.Tokens.Filter.height) |
|
||||||
]) |
|
||||||
|
|
||||||
statefulView.emptyView = EmptyView.activitiesEmptyView() |
|
||||||
} |
|
||||||
|
|
||||||
required init?(coder: NSCoder) { |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func configure(viewModel: PriceAlertsPageViewModel) { |
|
||||||
self.viewModel = viewModel |
|
||||||
|
|
||||||
reloadData() |
|
||||||
statefulView.endLoading() |
|
||||||
} |
|
||||||
|
|
||||||
deinit { |
|
||||||
statefulView.resetStatefulStateToReleaseObjectToAvoidMemoryLeak() |
|
||||||
} |
|
||||||
|
|
||||||
func reloadData() { |
|
||||||
tableView.reloadData() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension PriceAlertsPageView: UITableViewDataSource { |
|
||||||
|
|
||||||
func numberOfSections(in tableView: UITableView) -> Int { |
|
||||||
return 1 |
|
||||||
} |
|
||||||
|
|
||||||
//Hide the footer |
|
||||||
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { |
|
||||||
.leastNormalMagnitude |
|
||||||
} |
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { |
|
||||||
nil |
|
||||||
} |
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { |
|
||||||
.leastNormalMagnitude |
|
||||||
} |
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { |
|
||||||
nil |
|
||||||
} |
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { |
|
||||||
return viewModel.alerts.count |
|
||||||
} |
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { |
|
||||||
let cell: PriceAlertTableViewCell = tableView.dequeueReusableCell(for: indexPath) |
|
||||||
cell.delegate = self |
|
||||||
cell.configure(viewModel: .init(alert: viewModel.alerts[indexPath.row])) |
|
||||||
|
|
||||||
return cell |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
extension PriceAlertsPageView: PriceAlertTableViewCellDelegate { |
|
||||||
func cell(_ cell: PriceAlertTableViewCell, didToggle value: Bool, indexPath: IndexPath) { |
|
||||||
delegate?.updateAlert(in: self, value: value, indexPath: indexPath) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension PriceAlertsPageView: UITableViewDelegate { |
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { |
|
||||||
tableView.deselectRow(at: indexPath, animated: true) |
|
||||||
let alert = viewModel.alerts[indexPath.row] |
|
||||||
|
|
||||||
delegate?.editAlertSelected(in: self, alert: alert) |
|
||||||
} |
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { |
|
||||||
return trailingSwipeActionsConfiguration(forRowAt: indexPath) |
|
||||||
} |
|
||||||
|
|
||||||
private func trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { |
|
||||||
let title = R.string.localizable.delete() |
|
||||||
let hideAction = UIContextualAction(style: .destructive, title: title) { [weak self] (_, _, completionHandler) in |
|
||||||
guard let strongSelf = self else { return } |
|
||||||
|
|
||||||
strongSelf.viewModel.removeAlert(indexPath: indexPath) |
|
||||||
strongSelf.tableView.deleteRows(at: [indexPath], with: .automatic) |
|
||||||
strongSelf.statefulView.endLoading() |
|
||||||
// NOTE: small delay for correct remove animation |
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { |
|
||||||
strongSelf.delegate?.removeAlert(in: strongSelf, indexPath: indexPath) |
|
||||||
} |
|
||||||
|
|
||||||
completionHandler(true) |
|
||||||
} |
|
||||||
|
|
||||||
hideAction.backgroundColor = R.color.danger() |
|
||||||
hideAction.image = R.image.hideToken() |
|
||||||
let configuration = UISwipeActionsConfiguration(actions: [hideAction]) |
|
||||||
configuration.performsFirstActionWithFullSwipe = true |
|
||||||
|
|
||||||
return configuration |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension PriceAlertsPageView: AddHideTokensViewDelegate { |
|
||||||
func view(_ view: AddHideTokensView, didSelectAddHideTokensButton sender: UIButton) { |
|
||||||
delegate?.addAlertSelected(in: self) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
protocol PriceAlertTableViewCellDelegate: class { |
|
||||||
func cell(_ cell: PriceAlertsPageView.PriceAlertTableViewCell, didToggle value: Bool, indexPath: IndexPath) |
|
||||||
} |
|
||||||
|
|
||||||
extension PriceAlertsPageView { |
|
||||||
|
|
||||||
struct PriceAlertTableViewCellViewModel { |
|
||||||
let titleAttributedString: NSAttributedString |
|
||||||
let icon: UIImage? |
|
||||||
let isSelected: Bool |
|
||||||
|
|
||||||
init(alert: PriceAlert) { |
|
||||||
titleAttributedString = .init(string: alert.title, attributes: [ |
|
||||||
.font: Fonts.regular(size: 17), |
|
||||||
.foregroundColor: Colors.black |
|
||||||
]) |
|
||||||
icon = alert.icon |
|
||||||
isSelected = alert.isEnabled |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
class PriceAlertTableViewCell: UITableViewCell { |
|
||||||
private lazy var iconImageView: UIImageView = { |
|
||||||
let imageView = UIImageView() |
|
||||||
imageView.translatesAutoresizingMaskIntoConstraints = false |
|
||||||
|
|
||||||
return imageView |
|
||||||
}() |
|
||||||
|
|
||||||
private lazy var titleLabel: UILabel = { |
|
||||||
let label = UILabel() |
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false |
|
||||||
|
|
||||||
return label |
|
||||||
}() |
|
||||||
|
|
||||||
private lazy var switchButton: UISwitch = { |
|
||||||
let button = UISwitch() |
|
||||||
button.translatesAutoresizingMaskIntoConstraints = false |
|
||||||
|
|
||||||
return button |
|
||||||
}() |
|
||||||
|
|
||||||
weak var delegate: PriceAlertTableViewCellDelegate? |
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { |
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier) |
|
||||||
separatorInset = .zero |
|
||||||
|
|
||||||
let stackView = [ |
|
||||||
.spacerWidth(16), |
|
||||||
iconImageView, |
|
||||||
.spacerWidth(16), |
|
||||||
titleLabel, |
|
||||||
.spacerWidth(16, flexible: true), |
|
||||||
switchButton, |
|
||||||
.spacerWidth(16), |
|
||||||
].asStackView(axis: .horizontal, alignment: .center) |
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
|
||||||
contentView.addSubview(stackView) |
|
||||||
|
|
||||||
NSLayoutConstraint.activate([ |
|
||||||
iconImageView.heightAnchor.constraint(equalToConstant: 18), |
|
||||||
iconImageView.widthAnchor.constraint(equalToConstant: 18), |
|
||||||
stackView.anchorsConstraint(to: contentView, edgeInsets: .init(top: 14, left: 0, bottom: 14, right: 0)) |
|
||||||
]) |
|
||||||
|
|
||||||
switchButton.addTarget(self, action: #selector(toggleSelectionState), for: .valueChanged) |
|
||||||
} |
|
||||||
|
|
||||||
required init?(coder: NSCoder) { |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func configure(viewModel: PriceAlertTableViewCellViewModel) { |
|
||||||
iconImageView.image = viewModel.icon |
|
||||||
titleLabel.attributedText = viewModel.titleAttributedString |
|
||||||
switchButton.isEnabled = viewModel.isSelected |
|
||||||
} |
|
||||||
|
|
||||||
@objc private func toggleSelectionState(_ sender: UISwitch) { |
|
||||||
guard let indexPath = indexPath else { return } |
|
||||||
delegate?.cell(self, didToggle: sender.isEnabled, indexPath: indexPath) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,142 @@ |
|||||||
|
// |
||||||
|
// TopTabBarViewController.swift |
||||||
|
// AlphaWallet |
||||||
|
// |
||||||
|
// Created by Vladyslav Shepitko on 30.11.2022. |
||||||
|
// |
||||||
|
|
||||||
|
import UIKit |
||||||
|
|
||||||
|
protocol TopTabBarViewControllerDelegate: class { |
||||||
|
func viewController(_ viewController: TopTabBarViewController, didSelectPage index: Int) |
||||||
|
} |
||||||
|
|
||||||
|
class TopTabBarViewController: UIViewController { |
||||||
|
|
||||||
|
private lazy var tabBar: ScrollableSegmentedControl = { |
||||||
|
let cellConfiguration = Style.ScrollableSegmentedControlCell.configuration |
||||||
|
let controlConfiguration = Style.ScrollableSegmentedControl.configuration |
||||||
|
let cells = titles.map { title in |
||||||
|
ScrollableSegmentedControlCell(frame: .zero, title: title, configuration: cellConfiguration) |
||||||
|
} |
||||||
|
let control = ScrollableSegmentedControl(cells: cells, configuration: controlConfiguration) |
||||||
|
control.setSelection(cellIndex: selectedIndex) |
||||||
|
control.addTarget(self, action: #selector(didTapSegment), for: .touchUpInside) |
||||||
|
|
||||||
|
return control |
||||||
|
}() |
||||||
|
|
||||||
|
private lazy var scrollView: UIScrollView = { |
||||||
|
let scrollView = UIScrollView() |
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false |
||||||
|
scrollView.isPagingEnabled = true |
||||||
|
scrollView.isScrollEnabled = false |
||||||
|
|
||||||
|
return scrollView |
||||||
|
}() |
||||||
|
|
||||||
|
private let stackView: UIStackView = { |
||||||
|
let stackView = UIStackView() |
||||||
|
stackView.axis = .horizontal |
||||||
|
stackView.spacing = 0 |
||||||
|
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||||
|
|
||||||
|
return stackView |
||||||
|
}() |
||||||
|
|
||||||
|
weak var navigationDelegate: TopTabBarViewControllerDelegate? |
||||||
|
|
||||||
|
var selection: ControlSelection { |
||||||
|
return tabBar.selectedSegment |
||||||
|
} |
||||||
|
private (set) var bottomAnchorConstraints: [NSLayoutConstraint] = [] |
||||||
|
private let selectedIndex: Int |
||||||
|
private let titles: [String] |
||||||
|
|
||||||
|
init(titles: [String], selectedIndex: Int = 0) { |
||||||
|
self.titles = titles |
||||||
|
self.selectedIndex = selectedIndex |
||||||
|
super.init(nibName: nil, bundle: nil) |
||||||
|
|
||||||
|
view.addSubview(tabBar) |
||||||
|
view.addSubview(scrollView) |
||||||
|
scrollView.addSubview(stackView) |
||||||
|
|
||||||
|
bottomAnchorConstraints = [ |
||||||
|
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||||
|
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor) |
||||||
|
] |
||||||
|
|
||||||
|
NSLayoutConstraint.activate([ |
||||||
|
tabBar.heightAnchor.constraint(equalToConstant: DataEntry.Metric.TabBar.height), |
||||||
|
tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||||
|
tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||||
|
tabBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), |
||||||
|
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||||
|
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||||
|
scrollView.topAnchor.constraint(equalTo: tabBar.bottomAnchor), |
||||||
|
|
||||||
|
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), |
||||||
|
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), |
||||||
|
stackView.topAnchor.constraint(equalTo: tabBar.bottomAnchor), |
||||||
|
] + bottomAnchorConstraints) |
||||||
|
} |
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
//NOTE: need to triggle initial selection state when view layout its subviews for first time |
||||||
|
private var didLayoutSubviewsAtFirstTime: Bool = true |
||||||
|
|
||||||
|
func set(viewControllers: [UIViewController]) { |
||||||
|
viewControllers.forEach { addChild($0) } |
||||||
|
|
||||||
|
let views = viewControllers.compactMap { $0.view } |
||||||
|
stackView.addArrangedSubviews(views) |
||||||
|
|
||||||
|
let viewsHeights = views.flatMap { each -> [NSLayoutConstraint] in |
||||||
|
return [ |
||||||
|
each.widthAnchor.constraint(equalTo: view.widthAnchor), |
||||||
|
each.heightAnchor.constraint(equalTo: scrollView.heightAnchor) |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
|
NSLayoutConstraint.activate([ |
||||||
|
viewsHeights |
||||||
|
]) |
||||||
|
|
||||||
|
viewControllers.forEach { $0.didMove(toParent: self) } |
||||||
|
} |
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() { |
||||||
|
super.viewDidLayoutSubviews() |
||||||
|
|
||||||
|
guard scrollView.bounds.width != .zero && didLayoutSubviewsAtFirstTime else { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
didLayoutSubviewsAtFirstTime = false |
||||||
|
selectTab(selection: tabBar.selectedSegment, animated: false) |
||||||
|
} |
||||||
|
|
||||||
|
@objc private func didTapSegment(_ control: ScrollableSegmentedControl) { |
||||||
|
selectTab(selection: control.selectedSegment, animated: true) |
||||||
|
} |
||||||
|
|
||||||
|
private func selectTab(selection: ControlSelection, animated: Bool) { |
||||||
|
let index: Int |
||||||
|
switch selection { |
||||||
|
case .selected(let value): |
||||||
|
index = Int(value) |
||||||
|
case .unselected: |
||||||
|
index = 0 |
||||||
|
} |
||||||
|
|
||||||
|
let offset = CGPoint(x: CGFloat(index) * scrollView.bounds.width, y: 0) |
||||||
|
scrollView.setContentOffset(offset, animated: animated) |
||||||
|
|
||||||
|
navigationDelegate?.viewController(self, didSelectPage: index) |
||||||
|
} |
||||||
|
} |
@ -1,105 +0,0 @@ |
|||||||
// |
|
||||||
// InfoPageView.swift |
|
||||||
// AlphaWallet |
|
||||||
// |
|
||||||
// Created by Vladyslav Shepitko on 12.05.2021. |
|
||||||
// |
|
||||||
|
|
||||||
import UIKit |
|
||||||
import Combine |
|
||||||
import AlphaWalletFoundation |
|
||||||
|
|
||||||
protocol TokenInfoPageViewDelegate: class { |
|
||||||
func didPressViewContractWebPage(forContract contract: AlphaWallet.Address, in tokenInfoPageView: TokenInfoPageView) |
|
||||||
} |
|
||||||
|
|
||||||
class TokenInfoPageView: ScrollableStackView, PageViewType { |
|
||||||
private lazy var headerView = FungibleTokenHeaderView(viewModel: viewModel.headerViewModel) |
|
||||||
private lazy var chartView: TokenHistoryChartView = { |
|
||||||
let chartView = TokenHistoryChartView(viewModel: viewModel.chartViewModel) |
|
||||||
return chartView |
|
||||||
}() |
|
||||||
private let viewModel: TokenInfoPageViewModel |
|
||||||
private var cancelable = Set<AnyCancellable>() |
|
||||||
private let appear = PassthroughSubject<Void, Never>() |
|
||||||
|
|
||||||
weak var delegate: TokenInfoPageViewDelegate? |
|
||||||
var rightBarButtonItem: UIBarButtonItem? |
|
||||||
var title: String { return viewModel.tabTitle } |
|
||||||
|
|
||||||
init(viewModel: TokenInfoPageViewModel) { |
|
||||||
self.viewModel = viewModel |
|
||||||
super.init() |
|
||||||
|
|
||||||
headerView.delegate = self |
|
||||||
|
|
||||||
bind(viewModel: viewModel) |
|
||||||
} |
|
||||||
|
|
||||||
func viewWillAppear() { |
|
||||||
appear.send(()) |
|
||||||
} |
|
||||||
|
|
||||||
private func generateSubviews(for viewTypes: [TokenInfoPageViewModel.ViewType]) { |
|
||||||
stackView.removeAllArrangedSubviews() |
|
||||||
|
|
||||||
stackView.addArrangedSubview(headerView) |
|
||||||
|
|
||||||
for each in viewTypes { |
|
||||||
switch each { |
|
||||||
case .testnet: |
|
||||||
stackView.addArrangedSubview(UIView.spacer(height: 40)) |
|
||||||
stackView.addArrangedSubview(UIView.spacer(backgroundColor: Configuration.Color.Semantic.tableViewSeparator)) |
|
||||||
|
|
||||||
let view = TestnetTokenInfoView() |
|
||||||
view.configure(viewModel: .init()) |
|
||||||
|
|
||||||
stackView.addArrangedSubview(view) |
|
||||||
case .charts: |
|
||||||
stackView.addArrangedSubview(chartView) |
|
||||||
|
|
||||||
stackView.addArrangedSubview(UIView.spacer(height: 10)) |
|
||||||
stackView.addArrangedSubview(UIView.spacer(backgroundColor: Configuration.Color.Semantic.tableViewSeparator)) |
|
||||||
stackView.addArrangedSubview(UIView.spacer(height: 10)) |
|
||||||
case .field(let viewModel): |
|
||||||
let indexPath = IndexPath(row: 0, section: 0) |
|
||||||
let view = TokenAttributeView(indexPath: indexPath) |
|
||||||
view.delegate = self |
|
||||||
view.configure(viewModel: viewModel) |
|
||||||
|
|
||||||
stackView.addArrangedSubview(view) |
|
||||||
case .header(let viewModel): |
|
||||||
let view = TokenInfoHeaderView() |
|
||||||
view.configure(viewModel: viewModel) |
|
||||||
|
|
||||||
stackView.addArrangedSubview(view) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private func bind(viewModel: TokenInfoPageViewModel) { |
|
||||||
let input = TokenInfoPageViewModelInput(appear: appear.eraseToAnyPublisher()) |
|
||||||
let output = viewModel.transform(input: input) |
|
||||||
output.viewState |
|
||||||
.sink { [weak self] state in |
|
||||||
self?.generateSubviews(for: state.views) |
|
||||||
}.store(in: &cancelable) |
|
||||||
} |
|
||||||
|
|
||||||
required init?(coder: NSCoder) { |
|
||||||
return nil |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension TokenInfoPageView: TokenAttributeViewDelegate { |
|
||||||
func didSelect(in view: TokenAttributeView) { |
|
||||||
//no-op |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension TokenInfoPageView: FungibleTokenHeaderViewDelegate { |
|
||||||
|
|
||||||
func didPressViewContractWebPage(inHeaderView: FungibleTokenHeaderView) { |
|
||||||
delegate?.didPressViewContractWebPage(forContract: viewModel.token.contractAddress, in: self) |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,212 @@ |
|||||||
|
// |
||||||
|
// FungibleTokenCoordinator.swift |
||||||
|
// AlphaWallet |
||||||
|
// |
||||||
|
// Created by Vladyslav Shepitko on 19.11.2022. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
import AlphaWalletFoundation |
||||||
|
import PromiseKit |
||||||
|
import Combine |
||||||
|
|
||||||
|
protocol FungibleTokenCoordinatorDelegate: AnyObject, CanOpenURL { |
||||||
|
func didTapSwap(swapTokenFlow: SwapTokenFlow, in coordinator: FungibleTokenCoordinator) |
||||||
|
func didTapBridge(transactionType: TransactionType, service: TokenActionProvider, in coordinator: FungibleTokenCoordinator) |
||||||
|
func didTapBuy(transactionType: TransactionType, service: TokenActionProvider, in coordinator: FungibleTokenCoordinator) |
||||||
|
func didPress(for type: PaymentFlow, viewController: UIViewController, in coordinator: FungibleTokenCoordinator) |
||||||
|
func didTap(transaction: TransactionInstance, viewController: UIViewController, in coordinator: FungibleTokenCoordinator) |
||||||
|
func didTap(activity: Activity, viewController: UIViewController, in coordinator: FungibleTokenCoordinator) |
||||||
|
|
||||||
|
func didClose(in coordinator: FungibleTokenCoordinator) |
||||||
|
} |
||||||
|
|
||||||
|
class FungibleTokenCoordinator: Coordinator { |
||||||
|
private let keystore: Keystore |
||||||
|
private let assetDefinitionStore: AssetDefinitionStore |
||||||
|
private let analytics: AnalyticsLogger |
||||||
|
private let tokenActionsProvider: SupportedTokenActionsProvider |
||||||
|
private let coinTickersFetcher: CoinTickersFetcher |
||||||
|
private let activitiesService: ActivitiesServiceType |
||||||
|
private let sessions: ServerDictionary<WalletSession> |
||||||
|
private let session: WalletSession |
||||||
|
private let alertService: PriceAlertServiceType |
||||||
|
private let tokensService: TokenBalanceRefreshable & TokenViewModelState & TokenHolderState |
||||||
|
private let token: Token |
||||||
|
private let navigationController: UINavigationController |
||||||
|
private var cancelable = Set<AnyCancellable>() |
||||||
|
|
||||||
|
private lazy var rootViewController: FungibleTokenTabViewController = { |
||||||
|
let viewModel = FungibleTokenTabViewModel(token: token, session: session, tokensService: tokensService, assetDefinitionStore: assetDefinitionStore) |
||||||
|
let viewController = FungibleTokenTabViewController(viewModel: viewModel) |
||||||
|
let viewControlers = viewModel.tabBarItems.map { buildViewController(tabBarItem: $0) } |
||||||
|
viewController.set(viewControllers: viewControlers) |
||||||
|
viewController.delegate = self |
||||||
|
|
||||||
|
return viewController |
||||||
|
}() |
||||||
|
|
||||||
|
var coordinators: [Coordinator] = [] |
||||||
|
weak var delegate: FungibleTokenCoordinatorDelegate? |
||||||
|
|
||||||
|
init(token: Token, |
||||||
|
navigationController: UINavigationController, |
||||||
|
session: WalletSession, |
||||||
|
keystore: Keystore, |
||||||
|
assetDefinitionStore: AssetDefinitionStore, |
||||||
|
analytics: AnalyticsLogger, |
||||||
|
tokenActionsProvider: SupportedTokenActionsProvider, |
||||||
|
coinTickersFetcher: CoinTickersFetcher, |
||||||
|
activitiesService: ActivitiesServiceType, |
||||||
|
alertService: PriceAlertServiceType, |
||||||
|
tokensService: TokenBalanceRefreshable & TokenViewModelState & TokenHolderState, |
||||||
|
sessions: ServerDictionary<WalletSession>) { |
||||||
|
self.token = token |
||||||
|
self.navigationController = navigationController |
||||||
|
self.sessions = sessions |
||||||
|
self.tokensService = tokensService |
||||||
|
self.session = session |
||||||
|
self.keystore = keystore |
||||||
|
self.assetDefinitionStore = assetDefinitionStore |
||||||
|
self.analytics = analytics |
||||||
|
self.tokenActionsProvider = tokenActionsProvider |
||||||
|
self.coinTickersFetcher = coinTickersFetcher |
||||||
|
self.activitiesService = activitiesService |
||||||
|
self.alertService = alertService |
||||||
|
} |
||||||
|
|
||||||
|
func start() { |
||||||
|
rootViewController.hidesBottomBarWhenPushed = true |
||||||
|
rootViewController.navigationItem.largeTitleDisplayMode = .never |
||||||
|
|
||||||
|
navigationController.pushViewController(rootViewController, animated: true) |
||||||
|
} |
||||||
|
|
||||||
|
private func buildViewController(tabBarItem: FungibleTokenTabViewModel.TabBarItem) -> UIViewController { |
||||||
|
switch tabBarItem { |
||||||
|
case .details: |
||||||
|
return buildDetailsViewController() |
||||||
|
case .activities: |
||||||
|
return buildActivitiesViewController() |
||||||
|
case .alerts: |
||||||
|
return buildAlertsViewController() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func buildActivitiesViewController() -> UIViewController { |
||||||
|
let viewController = ActivitiesViewController(analytics: analytics, keystore: keystore, wallet: session.account, viewModel: .init(collection: .init(activities: [])), sessions: sessions, assetDefinitionStore: assetDefinitionStore) |
||||||
|
viewController.delegate = self |
||||||
|
|
||||||
|
//FIXME: replace later with moving it to `ActivitiesViewController` |
||||||
|
activitiesService.activitiesPublisher |
||||||
|
.map { ActivityPageViewModel(activitiesViewModel: .init(collection: .init(activities: $0))) } |
||||||
|
.receive(on: RunLoop.main) |
||||||
|
.sink { [viewController] in |
||||||
|
viewController.configure(viewModel: $0.activitiesViewModel) |
||||||
|
}.store(in: &cancelable) |
||||||
|
|
||||||
|
activitiesService.start() |
||||||
|
|
||||||
|
return viewController |
||||||
|
} |
||||||
|
|
||||||
|
private func buildAlertsViewController() -> UIViewController { |
||||||
|
let viewModel = PriceAlertsViewModel(alertService: alertService, token: token) |
||||||
|
let viewController = PriceAlertsViewController(viewModel: viewModel) |
||||||
|
viewController.delegate = self |
||||||
|
|
||||||
|
return viewController |
||||||
|
} |
||||||
|
|
||||||
|
private func buildDetailsViewController() -> UIViewController { |
||||||
|
lazy var viewModel = FungibleTokenDetailsViewModel(token: token, coinTickersFetcher: coinTickersFetcher, tokensService: tokensService, session: session, assetDefinitionStore: assetDefinitionStore, tokenActionsProvider: tokenActionsProvider) |
||||||
|
let viewController = FungibleTokenDetailsViewController(viewModel: viewModel) |
||||||
|
viewController.delegate = self |
||||||
|
|
||||||
|
return viewController |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenCoordinator: FungibleTokenDetailsViewControllerDelegate { |
||||||
|
func didTapSwap(swapTokenFlow: SwapTokenFlow, in viewController: FungibleTokenDetailsViewController) { |
||||||
|
delegate?.didTapSwap(swapTokenFlow: swapTokenFlow, in: self) |
||||||
|
} |
||||||
|
|
||||||
|
func didTapBridge(for token: Token, service: TokenActionProvider, in viewController: FungibleTokenDetailsViewController) { |
||||||
|
delegate?.didTapBridge(transactionType: .init(fungibleToken: token), service: service, in: self) |
||||||
|
} |
||||||
|
|
||||||
|
func didTapBuy(for token: Token, service: TokenActionProvider, in viewController: FungibleTokenDetailsViewController) { |
||||||
|
delegate?.didTapBuy(transactionType: .init(fungibleToken: token), service: service, in: self) |
||||||
|
} |
||||||
|
|
||||||
|
func didTapSend(for token: Token, in viewController: FungibleTokenDetailsViewController) { |
||||||
|
delegate?.didPress(for: .send(type: .transaction(.init(fungibleToken: token))), viewController: viewController, in: self) |
||||||
|
} |
||||||
|
|
||||||
|
func didTapReceive(for token: Token, in viewController: FungibleTokenDetailsViewController) { |
||||||
|
delegate?.didPress(for: .request, viewController: viewController, in: self) |
||||||
|
} |
||||||
|
|
||||||
|
func didTap(action: TokenInstanceAction, token: Token, in viewController: FungibleTokenDetailsViewController) { |
||||||
|
guard let navigationController = viewController.navigationController else { return } |
||||||
|
|
||||||
|
let tokenHolder = token.getTokenHolder(assetDefinitionStore: assetDefinitionStore, forWallet: session.account) |
||||||
|
delegate?.didPress(for: .send(type: .tokenScript(action: action, token: token, tokenHolder: tokenHolder)), viewController: navigationController, in: self) |
||||||
|
} |
||||||
|
|
||||||
|
func didPressViewContractWebPage(forContract contract: AlphaWallet.Address, server: RPCServer, in viewController: UIViewController) { |
||||||
|
delegate?.didPressViewContractWebPage(forContract: contract, server: server, in: viewController) |
||||||
|
} |
||||||
|
|
||||||
|
func didPressViewContractWebPage(_ url: URL, in viewController: UIViewController) { |
||||||
|
delegate?.didPressOpenWebPage(url, in: viewController) |
||||||
|
} |
||||||
|
|
||||||
|
func didPressOpenWebPage(_ url: URL, in viewController: UIViewController) { |
||||||
|
delegate?.didPressOpenWebPage(url, in: viewController) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenCoordinator: PriceAlertsViewControllerDelegate { |
||||||
|
func editAlertSelected(in viewController: PriceAlertsViewController, alert: PriceAlert) { |
||||||
|
let coordinator = EditPriceAlertCoordinator(navigationController: navigationController, configuration: .edit(alert), token: token, session: session, tokensService: tokensService, alertService: alertService) |
||||||
|
addCoordinator(coordinator) |
||||||
|
coordinator.delegate = self |
||||||
|
coordinator.start() |
||||||
|
} |
||||||
|
|
||||||
|
func addAlertSelected(in viewController: PriceAlertsViewController) { |
||||||
|
let coordinator = EditPriceAlertCoordinator(navigationController: navigationController, configuration: .create, token: token, session: session, tokensService: tokensService, alertService: alertService) |
||||||
|
addCoordinator(coordinator) |
||||||
|
coordinator.delegate = self |
||||||
|
coordinator.start() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenCoordinator: ActivitiesViewControllerDelegate { |
||||||
|
func didPressActivity(activity: AlphaWalletFoundation.Activity, in viewController: ActivitiesViewController) { |
||||||
|
delegate?.didTap(activity: activity, viewController: viewController, in: self) |
||||||
|
} |
||||||
|
|
||||||
|
func didPressTransaction(transaction: AlphaWalletFoundation.TransactionInstance, in viewController: ActivitiesViewController) { |
||||||
|
delegate?.didTap(transaction: transaction, viewController: viewController, in: self) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenCoordinator: EditPriceAlertCoordinatorDelegate { |
||||||
|
func didClose(in coordinator: EditPriceAlertCoordinator) { |
||||||
|
removeCoordinator(coordinator) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenCoordinator: FungibleTokenTabViewControllerDelegate { |
||||||
|
func didClose(in viewController: FungibleTokenTabViewController) { |
||||||
|
delegate?.didClose(in: self) |
||||||
|
} |
||||||
|
|
||||||
|
func open(url: URL) { |
||||||
|
delegate?.didPressOpenWebPage(url, in: rootViewController) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,187 @@ |
|||||||
|
// |
||||||
|
// FungibleTokenDetailsViewController.swift |
||||||
|
// AlphaWallet |
||||||
|
// |
||||||
|
// Created by Vladyslav Shepitko on 19.11.2022. |
||||||
|
// |
||||||
|
|
||||||
|
import UIKit |
||||||
|
import Combine |
||||||
|
import AlphaWalletFoundation |
||||||
|
|
||||||
|
protocol FungibleTokenDetailsViewControllerDelegate: AnyObject, CanOpenURL { |
||||||
|
func didTapSwap(swapTokenFlow: SwapTokenFlow, in viewController: FungibleTokenDetailsViewController) |
||||||
|
func didTapBridge(for token: Token, service: TokenActionProvider, in viewController: FungibleTokenDetailsViewController) |
||||||
|
func didTapBuy(for token: Token, service: TokenActionProvider, in viewController: FungibleTokenDetailsViewController) |
||||||
|
func didTapSend(for token: Token, in viewController: FungibleTokenDetailsViewController) |
||||||
|
func didTapReceive(for token: Token, in viewController: FungibleTokenDetailsViewController) |
||||||
|
func didTap(action: TokenInstanceAction, token: Token, in viewController: FungibleTokenDetailsViewController) |
||||||
|
} |
||||||
|
|
||||||
|
class FungibleTokenDetailsViewController: UIViewController { |
||||||
|
private let containerView: ScrollableStackView = ScrollableStackView() |
||||||
|
private let buttonsBar = HorizontalButtonsBar(configuration: .combined(buttons: 2)) |
||||||
|
private lazy var headerView: FungibleTokenHeaderView = { |
||||||
|
let view = FungibleTokenHeaderView(viewModel: viewModel.headerViewModel) |
||||||
|
view.delegate = self |
||||||
|
|
||||||
|
return view |
||||||
|
}() |
||||||
|
private lazy var chartView: TokenHistoryChartView = { |
||||||
|
let chartView = TokenHistoryChartView(viewModel: viewModel.chartViewModel) |
||||||
|
return chartView |
||||||
|
}() |
||||||
|
|
||||||
|
private let viewModel: FungibleTokenDetailsViewModel |
||||||
|
private var cancelable = Set<AnyCancellable>() |
||||||
|
private let willAppear = PassthroughSubject<Void, Never>() |
||||||
|
|
||||||
|
weak var delegate: FungibleTokenDetailsViewControllerDelegate? |
||||||
|
|
||||||
|
init(viewModel: FungibleTokenDetailsViewModel) { |
||||||
|
self.viewModel = viewModel |
||||||
|
super.init(nibName: nil, bundle: nil) |
||||||
|
|
||||||
|
let footerBar = ButtonsBarBackgroundView(buttonsBar: buttonsBar) |
||||||
|
view.addSubview(footerBar) |
||||||
|
view.addSubview(containerView) |
||||||
|
|
||||||
|
NSLayoutConstraint.activate([ |
||||||
|
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||||
|
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||||
|
containerView.topAnchor.constraint(equalTo: view.topAnchor), |
||||||
|
containerView.bottomAnchor.constraint(equalTo: footerBar.topAnchor), |
||||||
|
|
||||||
|
footerBar.anchorsConstraint(to: view) |
||||||
|
]) |
||||||
|
|
||||||
|
buttonsBar.viewController = self |
||||||
|
} |
||||||
|
|
||||||
|
override func viewDidLoad() { |
||||||
|
super.viewDidLoad() |
||||||
|
|
||||||
|
view.backgroundColor = Configuration.Color.Semantic.defaultViewBackground |
||||||
|
|
||||||
|
bind(viewModel: viewModel) |
||||||
|
} |
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) { |
||||||
|
super.viewWillAppear(animated) |
||||||
|
willAppear.send(()) |
||||||
|
} |
||||||
|
|
||||||
|
private func buildSubviews(for viewTypes: [FungibleTokenDetailsViewModel.ViewType]) -> [UIView] { |
||||||
|
var subviews: [UIView] = [] |
||||||
|
subviews += [headerView] |
||||||
|
|
||||||
|
for each in viewTypes { |
||||||
|
switch each { |
||||||
|
case .testnet: |
||||||
|
subviews += [UIView.spacer(height: 40)] |
||||||
|
subviews += [UIView.spacer(backgroundColor: Configuration.Color.Semantic.tableViewSeparator)] |
||||||
|
|
||||||
|
let view = TestnetTokenInfoView() |
||||||
|
view.configure(viewModel: .init()) |
||||||
|
|
||||||
|
subviews += [view] |
||||||
|
case .charts: |
||||||
|
subviews += [chartView] |
||||||
|
|
||||||
|
subviews += [UIView.spacer(height: 10)] |
||||||
|
subviews += [UIView.spacer(backgroundColor: Configuration.Color.Semantic.tableViewSeparator)] |
||||||
|
subviews += [UIView.spacer(height: 10)] |
||||||
|
case .field(let viewModel): |
||||||
|
let view = TokenAttributeView(indexPath: IndexPath(row: 0, section: 0)) |
||||||
|
view.configure(viewModel: viewModel) |
||||||
|
|
||||||
|
subviews += [view] |
||||||
|
case .header(let viewModel): |
||||||
|
let view = TokenInfoHeaderView() |
||||||
|
view.configure(viewModel: viewModel) |
||||||
|
|
||||||
|
subviews += [view] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return subviews |
||||||
|
} |
||||||
|
|
||||||
|
private func layoutSubviews(_ subviews: [UIView]) { |
||||||
|
containerView.stackView.removeAllArrangedSubviews() |
||||||
|
containerView.stackView.addArrangedSubviews(subviews) |
||||||
|
} |
||||||
|
|
||||||
|
private func bind(viewModel: FungibleTokenDetailsViewModel) { |
||||||
|
let input = FungibleTokenDetailsViewModelInput(willAppear: willAppear.eraseToAnyPublisher()) |
||||||
|
let output = viewModel.transform(input: input) |
||||||
|
output.viewState |
||||||
|
.sink { [weak self] viewState in |
||||||
|
guard let strongSelf = self else { return } |
||||||
|
|
||||||
|
strongSelf.layoutSubviews(strongSelf.buildSubviews(for: viewState.views)) |
||||||
|
strongSelf.configureActionButtons(with: viewState.actions) |
||||||
|
}.store(in: &cancelable) |
||||||
|
} |
||||||
|
|
||||||
|
required init?(coder: NSCoder) { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
private func configureActionButtons(with actions: [TokenInstanceAction]) { |
||||||
|
buttonsBar.configure(.combined(buttons: actions.count)) |
||||||
|
|
||||||
|
for (action, button) in zip(actions, buttonsBar.buttons) { |
||||||
|
button.setTitle(action.name, for: .normal) |
||||||
|
button.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside) |
||||||
|
|
||||||
|
switch viewModel.buttonState(for: action) { |
||||||
|
case .isEnabled(let isEnabled): |
||||||
|
button.isEnabled = isEnabled |
||||||
|
case .isDisplayed(let isDisplayed): |
||||||
|
button.displayButton = isDisplayed |
||||||
|
case .noOption: |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@objc private func actionButtonTapped(sender: UIButton) { |
||||||
|
for (action, button) in zip(viewModel.actions, buttonsBar.buttons) where button == sender { |
||||||
|
switch action.type { |
||||||
|
case .swap: |
||||||
|
delegate?.didTapSwap(swapTokenFlow: .swapToken(token: viewModel.token), in: self) |
||||||
|
case .erc20Send: |
||||||
|
delegate?.didTapSend(for: viewModel.token, in: self) |
||||||
|
case .erc20Receive: |
||||||
|
delegate?.didTapReceive(for: viewModel.token, in: self) |
||||||
|
case .nftRedeem, .nftSell, .nonFungibleTransfer: |
||||||
|
break |
||||||
|
case .tokenScript: |
||||||
|
if let message = viewModel.tokenScriptWarningMessage(for: action) { |
||||||
|
guard case .warning(let string) = message else { return } |
||||||
|
show(message: string) |
||||||
|
} else { |
||||||
|
delegate?.didTap(action: action, token: viewModel.token, in: self) |
||||||
|
} |
||||||
|
case .bridge(let service): |
||||||
|
delegate?.didTapBridge(for: viewModel.token, service: service, in: self) |
||||||
|
case .buy(let service): |
||||||
|
delegate?.didTapBuy(for: viewModel.token, service: service, in: self) |
||||||
|
} |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func show(message: String) { |
||||||
|
UIAlertController.alert(message: message, alertButtonTitles: [R.string.localizable.oK()], alertButtonStyles: [.default], viewController: self) |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenDetailsViewController: FungibleTokenHeaderViewDelegate { |
||||||
|
|
||||||
|
func didPressViewContractWebPage(inHeaderView: FungibleTokenHeaderView) { |
||||||
|
delegate?.didPressViewContractWebPage(forContract: viewModel.token.contractAddress, server: viewModel.token.server, in: self) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,94 @@ |
|||||||
|
// |
||||||
|
// FungibleTokenTabViewController.swift |
||||||
|
// AlphaWallet |
||||||
|
// |
||||||
|
// Created by Vladyslav Shepitko on 20.11.2022. |
||||||
|
// |
||||||
|
|
||||||
|
import UIKit |
||||||
|
import Combine |
||||||
|
import AlphaWalletFoundation |
||||||
|
|
||||||
|
protocol FungibleTokenTabViewControllerDelegate: AnyObject, CanOpenURL2 { |
||||||
|
func didClose(in viewController: FungibleTokenTabViewController) |
||||||
|
} |
||||||
|
|
||||||
|
class FungibleTokenTabViewController: TopTabBarViewController { |
||||||
|
private let viewModel: FungibleTokenTabViewModel |
||||||
|
private var cancelable = Set<AnyCancellable>() |
||||||
|
private let willAppear = PassthroughSubject<Void, Never>() |
||||||
|
|
||||||
|
weak var delegate: FungibleTokenTabViewControllerDelegate? |
||||||
|
|
||||||
|
init(viewModel: FungibleTokenTabViewModel) { |
||||||
|
self.viewModel = viewModel |
||||||
|
super.init(titles: viewModel.tabBarItems.map { $0.description }) |
||||||
|
} |
||||||
|
|
||||||
|
override func viewDidLoad() { |
||||||
|
super.viewDidLoad() |
||||||
|
|
||||||
|
view.backgroundColor = Configuration.Color.Semantic.defaultViewBackground |
||||||
|
updateNavigationRightBarButtons(tokenScriptFileStatusHandler: viewModel.tokenScriptFileStatusHandler) |
||||||
|
bind(viewModel: viewModel) |
||||||
|
} |
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) { |
||||||
|
super.viewWillAppear(animated) |
||||||
|
hideNavigationBarTopSeparatorLine() |
||||||
|
willAppear.send(()) |
||||||
|
} |
||||||
|
|
||||||
|
private func bind(viewModel: FungibleTokenTabViewModel) { |
||||||
|
let input = FungibleTokenTabViewModelInput(willAppear: willAppear.eraseToAnyPublisher()) |
||||||
|
let output = viewModel.transform(input: input) |
||||||
|
output.viewState |
||||||
|
.map { $0.title } |
||||||
|
.assign(to: \.title, on: navigationItem, ownership: .weak) |
||||||
|
.store(in: &cancelable) |
||||||
|
} |
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) { |
||||||
|
fatalError("init(coder:) has not been implemented") |
||||||
|
} |
||||||
|
|
||||||
|
private func updateNavigationRightBarButtons(tokenScriptFileStatusHandler xmlHandler: XMLHandler) { |
||||||
|
if Features.default.isAvailable(.isTokenScriptSignatureStatusEnabled) { |
||||||
|
let tokenScriptStatusPromise = xmlHandler.tokenScriptStatus |
||||||
|
if tokenScriptStatusPromise.isPending { |
||||||
|
let label: UIBarButtonItem = .init(title: R.string.localizable.tokenScriptVerifying(), style: .plain, target: nil, action: nil) |
||||||
|
navigationItem.rightBarButtonItem = label |
||||||
|
|
||||||
|
tokenScriptStatusPromise.done { [weak self] _ in |
||||||
|
self?.updateNavigationRightBarButtons(tokenScriptFileStatusHandler: xmlHandler) |
||||||
|
}.cauterize() |
||||||
|
} |
||||||
|
|
||||||
|
if let server = xmlHandler.server, let status = tokenScriptStatusPromise.value, server.matches(server: viewModel.session.server) { |
||||||
|
switch status { |
||||||
|
case .type0NoTokenScript: |
||||||
|
navigationItem.rightBarButtonItem = nil |
||||||
|
case .type1GoodTokenScriptSignatureGoodOrOptional, .type2BadTokenScript: |
||||||
|
let button = createTokenScriptFileStatusButton(withStatus: status, urlOpener: self) |
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: button) |
||||||
|
} |
||||||
|
} else { |
||||||
|
navigationItem.rightBarButtonItem = nil |
||||||
|
} |
||||||
|
} else { |
||||||
|
//no-op |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenTabViewController: PopNotifiable { |
||||||
|
func didPopViewController(animated: Bool) { |
||||||
|
delegate?.didClose(in: self) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenTabViewController: CanOpenURL2 { |
||||||
|
func open(url: URL) { |
||||||
|
delegate?.open(url: url) |
||||||
|
} |
||||||
|
} |
@ -1,248 +0,0 @@ |
|||||||
// Copyright © 2018 Stormbird PTE. LTD. |
|
||||||
|
|
||||||
import UIKit |
|
||||||
import Combine |
|
||||||
import AlphaWalletFoundation |
|
||||||
|
|
||||||
protocol FungibleTokenViewControllerDelegate: class, CanOpenURL { |
|
||||||
func didTapSwap(swapTokenFlow: SwapTokenFlow, in viewController: FungibleTokenViewController) |
|
||||||
func didTapBridge(for token: Token, service: TokenActionProvider, in viewController: FungibleTokenViewController) |
|
||||||
func didTapBuy(for token: Token, service: TokenActionProvider, in viewController: FungibleTokenViewController) |
|
||||||
func didTapSend(for token: Token, in viewController: FungibleTokenViewController) |
|
||||||
func didTapReceive(for token: Token, in viewController: FungibleTokenViewController) |
|
||||||
func didTap(transaction: TransactionInstance, in viewController: FungibleTokenViewController) |
|
||||||
func didTap(activity: Activity, in viewController: FungibleTokenViewController) |
|
||||||
func didTap(action: TokenInstanceAction, token: Token, in viewController: FungibleTokenViewController) |
|
||||||
func didTapAddAlert(for token: Token, in viewController: FungibleTokenViewController) |
|
||||||
func didTapEditAlert(for token: Token, alert: PriceAlert, in viewController: FungibleTokenViewController) |
|
||||||
func didClose(in viewController: FungibleTokenViewController) |
|
||||||
} |
|
||||||
|
|
||||||
class FungibleTokenViewController: UIViewController { |
|
||||||
private var viewModel: FungibleTokenViewModel |
|
||||||
private let buttonsBar = HorizontalButtonsBar(configuration: .combined(buttons: 2)) |
|
||||||
private lazy var tokenInfoPageView: TokenInfoPageView = { |
|
||||||
let view = TokenInfoPageView(viewModel: viewModel.tokenInfoPageViewModel) |
|
||||||
view.delegate = self |
|
||||||
|
|
||||||
return view |
|
||||||
}() |
|
||||||
private lazy var activitiesPageView: ActivitiesPageView = { |
|
||||||
return ActivitiesPageView(analytics: analytics, keystore: keystore, wallet: viewModel.wallet, viewModel: .init(activitiesViewModel: .init(collection: .init())), sessions: sessions, assetDefinitionStore: viewModel.assetDefinitionStore) |
|
||||||
}() |
|
||||||
private lazy var alertsPageView: PriceAlertsPageView = { |
|
||||||
return PriceAlertsPageView(viewModel: .init(alerts: [])) |
|
||||||
}() |
|
||||||
private let activitiesService: ActivitiesServiceType |
|
||||||
private let analytics: AnalyticsLogger |
|
||||||
private let keystore: Keystore |
|
||||||
private var cancelable = Set<AnyCancellable>() |
|
||||||
private let appear = PassthroughSubject<Void, Never>() |
|
||||||
private let updateAlert = PassthroughSubject<(value: Bool, indexPath: IndexPath), Never>() |
|
||||||
private let removeAlert = PassthroughSubject<IndexPath, Never>() |
|
||||||
private let sessions: ServerDictionary<WalletSession> |
|
||||||
weak var delegate: FungibleTokenViewControllerDelegate? |
|
||||||
|
|
||||||
init(keystore: Keystore, analytics: AnalyticsLogger, viewModel: FungibleTokenViewModel, activitiesService: ActivitiesServiceType, sessions: ServerDictionary<WalletSession>) { |
|
||||||
self.sessions = sessions |
|
||||||
self.viewModel = viewModel |
|
||||||
self.keystore = keystore |
|
||||||
self.activitiesService = activitiesService |
|
||||||
self.analytics = analytics |
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil) |
|
||||||
hidesBottomBarWhenPushed = true |
|
||||||
|
|
||||||
let footerBar = ButtonsBarBackgroundView(buttonsBar: buttonsBar) |
|
||||||
let pageWithFooter = PageViewWithFooter(pageView: tokenInfoPageView, footerBar: footerBar) |
|
||||||
let pages: [PageViewType] |
|
||||||
if Features.default.isAvailable(.isAlertsEnabled) && viewModel.hasCoinTicker { |
|
||||||
pages = [pageWithFooter, activitiesPageView, alertsPageView] |
|
||||||
} else { |
|
||||||
pages = [pageWithFooter, activitiesPageView] |
|
||||||
} |
|
||||||
|
|
||||||
let containerView = PagesContainerView(pages: pages) |
|
||||||
|
|
||||||
view.addSubview(containerView) |
|
||||||
NSLayoutConstraint.activate([containerView.anchorsConstraint(to: view)]) |
|
||||||
|
|
||||||
navigationItem.largeTitleDisplayMode = .never |
|
||||||
} |
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) { |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
override func viewDidLoad() { |
|
||||||
super.viewDidLoad() |
|
||||||
|
|
||||||
activitiesPageView.delegate = self |
|
||||||
alertsPageView.delegate = self |
|
||||||
buttonsBar.viewController = self |
|
||||||
|
|
||||||
bind(viewModel: viewModel) |
|
||||||
} |
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) { |
|
||||||
super.viewWillAppear(animated) |
|
||||||
hideNavigationBarTopSeparatorLine() |
|
||||||
|
|
||||||
appear.send(()) |
|
||||||
tokenInfoPageView.viewWillAppear() |
|
||||||
} |
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) { |
|
||||||
super.viewWillDisappear(animated) |
|
||||||
showNavigationBarTopSeparatorLine() |
|
||||||
} |
|
||||||
|
|
||||||
private func bind(viewModel: FungibleTokenViewModel) { |
|
||||||
view.backgroundColor = viewModel.backgroundColor |
|
||||||
|
|
||||||
updateNavigationRightBarButtons(tokenScriptFileStatusHandler: viewModel.tokenScriptFileStatusHandler) |
|
||||||
|
|
||||||
let input = FungibleTokenViewModelInput(appear: appear.eraseToAnyPublisher(), |
|
||||||
updateAlert: updateAlert.eraseToAnyPublisher(), |
|
||||||
removeAlert: removeAlert.eraseToAnyPublisher()) |
|
||||||
|
|
||||||
let output = viewModel.transform(input: input) |
|
||||||
output.viewState |
|
||||||
.sink { [weak self, navigationItem] state in |
|
||||||
navigationItem.title = state.title |
|
||||||
self?.configureActionButtons(with: state.actions) |
|
||||||
}.store(in: &cancelable) |
|
||||||
|
|
||||||
output.activities |
|
||||||
.sink { [weak activitiesPageView] in activitiesPageView?.configure(viewModel: $0) } |
|
||||||
.store(in: &cancelable) |
|
||||||
|
|
||||||
output.alerts |
|
||||||
.sink { [weak alertsPageView] in alertsPageView?.configure(viewModel: $0) } |
|
||||||
.store(in: &cancelable) |
|
||||||
} |
|
||||||
|
|
||||||
private func configureActionButtons(with actions: [TokenInstanceAction]) { |
|
||||||
buttonsBar.configure(.combined(buttons: actions.count)) |
|
||||||
|
|
||||||
for (action, button) in zip(actions, buttonsBar.buttons) { |
|
||||||
button.setTitle(action.name, for: .normal) |
|
||||||
button.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside) |
|
||||||
|
|
||||||
switch viewModel.buttonState(for: action) { |
|
||||||
case .isEnabled(let isEnabled): |
|
||||||
button.isEnabled = isEnabled |
|
||||||
case .isDisplayed(let isDisplayed): |
|
||||||
button.displayButton = isDisplayed |
|
||||||
case .noOption: |
|
||||||
continue |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private func updateNavigationRightBarButtons(tokenScriptFileStatusHandler xmlHandler: XMLHandler) { |
|
||||||
if Features.default.isAvailable(.isTokenScriptSignatureStatusEnabled) { |
|
||||||
let tokenScriptStatusPromise = xmlHandler.tokenScriptStatus |
|
||||||
if tokenScriptStatusPromise.isPending { |
|
||||||
let label: UIBarButtonItem = .init(title: R.string.localizable.tokenScriptVerifying(), style: .plain, target: nil, action: nil) |
|
||||||
navigationItem.rightBarButtonItem = label |
|
||||||
|
|
||||||
tokenScriptStatusPromise.done { [weak self] _ in |
|
||||||
self?.updateNavigationRightBarButtons(tokenScriptFileStatusHandler: xmlHandler) |
|
||||||
}.cauterize() |
|
||||||
} |
|
||||||
|
|
||||||
if let server = xmlHandler.server, let status = tokenScriptStatusPromise.value, server.matches(server: viewModel.session.server) { |
|
||||||
switch status { |
|
||||||
case .type0NoTokenScript: |
|
||||||
navigationItem.rightBarButtonItem = nil |
|
||||||
case .type1GoodTokenScriptSignatureGoodOrOptional, .type2BadTokenScript: |
|
||||||
let button = createTokenScriptFileStatusButton(withStatus: status, urlOpener: self) |
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: button) |
|
||||||
} |
|
||||||
} else { |
|
||||||
navigationItem.rightBarButtonItem = nil |
|
||||||
} |
|
||||||
} else { |
|
||||||
//no-op |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@objc private func actionButtonTapped(sender: UIButton) { |
|
||||||
let actions = viewModel.actions |
|
||||||
for (action, button) in zip(actions, buttonsBar.buttons) where button == sender { |
|
||||||
switch action.type { |
|
||||||
case .swap: |
|
||||||
delegate?.didTapSwap(swapTokenFlow: .swapToken(token: viewModel.token), in: self) |
|
||||||
case .erc20Send: |
|
||||||
delegate?.didTapSend(for: viewModel.token, in: self) |
|
||||||
case .erc20Receive: |
|
||||||
delegate?.didTapReceive(for: viewModel.token, in: self) |
|
||||||
case .nftRedeem, .nftSell, .nonFungibleTransfer: |
|
||||||
break |
|
||||||
case .tokenScript: |
|
||||||
if let message = viewModel.tokenScriptWarningMessage(for: action) { |
|
||||||
guard case .warning(let string) = message else { return } |
|
||||||
show(message: string) |
|
||||||
} else { |
|
||||||
delegate?.didTap(action: action, token: viewModel.token, in: self) |
|
||||||
} |
|
||||||
case .bridge(let service): |
|
||||||
delegate?.didTapBridge(for: viewModel.token, service: service, in: self) |
|
||||||
case .buy(let service): |
|
||||||
delegate?.didTapBuy(for: viewModel.token, service: service, in: self) |
|
||||||
} |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
private func show(message: String) { |
|
||||||
UIAlertController.alert(message: message, alertButtonTitles: [R.string.localizable.oK()], alertButtonStyles: [.default], viewController: self) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension FungibleTokenViewController: CanOpenURL2 { |
|
||||||
func open(url: URL) { |
|
||||||
delegate?.didPressOpenWebPage(url, in: self) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension FungibleTokenViewController: TokenInfoPageViewDelegate { |
|
||||||
func didPressViewContractWebPage(forContract contract: AlphaWallet.Address, in tokenInfoPageView: TokenInfoPageView) { |
|
||||||
delegate?.didPressViewContractWebPage(forContract: contract, server: viewModel.session.server, in: self) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension FungibleTokenViewController: PriceAlertsPageViewDelegate { |
|
||||||
func addAlertSelected(in view: PriceAlertsPageView) { |
|
||||||
delegate?.didTapAddAlert(for: viewModel.token, in: self) |
|
||||||
} |
|
||||||
|
|
||||||
func editAlertSelected(in view: PriceAlertsPageView, alert: PriceAlert) { |
|
||||||
delegate?.didTapEditAlert(for: viewModel.token, alert: alert, in: self) |
|
||||||
} |
|
||||||
|
|
||||||
func removeAlert(in view: PriceAlertsPageView, indexPath: IndexPath) { |
|
||||||
removeAlert.send(indexPath) |
|
||||||
} |
|
||||||
|
|
||||||
func updateAlert(in view: PriceAlertsPageView, value: Bool, indexPath: IndexPath) { |
|
||||||
updateAlert.send((value: value, indexPath: indexPath)) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension FungibleTokenViewController: ActivitiesPageViewDelegate { |
|
||||||
func didTap(activity: Activity, in view: ActivitiesPageView) { |
|
||||||
delegate?.didTap(activity: activity, in: self) |
|
||||||
} |
|
||||||
|
|
||||||
func didTap(transaction: TransactionInstance, in view: ActivitiesPageView) { |
|
||||||
delegate?.didTap(transaction: transaction, in: self) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension FungibleTokenViewController: PopNotifiable { |
|
||||||
func didPopViewController(animated: Bool) { |
|
||||||
delegate?.didClose(in: self) |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,300 @@ |
|||||||
|
// Copyright © 2018 Stormbird PTE. LTD. |
||||||
|
|
||||||
|
import UIKit |
||||||
|
import Combine |
||||||
|
import AlphaWalletFoundation |
||||||
|
|
||||||
|
struct FungibleTokenDetailsViewModelInput { |
||||||
|
let willAppear: AnyPublisher<Void, Never> |
||||||
|
} |
||||||
|
|
||||||
|
struct FungibleTokenDetailsViewModelOutput { |
||||||
|
let viewState: AnyPublisher<FungibleTokenDetailsViewModel.ViewState, Never> |
||||||
|
} |
||||||
|
|
||||||
|
final class FungibleTokenDetailsViewModel { |
||||||
|
private var chartHistoriesSubject: CurrentValueSubject<[ChartHistoryPeriod: ChartHistory], Never> = .init([:]) |
||||||
|
private let coinTickersFetcher: CoinTickersFetcher |
||||||
|
private let tokensService: TokenViewModelState |
||||||
|
private var cancelable = Set<AnyCancellable>() |
||||||
|
private var chartHistories: [ChartHistoryPeriod: ChartHistory] { chartHistoriesSubject.value } |
||||||
|
private lazy var coinTicker: AnyPublisher<CoinTicker?, Never> = { |
||||||
|
return tokensService.tokenViewModelPublisher(for: token) |
||||||
|
.map { $0?.balance.ticker } |
||||||
|
.eraseToAnyPublisher() |
||||||
|
}() |
||||||
|
private lazy var tokenHolder: TokenHolder = token.getTokenHolder(assetDefinitionStore: assetDefinitionStore, forWallet: session.account) |
||||||
|
private let session: WalletSession |
||||||
|
private let assetDefinitionStore: AssetDefinitionStore |
||||||
|
private let tokenActionsProvider: SupportedTokenActionsProvider |
||||||
|
private (set) var actions: [TokenInstanceAction] = [] |
||||||
|
|
||||||
|
let token: Token |
||||||
|
lazy var chartViewModel = TokenHistoryChartViewModel(chartHistories: chartHistoriesSubject.eraseToAnyPublisher(), coinTicker: coinTicker) |
||||||
|
lazy var headerViewModel = FungibleTokenHeaderViewModel(token: token, tokensService: tokensService) |
||||||
|
var wallet: Wallet { session.account } |
||||||
|
|
||||||
|
init(token: Token, coinTickersFetcher: CoinTickersFetcher, tokensService: TokenViewModelState, session: WalletSession, assetDefinitionStore: AssetDefinitionStore, tokenActionsProvider: SupportedTokenActionsProvider) { |
||||||
|
self.tokenActionsProvider = tokenActionsProvider |
||||||
|
self.session = session |
||||||
|
self.assetDefinitionStore = assetDefinitionStore |
||||||
|
self.tokensService = tokensService |
||||||
|
self.coinTickersFetcher = coinTickersFetcher |
||||||
|
self.token = token |
||||||
|
} |
||||||
|
|
||||||
|
func transform(input: FungibleTokenDetailsViewModelInput) -> FungibleTokenDetailsViewModelOutput { |
||||||
|
input.willAppear.flatMapLatest { [coinTickersFetcher, token] _ in |
||||||
|
coinTickersFetcher.fetchChartHistories(for: .init(token: token), force: false, periods: ChartHistoryPeriod.allCases) |
||||||
|
}.print("xxx.chartHistories").assign(to: \.value, on: chartHistoriesSubject) |
||||||
|
.store(in: &cancelable) |
||||||
|
|
||||||
|
let viewTypes = Publishers.CombineLatest(coinTicker, chartHistoriesSubject) |
||||||
|
.compactMap { [weak self] ticker, _ in self?.buildViewTypes(for: ticker) } |
||||||
|
|
||||||
|
let viewState = Publishers.CombineLatest(tokenActionsPublisher(), viewTypes) |
||||||
|
.map { FungibleTokenDetailsViewModel.ViewState(actions: $0, views: $1) } |
||||||
|
.eraseToAnyPublisher() |
||||||
|
|
||||||
|
return .init(viewState: viewState) |
||||||
|
} |
||||||
|
|
||||||
|
private func tokenActionsPublisher() -> AnyPublisher<[TokenInstanceAction], Never> { |
||||||
|
let whenTokenHolderHasChanged = tokenHolder.objectWillChange |
||||||
|
.map { [tokensService, token] _ in tokensService.tokenViewModel(for: token) } |
||||||
|
.receive(on: RunLoop.main) |
||||||
|
.eraseToAnyPublisher() |
||||||
|
|
||||||
|
let whenTokenActionsHasChanged = tokenActionsProvider.objectWillChange |
||||||
|
.map { [tokensService, token] _ in tokensService.tokenViewModel(for: token) } |
||||||
|
.receive(on: RunLoop.main) |
||||||
|
.eraseToAnyPublisher() |
||||||
|
|
||||||
|
let tokenViewModel = tokensService.tokenViewModelPublisher(for: token) |
||||||
|
|
||||||
|
return Publishers.MergeMany(tokenViewModel, whenTokenHolderHasChanged, whenTokenActionsHasChanged) |
||||||
|
.compactMap { _ in self.buildTokenActions() } |
||||||
|
.handleEvents(receiveOutput: { self.actions = $0 }) |
||||||
|
.eraseToAnyPublisher() |
||||||
|
} |
||||||
|
|
||||||
|
private func buildTokenActions() -> [TokenInstanceAction] { |
||||||
|
let xmlHandler = XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore) |
||||||
|
let actionsFromTokenScript = xmlHandler.actions |
||||||
|
infoLog("[TokenScript] actions names: \(actionsFromTokenScript.map(\.name))") |
||||||
|
if actionsFromTokenScript.isEmpty { |
||||||
|
switch token.type { |
||||||
|
case .erc875, .erc721, .erc721ForTickets, .erc1155: |
||||||
|
return [] |
||||||
|
case .erc20, .nativeCryptocurrency: |
||||||
|
let actions: [TokenInstanceAction] = [ |
||||||
|
.init(type: .erc20Send), |
||||||
|
.init(type: .erc20Receive) |
||||||
|
] |
||||||
|
|
||||||
|
return actions + tokenActionsProvider.actions(token: token) |
||||||
|
} |
||||||
|
} else { |
||||||
|
switch token.type { |
||||||
|
case .erc875, .erc721, .erc721ForTickets, .erc1155: |
||||||
|
return [] |
||||||
|
case .erc20: |
||||||
|
return actionsFromTokenScript + tokenActionsProvider.actions(token: token) |
||||||
|
case .nativeCryptocurrency: |
||||||
|
//TODO we should support retrieval of XML (and XMLHandler) based on address + server. For now, this is only important for native cryptocurrency. So might be ok to check like this for now |
||||||
|
if let server = xmlHandler.server, server.matches(server: token.server) { |
||||||
|
return actionsFromTokenScript + tokenActionsProvider.actions(token: token) |
||||||
|
} else { |
||||||
|
//TODO .erc20Send and .erc20Receive names aren't appropriate |
||||||
|
let actions: [TokenInstanceAction] = [ |
||||||
|
.init(type: .erc20Send), |
||||||
|
.init(type: .erc20Receive) |
||||||
|
] |
||||||
|
|
||||||
|
return actions + tokenActionsProvider.actions(token: token) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func buildViewTypes(for ticker: CoinTicker?) -> [FungibleTokenDetailsViewModel.ViewType] { |
||||||
|
var views: [FungibleTokenDetailsViewModel.ViewType] = [] |
||||||
|
|
||||||
|
if token.server.isTestnet { |
||||||
|
views = [ |
||||||
|
.testnet |
||||||
|
] |
||||||
|
} else { |
||||||
|
views = [ |
||||||
|
.charts, |
||||||
|
.header(viewModel: .init(title: R.string.localizable.tokenInfoHeaderPerformance())), |
||||||
|
.field(viewModel: dayViewModel), |
||||||
|
.field(viewModel: weekViewModel), |
||||||
|
.field(viewModel: monthViewModel), |
||||||
|
.field(viewModel: yearViewModel), |
||||||
|
|
||||||
|
.header(viewModel: .init(title: R.string.localizable.tokenInfoHeaderStats())), |
||||||
|
.field(viewModel: markerCapViewModel(for: ticker)), |
||||||
|
.field(viewModel: yearLowViewModel), |
||||||
|
.field(viewModel: yearHighViewModel) |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
|
return views |
||||||
|
} |
||||||
|
|
||||||
|
func tokenScriptWarningMessage(for action: TokenInstanceAction) -> TokenScriptWarningMessage? { |
||||||
|
let fungibleBalance = tokensService.tokenViewModel(for: token)?.balance.value |
||||||
|
if let selection = action.activeExcludingSelection(selectedTokenHolders: [tokenHolder], forWalletAddress: wallet.address, fungibleBalance: fungibleBalance) { |
||||||
|
if let denialMessage = selection.denial { |
||||||
|
return .warning(string: denialMessage) |
||||||
|
} else { |
||||||
|
//no-op shouldn't have reached here since the button should be disabled. So just do nothing to be safe |
||||||
|
return .undefined |
||||||
|
} |
||||||
|
} else { |
||||||
|
return nil |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func buttonState(for action: TokenInstanceAction) -> ActionButtonState { |
||||||
|
func _configButton(action: TokenInstanceAction) -> ActionButtonState { |
||||||
|
let fungibleBalance = tokensService.tokenViewModel(for: token)?.balance.value |
||||||
|
if let selection = action.activeExcludingSelection(selectedTokenHolders: [tokenHolder], forWalletAddress: wallet.address, fungibleBalance: fungibleBalance) { |
||||||
|
if selection.denial == nil { |
||||||
|
return .isDisplayed(false) |
||||||
|
} |
||||||
|
} |
||||||
|
return .noOption |
||||||
|
} |
||||||
|
|
||||||
|
switch wallet.type { |
||||||
|
case .real: |
||||||
|
return _configButton(action: action) |
||||||
|
case .watch: |
||||||
|
if session.config.development.shouldPretendIsRealWallet { |
||||||
|
return _configButton(action: action) |
||||||
|
} else { |
||||||
|
return .isEnabled(false) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func markerCapViewModel(for ticker: CoinTicker?) -> TokenAttributeViewModel { |
||||||
|
let value: String = ticker?.market_cap.flatMap { StringFormatter().largeNumberFormatter(for: $0, currency: "USD") } ?? "-" |
||||||
|
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldStatsMarket_cap(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private func totalSupplyViewModel(for ticker: CoinTicker?) -> TokenAttributeViewModel { |
||||||
|
let value: String = ticker?.total_supply.flatMap { String($0) } ?? "-" |
||||||
|
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldStatsTotal_supply(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private func maxSupplyViewModel(for ticker: CoinTicker?) -> TokenAttributeViewModel { |
||||||
|
let value: String = ticker?.max_supply.flatMap { Formatter.usd.string(from: $0) } ?? "-" |
||||||
|
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldStatsMax_supply(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private var yearLowViewModel: TokenAttributeViewModel { |
||||||
|
let value: String = { |
||||||
|
let history = chartHistories[ChartHistoryPeriod.year] |
||||||
|
if let min = HistoryHelper(history: history).minMax?.min, let value = Formatter.usd.string(from: min) { |
||||||
|
return value |
||||||
|
} else { |
||||||
|
return "-" |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldPerformanceYearLow(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private var yearHighViewModel: TokenAttributeViewModel { |
||||||
|
let value: String = { |
||||||
|
let history = chartHistories[ChartHistoryPeriod.year] |
||||||
|
if let max = HistoryHelper(history: history).minMax?.max, let value = Formatter.usd.string(from: max) { |
||||||
|
return value |
||||||
|
} else { |
||||||
|
return "-" |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldPerformanceYearHigh(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private var yearViewModel: TokenAttributeViewModel { |
||||||
|
let attributedValue: NSAttributedString = attributedHistoryValue(period: ChartHistoryPeriod.year) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldStatsYear(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private var monthViewModel: TokenAttributeViewModel { |
||||||
|
let attributedValue: NSAttributedString = attributedHistoryValue(period: ChartHistoryPeriod.month) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldStatsMonth(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private var weekViewModel: TokenAttributeViewModel { |
||||||
|
let attributedValue: NSAttributedString = attributedHistoryValue(period: ChartHistoryPeriod.week) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldStatsWeek(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private var dayViewModel: TokenAttributeViewModel { |
||||||
|
let attributedValue: NSAttributedString = attributedHistoryValue(period: ChartHistoryPeriod.day) |
||||||
|
return .init(title: R.string.localizable.tokenInfoFieldStatsDay(), attributedValue: attributedValue) |
||||||
|
} |
||||||
|
|
||||||
|
private func attributedHistoryValue(period: ChartHistoryPeriod) -> NSAttributedString { |
||||||
|
let result: (string: String, foregroundColor: UIColor) = { |
||||||
|
let result = HistoryHelper(history: chartHistories[period]) |
||||||
|
|
||||||
|
switch result.change { |
||||||
|
case .appreciate(let percentage, let value): |
||||||
|
let p = Formatter.percent.string(from: percentage) ?? "-" |
||||||
|
let v = Formatter.usd.string(from: value) ?? "-" |
||||||
|
|
||||||
|
return ("\(v) (\(p)%)", Style.value.appreciated) |
||||||
|
case .depreciate(let percentage, let value): |
||||||
|
let p = Formatter.percent.string(from: percentage) ?? "-" |
||||||
|
let v = Formatter.usd.string(from: value) ?? "-" |
||||||
|
|
||||||
|
return ("\(v) (\(p)%)", Style.value.depreciated) |
||||||
|
case .none: |
||||||
|
return ("-", Colors.black) |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
return TokenAttributeViewModel.attributedString(result.string, alignment: .right, font: Fonts.regular(size: 17), foregroundColor: result.foregroundColor, lineBreakMode: .byTruncatingTail) |
||||||
|
} |
||||||
|
|
||||||
|
var backgroundColor: UIColor { |
||||||
|
return Screen.TokenCard.Color.background |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenDetailsViewModel { |
||||||
|
enum TokenScriptWarningMessage { |
||||||
|
case warning(string: String) |
||||||
|
case undefined |
||||||
|
} |
||||||
|
|
||||||
|
enum ActionButtonState { |
||||||
|
case isDisplayed(Bool) |
||||||
|
case isEnabled(Bool) |
||||||
|
case noOption |
||||||
|
} |
||||||
|
|
||||||
|
enum ViewType { |
||||||
|
case charts |
||||||
|
case testnet |
||||||
|
case header(viewModel: TokenInfoHeaderViewModel) |
||||||
|
case field(viewModel: TokenAttributeViewModel) |
||||||
|
} |
||||||
|
|
||||||
|
struct ViewState { |
||||||
|
let actions: [TokenInstanceAction] |
||||||
|
let views: [FungibleTokenDetailsViewModel.ViewType] |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,79 @@ |
|||||||
|
// |
||||||
|
// FungibleTokenTabViewModel.swift |
||||||
|
// AlphaWallet |
||||||
|
// |
||||||
|
// Created by Vladyslav Shepitko on 20.11.2022. |
||||||
|
// |
||||||
|
|
||||||
|
import UIKit |
||||||
|
import AlphaWalletFoundation |
||||||
|
import Combine |
||||||
|
|
||||||
|
struct FungibleTokenTabViewModelInput { |
||||||
|
let willAppear: AnyPublisher<Void, Never> |
||||||
|
} |
||||||
|
|
||||||
|
struct FungibleTokenTabViewModelOutput { |
||||||
|
let viewState: AnyPublisher<FungibleTokenTabViewModel.ViewState, Never> |
||||||
|
} |
||||||
|
|
||||||
|
class FungibleTokenTabViewModel { |
||||||
|
private let token: Token |
||||||
|
private let tokensService: TokenBalanceRefreshable & TokenViewModelState |
||||||
|
private let assetDefinitionStore: AssetDefinitionStore |
||||||
|
private var cancelable = Set<AnyCancellable>() |
||||||
|
lazy var tokenScriptFileStatusHandler = XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore) |
||||||
|
|
||||||
|
let session: WalletSession |
||||||
|
let tabBarItems: [TabBarItem] |
||||||
|
|
||||||
|
init(token: Token, session: WalletSession, tokensService: TokenBalanceRefreshable & TokenViewModelState, assetDefinitionStore: AssetDefinitionStore) { |
||||||
|
self.tokensService = tokensService |
||||||
|
self.assetDefinitionStore = assetDefinitionStore |
||||||
|
self.token = token |
||||||
|
self.session = session |
||||||
|
|
||||||
|
let hasTicker = tokensService.tokenViewModel(for: token)?.balance.ticker != nil |
||||||
|
|
||||||
|
if Features.default.isAvailable(.isAlertsEnabled) && hasTicker { |
||||||
|
tabBarItems = [.details, .activities, .alerts] |
||||||
|
} else { |
||||||
|
tabBarItems = [.details, .activities] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func transform(input: FungibleTokenTabViewModelInput) -> FungibleTokenTabViewModelOutput { |
||||||
|
input.willAppear |
||||||
|
.sink { [tokensService, token] _ in |
||||||
|
tokensService.refreshBalance(updatePolicy: .token(token: token)) |
||||||
|
}.store(in: &cancelable) |
||||||
|
|
||||||
|
let viewState = tokensService |
||||||
|
.tokenViewModelPublisher(for: token) |
||||||
|
.map { $0?.tokenScriptOverrides?.titleInPluralForm } |
||||||
|
.map { FungibleTokenTabViewModel.ViewState(title: $0) } |
||||||
|
.eraseToAnyPublisher() |
||||||
|
|
||||||
|
return .init(viewState: viewState) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
extension FungibleTokenTabViewModel { |
||||||
|
enum TabBarItem: CustomStringConvertible { |
||||||
|
case details |
||||||
|
case activities |
||||||
|
case alerts |
||||||
|
|
||||||
|
var description: String { |
||||||
|
switch self { |
||||||
|
case .details: return R.string.localizable.tokenTabInfo() |
||||||
|
case .activities: return R.string.localizable.tokenTabActivity() |
||||||
|
case .alerts: return R.string.localizable.priceAlertNavigationTitle() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
struct ViewState { |
||||||
|
let title: String? |
||||||
|
} |
||||||
|
} |
@ -1,208 +0,0 @@ |
|||||||
// Copyright © 2018 Stormbird PTE. LTD. |
|
||||||
|
|
||||||
import Foundation |
|
||||||
import UIKit |
|
||||||
import BigInt |
|
||||||
import PromiseKit |
|
||||||
import Combine |
|
||||||
import AlphaWalletFoundation |
|
||||||
|
|
||||||
struct FungibleTokenViewModelInput { |
|
||||||
let appear: AnyPublisher<Void, Never> |
|
||||||
let updateAlert: AnyPublisher<(value: Bool, indexPath: IndexPath), Never> |
|
||||||
let removeAlert: AnyPublisher<IndexPath, Never> |
|
||||||
} |
|
||||||
|
|
||||||
struct FungibleTokenViewModelOutput { |
|
||||||
let viewState: AnyPublisher<FungibleTokenViewModel.ViewState, Never> |
|
||||||
let activities: AnyPublisher<ActivityPageViewModel, Never> |
|
||||||
let alerts: AnyPublisher<PriceAlertsPageViewModel, Never> |
|
||||||
} |
|
||||||
|
|
||||||
final class FungibleTokenViewModel { |
|
||||||
private var cancelable = Set<AnyCancellable>() |
|
||||||
private let coinTickersFetcher: CoinTickersFetcher |
|
||||||
private let tokenActionsProvider: SupportedTokenActionsProvider |
|
||||||
private let tokensService: TokenViewModelState & TokenBalanceRefreshable |
|
||||||
private let activitiesService: ActivitiesServiceType |
|
||||||
private let alertService: PriceAlertServiceType |
|
||||||
private lazy var tokenHolder: TokenHolder = token.getTokenHolder(assetDefinitionStore: assetDefinitionStore, forWallet: session.account) |
|
||||||
private (set) var actions: [TokenInstanceAction] = [] |
|
||||||
|
|
||||||
let session: WalletSession |
|
||||||
let assetDefinitionStore: AssetDefinitionStore |
|
||||||
var wallet: Wallet { session.account } |
|
||||||
lazy var tokenScriptFileStatusHandler = XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore) |
|
||||||
let token: Token |
|
||||||
|
|
||||||
var tokenScriptStatus: Promise<TokenLevelTokenScriptDisplayStatus> { |
|
||||||
let xmlHandler = XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore) |
|
||||||
return xmlHandler.tokenScriptStatus |
|
||||||
} |
|
||||||
|
|
||||||
var hasCoinTicker: Bool { |
|
||||||
return tokensService.tokenViewModel(for: token)?.balance.ticker != nil |
|
||||||
} |
|
||||||
|
|
||||||
lazy var tokenInfoPageViewModel = TokenInfoPageViewModel(token: token, coinTickersFetcher: coinTickersFetcher, tokensService: tokensService) |
|
||||||
|
|
||||||
let backgroundColor: UIColor = Colors.appBackground |
|
||||||
let sendButtonTitle: String = R.string.localizable.send() |
|
||||||
let receiveButtonTitle: String = R.string.localizable.receive() |
|
||||||
|
|
||||||
init(activitiesService: ActivitiesServiceType, alertService: PriceAlertServiceType, token: Token, session: WalletSession, assetDefinitionStore: AssetDefinitionStore, tokenActionsProvider: SupportedTokenActionsProvider, coinTickersFetcher: CoinTickersFetcher, tokensService: TokenViewModelState & TokenBalanceRefreshable) { |
|
||||||
self.activitiesService = activitiesService |
|
||||||
self.alertService = alertService |
|
||||||
self.token = token |
|
||||||
self.session = session |
|
||||||
self.assetDefinitionStore = assetDefinitionStore |
|
||||||
self.tokenActionsProvider = tokenActionsProvider |
|
||||||
self.coinTickersFetcher = coinTickersFetcher |
|
||||||
self.tokensService = tokensService |
|
||||||
} |
|
||||||
|
|
||||||
func tokenScriptWarningMessage(for action: TokenInstanceAction) -> TokenScriptWarningMessage? { |
|
||||||
let fungibleBalance = tokensService.tokenViewModel(for: token)?.balance.value |
|
||||||
if let selection = action.activeExcludingSelection(selectedTokenHolders: [tokenHolder], forWalletAddress: wallet.address, fungibleBalance: fungibleBalance) { |
|
||||||
if let denialMessage = selection.denial { |
|
||||||
return .warning(string: denialMessage) |
|
||||||
} else { |
|
||||||
//no-op shouldn't have reached here since the button should be disabled. So just do nothing to be safe |
|
||||||
return .undefined |
|
||||||
} |
|
||||||
} else { |
|
||||||
return nil |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func buttonState(for action: TokenInstanceAction) -> ActionButtonState { |
|
||||||
func _configButton(action: TokenInstanceAction) -> ActionButtonState { |
|
||||||
let fungibleBalance = tokensService.tokenViewModel(for: token)?.balance.value |
|
||||||
if let selection = action.activeExcludingSelection(selectedTokenHolders: [tokenHolder], forWalletAddress: wallet.address, fungibleBalance: fungibleBalance) { |
|
||||||
if selection.denial == nil { |
|
||||||
return .isDisplayed(false) |
|
||||||
} |
|
||||||
} |
|
||||||
return .noOption |
|
||||||
} |
|
||||||
|
|
||||||
switch wallet.type { |
|
||||||
case .real: |
|
||||||
return _configButton(action: action) |
|
||||||
case .watch: |
|
||||||
if session.config.development.shouldPretendIsRealWallet { |
|
||||||
return _configButton(action: action) |
|
||||||
} else { |
|
||||||
return .isEnabled(false) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func transform(input: FungibleTokenViewModelInput) -> FungibleTokenViewModelOutput { |
|
||||||
input.appear.receive(on: RunLoop.main) |
|
||||||
.sink { [tokensService, token] _ in tokensService.refreshBalance(updatePolicy: .token(token: token)) } |
|
||||||
.store(in: &cancelable) |
|
||||||
|
|
||||||
input.removeAlert |
|
||||||
.sink { [alertService] in alertService.remove(indexPath: $0) } |
|
||||||
.store(in: &cancelable) |
|
||||||
|
|
||||||
input.updateAlert |
|
||||||
.sink { [alertService] in alertService.update(indexPath: $0.indexPath, update: .enabled($0.value)) } |
|
||||||
.store(in: &cancelable) |
|
||||||
|
|
||||||
activitiesService.start() |
|
||||||
|
|
||||||
let whenTokenHolderHasChanged = tokenHolder.objectWillChange |
|
||||||
.map { [tokensService, token] _ in tokensService.tokenViewModel(for: token) } |
|
||||||
.receive(on: RunLoop.main) |
|
||||||
.eraseToAnyPublisher() |
|
||||||
|
|
||||||
let whenTokenActionsHasChanged = tokenActionsProvider.objectWillChange |
|
||||||
.map { [tokensService, token] _ in tokensService.tokenViewModel(for: token) } |
|
||||||
.receive(on: RunLoop.main) |
|
||||||
.eraseToAnyPublisher() |
|
||||||
|
|
||||||
let tokenViewModel = tokensService.tokenViewModelPublisher(for: token) |
|
||||||
|
|
||||||
let actions = Publishers.MergeMany(tokenViewModel, whenTokenHolderHasChanged, whenTokenActionsHasChanged) |
|
||||||
.compactMap { _ in self.buildTokenActions() } |
|
||||||
.handleEvents(receiveOutput: { self.actions = $0 }) |
|
||||||
|
|
||||||
let title = tokenViewModel.compactMap { $0?.tokenScriptOverrides?.titleInPluralForm } |
|
||||||
|
|
||||||
let activities = activitiesService.activitiesPublisher |
|
||||||
.map { ActivityPageViewModel(activitiesViewModel: .init(collection: .init(activities: $0))) } |
|
||||||
.receive(on: RunLoop.main) |
|
||||||
|
|
||||||
let alerts = alertService.alertsPublisher(forStrategy: .token(token)) |
|
||||||
.map { PriceAlertsPageViewModel(alerts: $0) } |
|
||||||
.receive(on: RunLoop.main) |
|
||||||
|
|
||||||
let viewState = Publishers.CombineLatest(actions, title) |
|
||||||
.map { actions, title in FungibleTokenViewModel.ViewState(title: title, actions: actions) } |
|
||||||
|
|
||||||
return .init(viewState: viewState.eraseToAnyPublisher(), |
|
||||||
activities: activities.eraseToAnyPublisher(), |
|
||||||
alerts: alerts.eraseToAnyPublisher()) |
|
||||||
} |
|
||||||
|
|
||||||
private func buildTokenActions() -> [TokenInstanceAction] { |
|
||||||
let xmlHandler = XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore) |
|
||||||
let actionsFromTokenScript = xmlHandler.actions |
|
||||||
infoLog("[TokenScript] actions names: \(actionsFromTokenScript.map(\.name))") |
|
||||||
if actionsFromTokenScript.isEmpty { |
|
||||||
switch token.type { |
|
||||||
case .erc875, .erc721, .erc721ForTickets, .erc1155: |
|
||||||
return [] |
|
||||||
case .erc20, .nativeCryptocurrency: |
|
||||||
let actions: [TokenInstanceAction] = [ |
|
||||||
.init(type: .erc20Send), |
|
||||||
.init(type: .erc20Receive) |
|
||||||
] |
|
||||||
|
|
||||||
return actions + tokenActionsProvider.actions(token: token) |
|
||||||
} |
|
||||||
} else { |
|
||||||
switch token.type { |
|
||||||
case .erc875, .erc721, .erc721ForTickets, .erc1155: |
|
||||||
return [] |
|
||||||
case .erc20: |
|
||||||
return actionsFromTokenScript + tokenActionsProvider.actions(token: token) |
|
||||||
case .nativeCryptocurrency: |
|
||||||
//TODO we should support retrieval of XML (and XMLHandler) based on address + server. For now, this is only important for native cryptocurrency. So might be ok to check like this for now |
|
||||||
if let server = xmlHandler.server, server.matches(server: token.server) { |
|
||||||
return actionsFromTokenScript + tokenActionsProvider.actions(token: token) |
|
||||||
} else { |
|
||||||
//TODO .erc20Send and .erc20Receive names aren't appropriate |
|
||||||
let actions: [TokenInstanceAction] = [ |
|
||||||
.init(type: .erc20Send), |
|
||||||
.init(type: .erc20Receive) |
|
||||||
] |
|
||||||
|
|
||||||
return actions + tokenActionsProvider.actions(token: token) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension FungibleTokenViewModel { |
|
||||||
|
|
||||||
enum TokenScriptWarningMessage { |
|
||||||
case warning(string: String) |
|
||||||
case undefined |
|
||||||
} |
|
||||||
|
|
||||||
enum ActionButtonState { |
|
||||||
case isDisplayed(Bool) |
|
||||||
case isEnabled(Bool) |
|
||||||
case noOption |
|
||||||
} |
|
||||||
|
|
||||||
struct ViewState { |
|
||||||
let title: String |
|
||||||
let actions: [TokenInstanceAction] |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,188 +0,0 @@ |
|||||||
// Copyright © 2018 Stormbird PTE. LTD. |
|
||||||
|
|
||||||
import UIKit |
|
||||||
import Combine |
|
||||||
import AlphaWalletFoundation |
|
||||||
|
|
||||||
struct TokenInfoPageViewModelInput { |
|
||||||
let appear: AnyPublisher<Void, Never> |
|
||||||
} |
|
||||||
|
|
||||||
struct TokenInfoPageViewModelOutput { |
|
||||||
let viewState: AnyPublisher<TokenInfoPageViewModel.ViewState, Never> |
|
||||||
} |
|
||||||
|
|
||||||
final class TokenInfoPageViewModel { |
|
||||||
private var chartHistoriesSubject: CurrentValueSubject<[ChartHistoryPeriod: ChartHistory], Never> = .init([:]) |
|
||||||
private let coinTickersFetcher: CoinTickersFetcher |
|
||||||
private var ticker: CoinTicker? |
|
||||||
private let tokensService: TokenViewModelState |
|
||||||
private var cancelable = Set<AnyCancellable>() |
|
||||||
private var chartHistories: [ChartHistoryPeriod: ChartHistory] { chartHistoriesSubject.value } |
|
||||||
private lazy var coinTicker: AnyPublisher<CoinTicker?, Never> = { |
|
||||||
return tokensService.tokenViewModelPublisher(for: token) |
|
||||||
.map { $0?.balance.ticker } |
|
||||||
.eraseToAnyPublisher() |
|
||||||
}() |
|
||||||
|
|
||||||
var tabTitle: String { return R.string.localizable.tokenTabInfo() } |
|
||||||
let token: Token |
|
||||||
lazy var chartViewModel: TokenHistoryChartViewModel = .init(chartHistories: chartHistoriesSubject.eraseToAnyPublisher(), coinTicker: coinTicker) |
|
||||||
lazy var headerViewModel: FungibleTokenHeaderViewModel = .init(token: token, tokensService: tokensService) |
|
||||||
|
|
||||||
init(token: Token, coinTickersFetcher: CoinTickersFetcher, tokensService: TokenViewModelState) { |
|
||||||
self.tokensService = tokensService |
|
||||||
self.coinTickersFetcher = coinTickersFetcher |
|
||||||
self.token = token |
|
||||||
} |
|
||||||
|
|
||||||
func transform(input: TokenInfoPageViewModelInput) -> TokenInfoPageViewModelOutput { |
|
||||||
input.appear.flatMapLatest { [coinTickersFetcher, token] _ in |
|
||||||
coinTickersFetcher.fetchChartHistories(for: .init(token: token), force: false, periods: ChartHistoryPeriod.allCases) |
|
||||||
}.assign(to: \.value, on: chartHistoriesSubject) |
|
||||||
.store(in: &cancelable) |
|
||||||
|
|
||||||
let coinTicker = coinTicker.handleEvents(receiveOutput: { [weak self] in self?.ticker = $0 }).map { _ in } |
|
||||||
let chartHistories = chartHistoriesSubject.map { _ in } |
|
||||||
let viewTypes = Publishers.Merge(coinTicker, chartHistories) |
|
||||||
.compactMap { [weak self] _ in self?.buildViewTypes() } |
|
||||||
|
|
||||||
let viewState = viewTypes |
|
||||||
.map { TokenInfoPageViewModel.ViewState(views: $0) } |
|
||||||
.eraseToAnyPublisher() |
|
||||||
|
|
||||||
return .init(viewState: viewState) |
|
||||||
} |
|
||||||
|
|
||||||
private func buildViewTypes() -> [TokenInfoPageViewModel.ViewType] { |
|
||||||
var views: [TokenInfoPageViewModel.ViewType] = [] |
|
||||||
|
|
||||||
if token.server.isTestnet { |
|
||||||
views = [ |
|
||||||
.testnet |
|
||||||
] |
|
||||||
} else { |
|
||||||
views = [ |
|
||||||
.charts, |
|
||||||
.header(viewModel: .init(title: R.string.localizable.tokenInfoHeaderPerformance())), |
|
||||||
.field(viewModel: dayViewModel), |
|
||||||
.field(viewModel: weekViewModel), |
|
||||||
.field(viewModel: monthViewModel), |
|
||||||
.field(viewModel: yearViewModel), |
|
||||||
|
|
||||||
.header(viewModel: .init(title: R.string.localizable.tokenInfoHeaderStats())), |
|
||||||
.field(viewModel: markerCapViewModel), |
|
||||||
.field(viewModel: yearLowViewModel), |
|
||||||
.field(viewModel: yearHighViewModel) |
|
||||||
] |
|
||||||
} |
|
||||||
|
|
||||||
return views |
|
||||||
} |
|
||||||
|
|
||||||
private var markerCapViewModel: TokenAttributeViewModel { |
|
||||||
let value: String = ticker?.market_cap.flatMap { StringFormatter().largeNumberFormatter(for: $0, currency: "USD") } ?? "-" |
|
||||||
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldStatsMarket_cap(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private var totalSupplyViewModel: TokenAttributeViewModel { |
|
||||||
let value: String = ticker?.total_supply.flatMap { String($0) } ?? "-" |
|
||||||
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldStatsTotal_supply(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private var maxSupplyViewModel: TokenAttributeViewModel { |
|
||||||
let value: String = ticker?.max_supply.flatMap { Formatter.usd.string(from: $0) } ?? "-" |
|
||||||
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldStatsMax_supply(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private var yearLowViewModel: TokenAttributeViewModel { |
|
||||||
let value: String = { |
|
||||||
let history = chartHistories[ChartHistoryPeriod.year] |
|
||||||
if let min = HistoryHelper(history: history).minMax?.min, let value = Formatter.usd.string(from: min) { |
|
||||||
return value |
|
||||||
} else { |
|
||||||
return "-" |
|
||||||
} |
|
||||||
}() |
|
||||||
|
|
||||||
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldPerformanceYearLow(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private var yearHighViewModel: TokenAttributeViewModel { |
|
||||||
let value: String = { |
|
||||||
let history = chartHistories[ChartHistoryPeriod.year] |
|
||||||
if let max = HistoryHelper(history: history).minMax?.max, let value = Formatter.usd.string(from: max) { |
|
||||||
return value |
|
||||||
} else { |
|
||||||
return "-" |
|
||||||
} |
|
||||||
}() |
|
||||||
|
|
||||||
let attributedValue = TokenAttributeViewModel.defaultValueAttributedString(value) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldPerformanceYearHigh(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private var yearViewModel: TokenAttributeViewModel { |
|
||||||
let attributedValue: NSAttributedString = attributedHistoryValue(period: ChartHistoryPeriod.year) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldStatsYear(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private var monthViewModel: TokenAttributeViewModel { |
|
||||||
let attributedValue: NSAttributedString = attributedHistoryValue(period: ChartHistoryPeriod.month) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldStatsMonth(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private var weekViewModel: TokenAttributeViewModel { |
|
||||||
let attributedValue: NSAttributedString = attributedHistoryValue(period: ChartHistoryPeriod.week) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldStatsWeek(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private var dayViewModel: TokenAttributeViewModel { |
|
||||||
let attributedValue: NSAttributedString = attributedHistoryValue(period: ChartHistoryPeriod.day) |
|
||||||
return .init(title: R.string.localizable.tokenInfoFieldStatsDay(), attributedValue: attributedValue) |
|
||||||
} |
|
||||||
|
|
||||||
private func attributedHistoryValue(period: ChartHistoryPeriod) -> NSAttributedString { |
|
||||||
let result: (string: String, foregroundColor: UIColor) = { |
|
||||||
let result = HistoryHelper(history: chartHistories[period]) |
|
||||||
|
|
||||||
switch result.change { |
|
||||||
case .appreciate(let percentage, let value): |
|
||||||
let p = Formatter.percent.string(from: percentage) ?? "-" |
|
||||||
let v = Formatter.usd.string(from: value) ?? "-" |
|
||||||
|
|
||||||
return ("\(v) (\(p)%)", Style.value.appreciated) |
|
||||||
case .depreciate(let percentage, let value): |
|
||||||
let p = Formatter.percent.string(from: percentage) ?? "-" |
|
||||||
let v = Formatter.usd.string(from: value) ?? "-" |
|
||||||
|
|
||||||
return ("\(v) (\(p)%)", Style.value.depreciated) |
|
||||||
case .none: |
|
||||||
return ("-", Colors.black) |
|
||||||
} |
|
||||||
}() |
|
||||||
|
|
||||||
return TokenAttributeViewModel.attributedString(result.string, alignment: .right, font: Fonts.regular(size: 17), foregroundColor: result.foregroundColor, lineBreakMode: .byTruncatingTail) |
|
||||||
} |
|
||||||
|
|
||||||
var backgroundColor: UIColor { |
|
||||||
return Screen.TokenCard.Color.background |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
extension TokenInfoPageViewModel { |
|
||||||
enum ViewType { |
|
||||||
case charts |
|
||||||
case testnet |
|
||||||
case header(viewModel: TokenInfoHeaderViewModel) |
|
||||||
case field(viewModel: TokenAttributeViewModel) |
|
||||||
} |
|
||||||
|
|
||||||
struct ViewState { |
|
||||||
let views: [TokenInfoPageViewModel.ViewType] |
|
||||||
} |
|
||||||
} |
|
Loading…
Reference in new issue