diff --git a/Shared/Strings.swift b/Shared/Strings.swift index 1fca99c7..c5adf4dc 100644 --- a/Shared/Strings.swift +++ b/Shared/Strings.swift @@ -63,5 +63,11 @@ struct Strings { static let secretWordsGiveFullAccess = "Secret words give full access to your funds." static let privateKeyGivesFullAccess = "Private key gives full access to your funds." static let toShowAccountKey = "To show account key" + static let loading = "Loading" + static let failedToLoad = "Failed to load" + static let tryAgain = "Try again" + static let noData = "There is no data yet" + static let refresh = "Refresh" + static let tokenaryIsEmpty = "Tokenary is empty" } diff --git a/Tokenary iOS/Content/Images.swift b/Tokenary iOS/Content/Images.swift new file mode 100644 index 00000000..0e2082bb --- /dev/null +++ b/Tokenary iOS/Content/Images.swift @@ -0,0 +1,19 @@ +// Copyright © 2021 Tokenary. All rights reserved. + +import UIKit + +struct Images { + + static var noData: UIImage { systemName("wind") } + static var failedToLoad: UIImage { systemName("xmark.octagon") } + static var preferences: UIImage { systemName("gearshape") } + + private static func named(_ name: String) -> UIImage { + return UIImage(named: name)! + } + + private static func systemName(_ systemName: String, configuration: UIImage.Configuration? = nil) -> UIImage { + return UIImage(systemName: systemName, withConfiguration: configuration)! + } + +} diff --git a/Tokenary iOS/Extensions/UIView.swift b/Tokenary iOS/Extensions/UIView.swift new file mode 100644 index 00000000..b3b189ec --- /dev/null +++ b/Tokenary iOS/Extensions/UIView.swift @@ -0,0 +1,19 @@ +// Copyright © 2021 Tokenary. All rights reserved. + +import UIKit + +func loadNib(_ type: View.Type) -> View { + return Bundle.main.loadNibNamed(String(describing: type), owner: nil, options: nil)![0] as! View +} + +extension UIView { + + func addSubviewConstrainedToFrame(_ subview: UIView) { + addSubview(subview) + subview.translatesAutoresizingMaskIntoConstraints = false + let firstConstraints = NSLayoutConstraint.constraints(withVisualFormat: "H:|-0-[subview]-0-|", options: .directionLeadingToTrailing, metrics: nil, views: ["subview": subview]) + let secondConstraints = NSLayoutConstraint.constraints(withVisualFormat: "V:|-0-[subview]-0-|", options: .directionLeadingToTrailing, metrics: nil, views: ["subview": subview]) + addConstraints(firstConstraints + secondConstraints) + } + +} diff --git a/Tokenary iOS/Library/DataStateView.swift b/Tokenary iOS/Library/DataStateView.swift new file mode 100644 index 00000000..7e311724 --- /dev/null +++ b/Tokenary iOS/Library/DataStateView.swift @@ -0,0 +1,167 @@ +// Copyright © 2021 Tokenary. All rights reserved. + +import UIKit +import BlockiesSwift + +enum DataState: CaseIterable { + case hasData, loading, failedToLoad, noData, unknown +} + +protocol DataStateContainer: AnyObject { + + var dataState: DataState { get set } + func configureDataState(_ dataState: DataState, description: String?, image: UIImage?, buttonTitle: String?, actionHandler: (() -> Void)?) +} + +class DataStateView: UIView { + + private class Configuration { + + let description: String? + let image: UIImage? + let buttonTitle: String? + let actionHandler: (() -> Void)? + + init(description: String? = nil, image: UIImage? = nil, buttonTitle: String? = nil, actionHandler: (() -> Void)? = nil) { + self.description = description + self.image = image + self.buttonTitle = buttonTitle + self.actionHandler = actionHandler + } + + static func defaultForDataState(_ dataState: DataState) -> Configuration { + let configuration: Configuration + switch dataState { + case .hasData, .loading, .unknown: + configuration = Configuration() + case .failedToLoad: + configuration = Configuration(description: Strings.failedToLoad, image: Images.failedToLoad, buttonTitle: Strings.tryAgain) + case .noData: + configuration = Configuration(description: Strings.noData, image: Images.noData, buttonTitle: Strings.refresh) + } + return configuration + } + } + + fileprivate static let tag = Int.max + fileprivate static var new: DataStateView { + let view = loadNib(DataStateView.self) + view.tag = tag + view.isHidden = true + view.observeKeyboard() + return view + } + + fileprivate var shouldMoveWithKeyboard = true + fileprivate var currentState = DataState.unknown { + didSet { updateForCurrentState() } + } + + private var configurations = [DataState: Configuration]() + + @IBOutlet private weak var centerYConstraint: NSLayoutConstraint! + @IBOutlet private weak var activityIndicator: UIActivityIndicatorView! + @IBOutlet private weak var imageView: UIImageView! + @IBOutlet private weak var descriptionLabel: UILabel! + @IBOutlet private weak var button: UIButton! + @IBOutlet private weak var activityIndicatorDescriptionLabel: UILabel! { + didSet { + activityIndicatorDescriptionLabel.text = Strings.loading.uppercased() + } + } + + @IBAction private func didTapButton(_ sender: Any) { + configurations[currentState]?.actionHandler?() + } + + fileprivate func configureDataState(_ dataState: DataState, description: String? = nil, image: UIImage? = nil, buttonTitle: String? = nil, actionHandler: (() -> Void)? = nil) { + let newConfiguration = Configuration(description: description, image: image, buttonTitle: buttonTitle, actionHandler: actionHandler) + configurations[dataState] = newConfiguration + } + + private func updateForCurrentState() { + isHidden = currentState == .unknown || currentState == .hasData + + let configuration = configurations[currentState] + let defaultConfiguration = Configuration.defaultForDataState(currentState) + + imageView.image = configuration?.image ?? defaultConfiguration.image + descriptionLabel.text = configuration?.description ?? defaultConfiguration.description + button.setTitle(configuration?.buttonTitle ?? defaultConfiguration.buttonTitle, for: .normal) + + let isLoading = currentState == .loading + + activityIndicator.isHidden = !isLoading + activityIndicatorDescriptionLabel.isHidden = !isLoading + imageView.isHidden = isLoading + descriptionLabel.isHidden = isLoading + button.isHidden = isLoading || configuration?.actionHandler == nil + + if isLoading { + activityIndicator.startAnimating() + } else if activityIndicator.isAnimating { + activityIndicator.stopAnimating() + } + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return button.frame.insetBy(dx: -30, dy: -30).contains(point) + } + +} + +extension DataStateView: KeyboardObserver { + + func keyboardWill(show: Bool, height: CGFloat, animtaionOptions: UIView.AnimationOptions, duration: Double) { + guard shouldMoveWithKeyboard else { return } + let centerOffset: CGFloat = show ? -105 : -50 + + UIView.animate(withDuration: duration, + delay: 0, + options: animtaionOptions, + animations: { [weak self] in + self?.centerYConstraint.constant = centerOffset + self?.layoutIfNeeded() + }, + completion: nil + ) + } + +} + +extension DataStateContainer where Self: UIViewController { + + var dataState: DataState { + get { + return dataStateView.currentState + } + set { + dataStateView.currentState = newValue + } + } + + func dataStateShouldMoveWithKeyboard(_ shouldMove: Bool) { + dataStateView.shouldMoveWithKeyboard = shouldMove + } + + func setDataStateViewTransparent(_ isTransparent: Bool) { + dataStateView.backgroundColor = isTransparent ? .clear : .systemGroupedBackground + } + + func configureDataState(_ dataState: DataState, description: String? = nil, image: UIImage? = nil, buttonTitle: String? = nil, actionHandler: (() -> Void)? = nil) { + dataStateView.configureDataState(dataState, description: description, image: image, buttonTitle: buttonTitle, actionHandler: actionHandler) + } + + private var dataStateView: DataStateView { + if let subview = view.viewWithTag(DataStateView.tag) as? DataStateView { return subview } + + let dataStateView = DataStateView.new + view.addSubview(dataStateView) + dataStateView.translatesAutoresizingMaskIntoConstraints = false + let firstConstraint = NSLayoutConstraint.constraints(withVisualFormat: "H:|-0-[subview]-0-|", options: .directionLeadingToTrailing, metrics: nil, views: ["subview": dataStateView]) + let secondConstraint = NSLayoutConstraint.constraints(withVisualFormat: "V:|-0-[subview]-0-|", options: .directionLeadingToTrailing, metrics: nil, views: ["subview": dataStateView]) + view.addConstraints(firstConstraint + secondConstraint) + view.bringSubviewToFront(dataStateView) + return dataStateView + } +} diff --git a/Tokenary iOS/Library/DataStateView.xib b/Tokenary iOS/Library/DataStateView.xib new file mode 100644 index 00000000..1c58ce15 --- /dev/null +++ b/Tokenary iOS/Library/DataStateView.xib @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tokenary iOS/Screens/Accounts/AccountsListViewController.swift b/Tokenary iOS/Screens/Accounts/AccountsListViewController.swift index b9942625..7436310d 100644 --- a/Tokenary iOS/Screens/Accounts/AccountsListViewController.swift +++ b/Tokenary iOS/Screens/Accounts/AccountsListViewController.swift @@ -2,7 +2,7 @@ import UIKit -class AccountsListViewController: UIViewController { +class AccountsListViewController: UIViewController, DataStateContainer { private let walletsManager = WalletsManager.shared private let keychain = Keychain.shared @@ -25,8 +25,27 @@ class AccountsListViewController: UIViewController { navigationController?.navigationBar.prefersLargeTitles = true navigationItem.largeTitleDisplayMode = .always let addItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addAccount)) - let preferencesItem = UIBarButtonItem(image: UIImage(systemName: "gearshape"), style: UIBarButtonItem.Style.plain, target: self, action: #selector(preferencesButtonTapped)) + let preferencesItem = UIBarButtonItem(image: Images.preferences, style: UIBarButtonItem.Style.plain, target: self, action: #selector(preferencesButtonTapped)) navigationItem.rightBarButtonItems = [addItem, preferencesItem] + configureDataState(.noData, description: Strings.tokenaryIsEmpty, buttonTitle: Strings.addAccount) { [weak self] in + self?.addAccount() + } + dataStateShouldMoveWithKeyboard(false) + updateDataState() + } + + private func updateDataState() { + let isEmpty = wallets.isEmpty + dataState = isEmpty ? .noData : .hasData + let canScroll = !isEmpty + if tableView.isScrollEnabled != canScroll { + tableView.isScrollEnabled = canScroll + } + } + + private func reloadData() { + updateDataState() + tableView.reloadData() } @objc private func preferencesButtonTapped() { @@ -76,7 +95,7 @@ class AccountsListViewController: UIViewController { private func createNewAccountAndShowSecretWords() { guard let wallet = try? walletsManager.createWallet() else { return } - tableView.reloadData() + reloadData() showKey(wallet: wallet, mnemonic: true) } @@ -104,7 +123,7 @@ class AccountsListViewController: UIViewController { let importAccountViewController = instantiate(ImportViewController.self, from: .main) importAccountViewController.completion = { [weak self] success in if success { - self?.tableView.reloadData() + self?.reloadData() } } present(importAccountViewController.inNavigationController, animated: true) @@ -169,7 +188,7 @@ class AccountsListViewController: UIViewController { private func removeWallet(_ wallet: TokenaryWallet) { try? walletsManager.delete(wallet: wallet) - tableView.reloadData() + reloadData() } private func didTapExportAccount(_ wallet: TokenaryWallet) { diff --git a/Tokenary.xcodeproj/project.pbxproj b/Tokenary.xcodeproj/project.pbxproj index 187d6f36..4798b2a8 100644 --- a/Tokenary.xcodeproj/project.pbxproj +++ b/Tokenary.xcodeproj/project.pbxproj @@ -69,6 +69,11 @@ 2C96D39827623EC600687301 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C96D39727623EC600687301 /* URL.swift */; }; 2C96D39927623ECE00687301 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C96D39727623EC600687301 /* URL.swift */; }; 2C96D39C2763ADE100687301 /* LocalAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C96D39B2763ADE100687301 /* LocalAuthentication.swift */; }; + 2C96D3A22763C65B00687301 /* KeyboardObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C96D3A12763C65B00687301 /* KeyboardObserver.swift */; }; + 2C96D3A42763C6A800687301 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C96D3A32763C6A800687301 /* UIView.swift */; }; + 2C96D3A62763CCA000687301 /* Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C96D3A52763CCA000687301 /* Images.swift */; }; + 2C96D3A92763D13400687301 /* DataStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C96D3A72763D13400687301 /* DataStateView.swift */; }; + 2C96D3AA2763D13400687301 /* DataStateView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2C96D3A82763D13400687301 /* DataStateView.xib */; }; 2C9F0B6526BDC9AF008FA3D6 /* EthereumNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9F0B6426BDC9AF008FA3D6 /* EthereumNetwork.swift */; }; 2C9F0B6826BDCB2E008FA3D6 /* EthereumChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9F0B6726BDCB2E008FA3D6 /* EthereumChain.swift */; }; 2CAA412526C7CD93009F3535 /* ReviewRequester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAA412426C7CD93009F3535 /* ReviewRequester.swift */; }; @@ -246,6 +251,11 @@ 2C96D3952762380400687301 /* ButtonWithExtendedArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonWithExtendedArea.swift; sourceTree = ""; }; 2C96D39727623EC600687301 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 2C96D39B2763ADE100687301 /* LocalAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthentication.swift; sourceTree = ""; }; + 2C96D3A12763C65B00687301 /* KeyboardObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = KeyboardObserver.swift; path = "../../../Wildberries/wbx-ios/WBX-iOS/Library/Tools/KeyboardObserver.swift"; sourceTree = ""; }; + 2C96D3A32763C6A800687301 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; + 2C96D3A52763CCA000687301 /* Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Images.swift; sourceTree = ""; }; + 2C96D3A72763D13400687301 /* DataStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataStateView.swift; sourceTree = ""; }; + 2C96D3A82763D13400687301 /* DataStateView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = DataStateView.xib; sourceTree = ""; }; 2C9F0B6426BDC9AF008FA3D6 /* EthereumNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EthereumNetwork.swift; sourceTree = ""; }; 2C9F0B6726BDCB2E008FA3D6 /* EthereumChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EthereumChain.swift; sourceTree = ""; }; 2CAA412426C7CD93009F3535 /* ReviewRequester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewRequester.swift; sourceTree = ""; }; @@ -501,6 +511,7 @@ 2C8E88A0275F99D4003EB8DB /* Content */ = { isa = PBXGroup; children = ( + 2C96D3A52763CCA000687301 /* Images.swift */, 2C8E889E275F9967003EB8DB /* Storyboard.swift */, ); path = Content; @@ -519,6 +530,9 @@ 2C96D394276237F600687301 /* Library */ = { isa = PBXGroup; children = ( + 2C96D3A72763D13400687301 /* DataStateView.swift */, + 2C96D3A82763D13400687301 /* DataStateView.xib */, + 2C96D3A12763C65B00687301 /* KeyboardObserver.swift */, 2C96D3952762380400687301 /* ButtonWithExtendedArea.swift */, 2C96D39B2763ADE100687301 /* LocalAuthentication.swift */, ); @@ -530,6 +544,7 @@ children = ( 2CC6EF0C275E64810040CC62 /* UIViewController.swift */, 2C8E88A3275FB7B9003EB8DB /* UIApplication.swift */, + 2C96D3A32763C6A800687301 /* UIView.swift */, 2C96D391276232A300687301 /* UITableView.swift */, ); path = Extensions; @@ -856,6 +871,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2C96D3AA2763D13400687301 /* DataStateView.xib in Resources */, 2C5FF97E26C84F7C00B32ACC /* LaunchScreen.storyboard in Resources */, 2C96D3902762317300687301 /* AccountTableViewCell.xib in Resources */, 2C5FF97B26C84F7C00B32ACC /* Assets.xcassets in Resources */, @@ -1080,7 +1096,9 @@ 2CF255A6275A48BB00AE54B9 /* GasService.swift in Sources */, 2C96D392276232A300687301 /* UITableView.swift in Sources */, 2CF255A5275A48BB00AE54B9 /* ReviewRequester.swift in Sources */, + 2C96D3A22763C65B00687301 /* KeyboardObserver.swift in Sources */, 2CF255B6275A746000AE54B9 /* AccountsListViewController.swift in Sources */, + 2C96D3A42763C6A800687301 /* UIView.swift in Sources */, 2CF25597275A46D300AE54B9 /* Defaults.swift in Sources */, 2CF255A2275A47DD00AE54B9 /* String.swift in Sources */, 2CF2559D275A479800AE54B9 /* TokenaryWallet.swift in Sources */, @@ -1097,12 +1115,14 @@ 2CF255B4275A744000AE54B9 /* PasswordViewController.swift in Sources */, 2C8E889F275F9967003EB8DB /* Storyboard.swift in Sources */, 2C96D39C2763ADE100687301 /* LocalAuthentication.swift in Sources */, + 2C96D3A62763CCA000687301 /* Images.swift in Sources */, 2CF255AA275A48BB00AE54B9 /* SessionStorage.swift in Sources */, 2C96D39827623EC600687301 /* URL.swift in Sources */, 2CF255AD275A48CF00AE54B9 /* EthereumChain.swift in Sources */, 2CF2559C275A477F00AE54B9 /* ApprovalSubject.swift in Sources */, 2CF255B1275A4A1800AE54B9 /* ResponseToExtension.swift in Sources */, 2CF2559B275A46E700AE54B9 /* AuthenticationReason.swift in Sources */, + 2C96D3A92763D13400687301 /* DataStateView.swift in Sources */, 2CF255A3275A47DD00AE54B9 /* UserDefaults.swift in Sources */, 2CF255B8275A748300AE54B9 /* ApproveTransactionViewController.swift in Sources */, 2C8E88A4275FB7B9003EB8DB /* UIApplication.swift in Sources */,