diff --git a/Trust.xcodeproj/project.pbxproj b/Trust.xcodeproj/project.pbxproj index acee36b55..c2105f2fd 100644 --- a/Trust.xcodeproj/project.pbxproj +++ b/Trust.xcodeproj/project.pbxproj @@ -53,6 +53,9 @@ 296106BF1F7639250006164B /* FetchTransactionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296106BE1F7639250006164B /* FetchTransactionsRequest.swift */; }; 296106C21F76403A0006164B /* TokenViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296106C11F76403A0006164B /* TokenViewCell.swift */; }; 296106C41F7640C50006164B /* TokenViewCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296106C31F7640C50006164B /* TokenViewCellViewModel.swift */; }; + 296106C61F7645CC0006164B /* TokensViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296106C51F7645CC0006164B /* TokensViewController.swift */; }; + 296106C81F7646590006164B /* TokensViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296106C71F7646590006164B /* TokensViewModel.swift */; }; + 296106CA1F764AB60006164B /* TokensDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296106C91F764AB60006164B /* TokensDataStore.swift */; }; 296421951F70C1EC00EB363B /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296421941F70C1EC00EB363B /* LoadingView.swift */; }; 296421971F70C1F200EB363B /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296421961F70C1F200EB363B /* ErrorView.swift */; }; 296421991F70C1F900EB363B /* EmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296421981F70C1F900EB363B /* EmptyView.swift */; }; @@ -173,6 +176,9 @@ 296106BE1F7639250006164B /* FetchTransactionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTransactionsRequest.swift; sourceTree = ""; }; 296106C11F76403A0006164B /* TokenViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenViewCell.swift; sourceTree = ""; }; 296106C31F7640C50006164B /* TokenViewCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenViewCellViewModel.swift; sourceTree = ""; }; + 296106C51F7645CC0006164B /* TokensViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokensViewController.swift; sourceTree = ""; }; + 296106C71F7646590006164B /* TokensViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokensViewModel.swift; sourceTree = ""; }; + 296106C91F764AB60006164B /* TokensDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokensDataStore.swift; sourceTree = ""; }; 296421941F70C1EC00EB363B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 296421961F70C1F200EB363B /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 296421981F70C1F900EB363B /* EmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyView.swift; sourceTree = ""; }; @@ -502,6 +508,9 @@ children = ( 296106C11F76403A0006164B /* TokenViewCell.swift */, 296106C31F7640C50006164B /* TokenViewCellViewModel.swift */, + 296106C51F7645CC0006164B /* TokensViewController.swift */, + 296106C71F7646590006164B /* TokensViewModel.swift */, + 296106C91F764AB60006164B /* TokensDataStore.swift */, ); path = Tokens; sourceTree = ""; @@ -968,6 +977,7 @@ 291F52B91F6B880F00B369AB /* EtherKeystore.swift in Sources */, 29BB94971F6FCD60009B09CC /* SendViewModel.swift in Sources */, 291F52B31F6B814300B369AB /* SMP Bignum Extensions.swift in Sources */, + 296106C81F7646590006164B /* TokensViewModel.swift in Sources */, 291F52B41F6B814300B369AB /* SMP Core.swift in Sources */, 2996F14A1F6C9D10005C33AE /* ExportCoordinator.swift in Sources */, 29BE3FD21F707DC300F6BFC2 /* TransactionCoordinator.swift in Sources */, @@ -978,6 +988,7 @@ 291ED08B1F6F5D2100E7E93A /* Bundle.swift in Sources */, 296106BF1F7639250006164B /* FetchTransactionsRequest.swift in Sources */, 293B8B451F70A20200356286 /* TransactionViewCell.swift in Sources */, + 296106C61F7645CC0006164B /* TokensViewController.swift in Sources */, 29282B531F7630970067F88D /* Token.swift in Sources */, 29C9F5FB1F720C050025C494 /* FloatLabelTextField.swift in Sources */, 296421951F70C1EC00EB363B /* LoadingView.swift in Sources */, @@ -1027,6 +1038,7 @@ 291F52BC1F6B8D0600B369AB /* Account.swift in Sources */, 2912CD321F6A83EE00C6CBE3 /* WelcomeViewController.swift in Sources */, 291F52A91F6B7BE100B369AB /* BlockNumber.swift in Sources */, + 296106CA1F764AB60006164B /* TokensDataStore.swift in Sources */, 29BB94931F6FC380009B09CC /* BalanceViewModel.swift in Sources */, 29D03F1D1F712183006E548C /* Button.swift in Sources */, 291F52BF1F6C874E00B369AB /* AccountsViewController.swift in Sources */, diff --git a/Trust/Coordinator/AppCoordinator.swift b/Trust/Coordinator/AppCoordinator.swift index ec50e294b..87867d1d3 100644 --- a/Trust/Coordinator/AppCoordinator.swift +++ b/Trust/Coordinator/AppCoordinator.swift @@ -84,6 +84,11 @@ class AppCoordinator: NSObject { settingsCoordinator.start() settingsCoordinator.delegate = self } + + func showTokens(for account: Account) { + let controller = TokensViewController(account: account) + rootNavigationController.pushViewController(controller, animated: true) + } } extension AppCoordinator: WelcomeViewControllerDelegate { @@ -158,4 +163,8 @@ extension AppCoordinator: TransactionsViewControllerDelegate { let controller = TransactionViewController(transaction: transaction) rootNavigationController.pushViewController(controller, animated: true) } + + func didPressTokens(account: Account, in viewController: TransactionsViewController) { + showTokens(for: account) + } } diff --git a/Trust/Models/Token.swift b/Trust/Models/Token.swift index b6c4fbc41..5e86e4b4e 100644 --- a/Trust/Models/Token.swift +++ b/Trust/Models/Token.swift @@ -8,6 +8,7 @@ struct Token { let symbol: String let totalSupply: String let balance: Int64 + let decimals: Int64 } extension Token { @@ -18,7 +19,8 @@ extension Token { name: tokenInfo["name"] as? String ?? "", symbol: tokenInfo["symbol"] as? String ?? "", totalSupply: tokenInfo["symbol"] as? String ?? "", - balance: json["balance"] as? Int64 ?? 0 + balance: json["balance"] as? Int64 ?? 0, + decimals: json["decimals"] as? Int64 ?? Int64(json["decimals"] as? String ?? "") ?? 0 ) } } diff --git a/Trust/Transactions/Tokens/TokenViewCell.swift b/Trust/Transactions/Tokens/TokenViewCell.swift new file mode 100644 index 000000000..92bb22a6d --- /dev/null +++ b/Trust/Transactions/Tokens/TokenViewCell.swift @@ -0,0 +1,77 @@ +// Copyright SIX DAY LLC, Inc. All rights reserved. + +import Foundation +import UIKit + +class TokenViewCell: UITableViewCell { + + static let identifier = "TokenViewCell" + + let titleLabel = UILabel() + let amountLabel = UILabel() + let symbolImageView = UIImageView() + let subTitleLabel = UILabel() + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + subTitleLabel.translatesAutoresizingMaskIntoConstraints = false + subTitleLabel.lineBreakMode = .byTruncatingMiddle + + symbolImageView.translatesAutoresizingMaskIntoConstraints = false + symbolImageView.image = R.image.accountsSwitch() + + amountLabel.textAlignment = .right + amountLabel.translatesAutoresizingMaskIntoConstraints = false + + let leftStackView = UIStackView(arrangedSubviews: [titleLabel, subTitleLabel]) + leftStackView.translatesAutoresizingMaskIntoConstraints = false + leftStackView.axis = .vertical + leftStackView.spacing = 6 + + let rightStackView = UIStackView(arrangedSubviews: [amountLabel]) + rightStackView.translatesAutoresizingMaskIntoConstraints = false + rightStackView.axis = .vertical + + let stackView = UIStackView(arrangedSubviews: [symbolImageView, leftStackView, rightStackView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = 10 + stackView.distribution = .fill + + symbolImageView.setContentHuggingPriority(UILayoutPriorityDefaultLow, for: .horizontal) + titleLabel.setContentHuggingPriority(UILayoutPriorityDefaultLow, for: .horizontal) + + amountLabel.setContentHuggingPriority(UILayoutPriorityRequired, for: .horizontal) + stackView.setContentHuggingPriority(UILayoutPriorityRequired, for: .horizontal) + + addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: Layout.sideMargin), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Layout.sideMargin), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -Layout.sideMargin), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.sideMargin), + ]) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(viewModel: TokenViewCellViewModel) { + titleLabel.text = viewModel.title + + amountLabel.text = viewModel.amount + amountLabel.textColor = viewModel.amountTextColor + amountLabel.font = viewModel.amountFont + + subTitleLabel.text = viewModel.subTitle + subTitleLabel.textColor = viewModel.subTitleTextColor + subTitleLabel.font = viewModel.subTitleFont + + backgroundColor = viewModel.backgroundColor + } +} diff --git a/Trust/Transactions/Tokens/TokenViewCellViewModel.swift b/Trust/Transactions/Tokens/TokenViewCellViewModel.swift new file mode 100644 index 000000000..14acfdda9 --- /dev/null +++ b/Trust/Transactions/Tokens/TokenViewCellViewModel.swift @@ -0,0 +1,45 @@ +// Copyright SIX DAY LLC, Inc. All rights reserved. + +import Foundation +import UIKit + +struct TokenViewCellViewModel { + + let token: Token + + init(token: Token) { + self.token = token + } + + var title: String { + return token.name + } + + var amount: String { + return "\(token.balance)" + } + + var amountTextColor: UIColor { + return Colors.black + } + + var amountFont: UIFont { + return UIFont.systemFont(ofSize: 17, weight: UIFontWeightMedium) + } + + var subTitle: String { + return token.symbol + } + + var subTitleTextColor: UIColor { + return Colors.gray + } + + var subTitleFont: UIFont { + return UIFont.systemFont(ofSize: 12, weight: UIFontWeightThin) + } + + var backgroundColor: UIColor { + return .white + } +} diff --git a/Trust/Transactions/Tokens/TokensDataStore.swift b/Trust/Transactions/Tokens/TokensDataStore.swift new file mode 100644 index 000000000..798ad8602 --- /dev/null +++ b/Trust/Transactions/Tokens/TokensDataStore.swift @@ -0,0 +1,37 @@ +// Copyright SIX DAY LLC, Inc. All rights reserved. + +import Foundation +import Result +import APIKit + +protocol TokensDataStoreDelegate: class { + func didUpdate(result: Result) +} + +class TokensDataStore { + + let account: Account + weak var delegate: TokensDataStoreDelegate? + var tokens: [Token] = [] + + init(account: Account) { + self.account = account + } + + func update(tokens: [Token]) { + self.tokens = tokens + delegate?.didUpdate(result: .success(TokensViewModel(tokens: tokens))) + } + + func fetch() { + let request = GetTokensRequest(address: account.address.address) + Session.send(request) { result in + switch result { + case .success(let response): + self.update(tokens: response) + case .failure: + self.delegate?.didUpdate(result: .failure(TokenError.failedToFetch)) + } + } + } +} diff --git a/Trust/Transactions/Tokens/TokensViewController.swift b/Trust/Transactions/Tokens/TokensViewController.swift new file mode 100644 index 000000000..189a6866f --- /dev/null +++ b/Trust/Transactions/Tokens/TokensViewController.swift @@ -0,0 +1,133 @@ +// Copyright SIX DAY LLC, Inc. All rights reserved. + +import Foundation +import UIKit +import StatefulViewController +import Result + +class TokensViewController: UIViewController { + + private lazy var dataStore: TokensDataStore = { + return .init(account: self.account) + }() + + var viewModel: TokensViewModel = TokensViewModel(tokens: []) + let account: Account + let tableView: UITableView + let refreshControl = UIRefreshControl() + + init( + account: Account + ) { + self.account = account + tableView = UITableView(frame: .zero, style: .plain) + + super.init(nibName: nil, bundle: nil) + + dataStore.delegate = self + + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.dataSource = self + tableView.separatorStyle = .none + tableView.backgroundColor = .white + tableView.rowHeight = 72 + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + ]) + + refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) + tableView.addSubview(refreshControl) + + errorView = { + let view = ErrorView() + view.onRetry = fetch + return view + }() + + loadingView = { + let view = LoadingView() + return view + }() + + emptyView = { + let view = EmptyView() + view.onRetry = fetch + return view + }() + + title = viewModel.title + view.backgroundColor = viewModel.backgroundColor + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + fetch() + } + + func pullToRefresh() { + refreshControl.beginRefreshing() + fetch() + } + + func fetch() { + dataStore.fetch() + startLoading() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension TokensViewController: StatefulViewController { + func hasContent() -> Bool { + return viewModel.hasContent + } +} + +extension TokensViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true ) + } +} + +extension TokensViewController: TokensDataStoreDelegate { + func didUpdate(result: Result) { + switch result { + case .success(let viewModel): + self.viewModel = viewModel + endLoading() + case .failure(let error): + endLoading(error: error) + } + tableView.reloadData() + + if refreshControl.isRefreshing { + refreshControl.endRefreshing() + } + } +} + +extension TokensViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return viewModel.numberOfSections + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let token = viewModel.item(for: indexPath.row, section: indexPath.section) + let cell = TokenViewCell(style: .default, reuseIdentifier: TokenViewCell.identifier) + cell.configure(viewModel: .init(token: token)) + return cell + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.numberOfItems(for: section) + } +} diff --git a/Trust/Transactions/Tokens/TokensViewModel.swift b/Trust/Transactions/Tokens/TokensViewModel.swift new file mode 100644 index 000000000..92313fd12 --- /dev/null +++ b/Trust/Transactions/Tokens/TokensViewModel.swift @@ -0,0 +1,37 @@ +// Copyright SIX DAY LLC, Inc. All rights reserved. + +import Foundation +import UIKit + +struct TokensViewModel { + + var tokens: [Token] = [] + + init(tokens: [Token]) { + self.tokens = tokens + } + + var title: String { + return "Tokens" + } + + var backgroundColor: UIColor { + return .white + } + + var hasContent: Bool { + return !tokens.isEmpty + } + + var numberOfSections: Int { + return 1 + } + + func numberOfItems(for section: Int) -> Int { + return tokens.count + } + + func item(for row: Int, section: Int) -> Token { + return tokens[row] + } +} diff --git a/Trust/Transactions/TransactionDataStore.swift b/Trust/Transactions/TransactionDataStore.swift index e8b9e22c0..6e9c5d1e4 100644 --- a/Trust/Transactions/TransactionDataStore.swift +++ b/Trust/Transactions/TransactionDataStore.swift @@ -2,10 +2,18 @@ import Foundation import APIKit +import Result + +enum TransactionError: Error { + case failedToFetch +} + +enum TokenError: Error { + case failedToFetch +} protocol TransactionDataStoreDelegate: class { - func didUpdate(viewModel: TransactionsViewModel) - func didFail(with error: Error, viewModel: TransactionsViewModel) + func didUpdate(result: Result) } class TransactionDataStore { @@ -24,12 +32,11 @@ class TransactionDataStore { func fetch() { fetchTransactions() - fetchTokens() } func update(transactions: [Transaction]) { self.transactions = transactions - delegate?.didUpdate(viewModel: viewModel) + delegate?.didUpdate(result: .success(viewModel)) } func update(tokens: [Token]) { @@ -42,20 +49,8 @@ class TransactionDataStore { switch result { case .success(let response): self.update(transactions: response) - case .failure(let error): - self.delegate?.didFail(with: error, viewModel: self.viewModel) - } - } - } - - func fetchTokens() { - let request = GetTokensRequest(address: account.address.address) - Session.send(request) { result in - switch result { - case .success(let response): - self.update(tokens: response) - case .failure(let error): - self.delegate?.didFail(with: error, viewModel: self.viewModel) + case .failure: + self.delegate?.didUpdate(result: .failure(TransactionError.failedToFetch)) } } } diff --git a/Trust/Transactions/TransactionsViewController.swift b/Trust/Transactions/TransactionsViewController.swift index 510570840..dd5e3c514 100644 --- a/Trust/Transactions/TransactionsViewController.swift +++ b/Trust/Transactions/TransactionsViewController.swift @@ -4,16 +4,18 @@ import UIKit import APIKit import JSONRPCKit import StatefulViewController +import Result protocol TransactionsViewControllerDelegate: class { func didPressSend(for account: Account, in viewController: TransactionsViewController) func didPressRequest(for account: Account, in viewController: TransactionsViewController) func didPressTransaction(transaction: Transaction, in viewController: TransactionsViewController) + func didPressTokens(account: Account, in viewController: TransactionsViewController) } class TransactionsViewController: UIViewController { - var viewModel: TransactionsViewModel! + var viewModel: TransactionsViewModel = TransactionsViewModel(transactions: []) private lazy var dataStore: TransactionDataStore = { return .init(account: self.account) @@ -39,8 +41,6 @@ class TransactionsViewController: UIViewController { sendButton = Button(size: .extraLarge, style: .squared) requestButton = Button(size: .extraLarge, style: .squared) - viewModel = TransactionsViewModel(transactions: []) - super.init(nibName: nil, bundle: nil) dataStore.delegate = self @@ -114,6 +114,12 @@ class TransactionsViewController: UIViewController { fetch() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + delegate?.didPressTokens(account: account, in: self) + } + func setTitlte(text: String) { titleView.title = text } @@ -176,14 +182,19 @@ extension TransactionsViewController: UITableViewDelegate { } extension TransactionsViewController: TransactionDataStoreDelegate { - func didFail(with error: Error, viewModel: TransactionsViewModel) { - endLoading(error: error) + func didUpdate(result: Result) { + } - func didUpdate(viewModel: TransactionsViewModel) { - self.viewModel = viewModel + func didUpdate(result: Result) { + switch result { + case .success(let viewModel): + self.viewModel = viewModel + endLoading() + case .failure(let error): + endLoading(error: error) + } tableView.reloadData() - endLoading() if refreshControl.isRefreshing { refreshControl.endRefreshing() diff --git a/TrustTests/Coordinators/AppCoordinatorTests.swift b/TrustTests/Coordinators/AppCoordinatorTests.swift index 5be5246fe..3215e4259 100644 --- a/TrustTests/Coordinators/AppCoordinatorTests.swift +++ b/TrustTests/Coordinators/AppCoordinatorTests.swift @@ -102,4 +102,19 @@ class AppCoordinatorTests: XCTestCase { XCTAssertTrue(coordinator.rootNavigationController.viewControllers[0] is TransactionsViewController) } + + func testShowTokens() { + let coordinator = AppCoordinator( + window: UIWindow(), + keystore: FakeKeystore( + accounts: [.make()] + ), + rootNavigationController: FakeNavigationController() + ) + coordinator.start() + + coordinator.showTokens(for: .make()) + + XCTAssertTrue(coordinator.rootNavigationController.viewControllers[1] is TokensViewController) + } }