diff --git a/project.yml b/project.yml index cb6bce1..9e1ed23 100644 --- a/project.yml +++ b/project.yml @@ -3,22 +3,22 @@ options: minimumXcodeGenVersion: 2.5.0 bundleIdPrefix: com.codecritique targets: - xkcd: - type: application - platform: iOS - sources: - - sources - - assets - xkcdTests: - type: bundle.unit-test + Tests: + type: bundle.unit-test platform: iOS sources: - - xkcdTests + - tests dependencies: - target: xkcd scheme: testTargets: - - xkcdTests + - Tests + xkcd: + type: application + platform: iOS + sources: + - sources + - assets postbuildScripts: - path: scripts/swiftlint.sh name: SwiftLint diff --git a/sources/AppDelegate.swift b/sources/AppDelegate.swift index 3956b7d..e31bdbd 100644 --- a/sources/AppDelegate.swift +++ b/sources/AppDelegate.swift @@ -11,10 +11,4 @@ import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - return true - } } diff --git a/sources/ComicDetailsViewController.swift b/sources/ComicDetailsViewController.swift index 5ff6d2b..e4837c6 100644 --- a/sources/ComicDetailsViewController.swift +++ b/sources/ComicDetailsViewController.swift @@ -2,24 +2,29 @@ // Created: 2019-09-21 // - import UIKit class ComicDetailsViewController: UIViewController { + enum SearchTagSection: CaseIterable { + case onlySection + } + + private var tagTableViewDataSource: UITableViewDiffableDataSource? + private var networkManager = NetworkManager() - var comicId:Int? - var comicImage:UIImage? - var tags:[String]? + var comic: ComicModel? + var comicImage: UIImage? + var tags: [String]? - //Mark: UI Elements - let comicImageView: UIImageView = { - let imageView = UIImageView(frame: CGRect(x: 0,y: 0,width: 150,height: 150)) + // MARK: UI Elements + fileprivate let comicImageView: UIImageView = { + let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 150, height: 150)) imageView.contentMode = UIView.ContentMode.scaleAspectFill imageView.clipsToBounds = true return imageView }() - let tagCollectionView: UICollectionView = { + fileprivate let tagCollectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .vertical layout.estimatedItemSize = CGSize(width: 30, height: 30) @@ -30,7 +35,7 @@ class ComicDetailsViewController: UIViewController { return collectionView }() - let tagTextField: UITextField = { + fileprivate let tagTextField: UITextField = { let tagTextField = UITextField(frame: CGRect.zero) tagTextField.translatesAutoresizingMaskIntoConstraints = false tagTextField.borderStyle = .roundedRect @@ -38,7 +43,7 @@ class ComicDetailsViewController: UIViewController { return tagTextField }() - let addTagButton: UIButton = { + fileprivate let addTagButton: UIButton = { let addTagButton = UIButton(frame: CGRect.zero) addTagButton.setTitle("Add", for: .normal) addTagButton.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) @@ -47,21 +52,43 @@ class ComicDetailsViewController: UIViewController { return addTagButton }() - //Mark: Methods + fileprivate var searchTagTableView: UITableView = { + let tableView = UITableView(frame: CGRect.zero) + tableView.translatesAutoresizingMaskIntoConstraints = false + return tableView + }() + + // MARK: Methods override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white - title = comicId.map { String($0) } ?? "Unknown" - comicImageView.image = comicImage + if let title = comic?.id { + self.title = String(title) + } else { + title = "unknown" + } loadTags() setupNavigationBar() layoutElements() setupTagCollectionView() + setUpSearchTagTableView() + + networkManager.fetchTags { [weak self] (result) in + switch result { + case .success(let tags): + let snapShot = NSDiffableDataSourceSnapshot() + snapShot.appendSections([SearchTagSection.onlySection]) + snapShot.appendItems(tags) + self?.tagTableViewDataSource?.apply(snapShot) + case .failure(let error): + print("Error: ", error) + } + } } - fileprivate static func createHorizontalRowStack() -> UIStackView{ + fileprivate static func createHorizontalRowStack() -> UIStackView { let horizontalStackView = UIStackView(frame: CGRect.zero) horizontalStackView.axis = .horizontal horizontalStackView.translatesAutoresizingMaskIntoConstraints = false @@ -74,13 +101,13 @@ class ComicDetailsViewController: UIViewController { fileprivate static func createColumnStack() -> UIStackView { let verticalStackView = UIStackView(frame: CGRect.zero) verticalStackView.axis = .vertical - verticalStackView.translatesAutoresizingMaskIntoConstraints = false; + verticalStackView.translatesAutoresizingMaskIntoConstraints = false return verticalStackView } fileprivate func layoutElements() { - let stackContainer = ComicDetailsViewController.createColumnStack() + stackContainer.distribution = .fill let topRow = ComicDetailsViewController.createHorizontalRowStack() let middleRow = ComicDetailsViewController.createHorizontalRowStack() @@ -93,6 +120,7 @@ class ComicDetailsViewController: UIViewController { //Establish layout hierarchy stackContainer.addArrangedSubview(topRow) stackContainer.addArrangedSubview(middleRow) + stackContainer.addArrangedSubview(searchTagTableView) view.addSubview(stackContainer) //Add the constraints @@ -101,33 +129,66 @@ class ComicDetailsViewController: UIViewController { tagTextField.heightAnchor.constraint(equalToConstant: 30).isActive = true addTagButton.heightAnchor.constraint(equalToConstant: 30).isActive = true + searchTagTableView.widthAnchor.constraint(equalTo: stackContainer.widthAnchor).isActive = true + stackContainer.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true stackContainer.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true stackContainer.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true + stackContainer.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true } fileprivate func setupNavigationBar() { - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(onCancel)) - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(onSave)) + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(onCancel) + ) + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .save, + target: self, + action: #selector(onSave) + ) navigationController?.navigationBar.isTranslucent = false } + fileprivate func setUpSearchTagTableView() { + searchTagTableView.register(TagTableViewCell.self, forCellReuseIdentifier: "TagTableViewCell") + self.tagTableViewDataSource = makeTagTableViewDataSource() + searchTagTableView.dataSource = tagTableViewDataSource + searchTagTableView.delegate = self + } + + private func makeTagTableViewDataSource() -> UITableViewDiffableDataSource { + return UITableViewDiffableDataSource( + tableView: searchTagTableView) { (tableView, indexPath, tag) -> UITableViewCell? in + let cell = tableView.dequeueReusableCell( + withIdentifier: "TagTableViewCell", + for: indexPath + ) + + cell.textLabel?.text = tag.title + return cell + } + } + fileprivate func setupTagCollectionView() { - tagCollectionView.register(TagCell.self, forCellWithReuseIdentifier: "TagCell") + tagCollectionView.register(TagCollectionViewCell.self, forCellWithReuseIdentifier: "TagCell") tagCollectionView.dataSource = self tagCollectionView.delegate = self } fileprivate func loadTags() { - tags = ["Astronomy", "Discovery", "Futility", "Survival", "Scientist"].sorted() + tags = ["Astronomy", "Discovery", "Futility", "Survival", "Scientist"].sorted() } - //Mark: Navigation Actions - @objc func onSave() { - //To do callback + // MARK: Navigation Actions + @objc + fileprivate func onSave() { + //Todo callback } - @objc func onCancel() { + @objc + fileprivate func onCancel() { dismiss(animated: true) } } @@ -138,23 +199,32 @@ extension ComicDetailsViewController: UICollectionViewDataSource { } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let tagCell = collectionView.dequeueReusableCell(withReuseIdentifier: "TagCell", for: indexPath) as? TagCell else { + guard let tagCell = collectionView.dequeueReusableCell( + withReuseIdentifier: "TagCell", + for: indexPath + ) as? TagCollectionViewCell else { return UICollectionViewCell() } tagCell.textLabel.text = tags?[indexPath.row] return tagCell } - } extension ComicDetailsViewController: UICollectionViewDelegate { } +extension ComicDetailsViewController: UITableViewDelegate { -class TagCell: UICollectionViewCell { +} + +class TagTableViewCell: UITableViewCell { + +} - var textLabel:UILabel = { +class TagCollectionViewCell: UICollectionViewCell { + + fileprivate var textLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.font = UIFont.preferredFont(forTextStyle: .subheadline) @@ -163,7 +233,17 @@ class TagCell: UICollectionViewCell { override init(frame: CGRect) { super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + private func commonInit() { addSubview(textLabel) + textLabel.topAnchor.constraint(equalTo: self.topAnchor).isActive = true textLabel.heightAnchor.constraint(equalTo: self.heightAnchor).isActive = true self.widthAnchor.constraint(equalTo: textLabel.widthAnchor, constant: 8).isActive = true @@ -172,11 +252,6 @@ class TagCell: UICollectionViewCell { layer.cornerRadius = 8.0 } - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - print("Not implemented") - } - override var isSelected: Bool { didSet { switch isSelected { diff --git a/sources/ComicViewController.swift b/sources/ComicViewController.swift index 21856c1..bc064f9 100644 --- a/sources/ComicViewController.swift +++ b/sources/ComicViewController.swift @@ -9,6 +9,7 @@ import UIKit class ComicViewController: UIViewController { let comicImageView = UIImageView() + let networkManager = NetworkManager() lazy var backGestureRecognizer: UISwipeGestureRecognizer = { var swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleBackGesture(_:))) @@ -35,7 +36,13 @@ class ComicViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() setupSubViews() - fetchComicData(completion: displayImage(comic:)) + networkManager.fetchRandomComic { [weak self] (comic) in + guard let comic = comic else { + return + } + + self?.displayImage(comic: comic) + } } private func setupSubViews() { @@ -51,9 +58,6 @@ class ComicViewController: UIViewController { } private func addConstraints() { - // TODO - Added by Arun - // commenting the next line because we are still loading this view fro the storyboard - // view.translatesAutoresizingMaskIntoConstraints = false comicImageView.translatesAutoresizingMaskIntoConstraints = false view.centerXAnchor.constraint(equalTo: comicImageView.centerXAnchor).isActive = true @@ -72,14 +76,19 @@ class ComicViewController: UIViewController { comicImageView.addGestureRecognizer(tapGestureRecognizer) } - // MARK: UTILITIES private func nextComic() { if let currentComic = currentComic { historyStack.push(currentComic) } - fetchComicData(completion: displayImage(comic:)) + + networkManager.fetchRandomComic { [weak self] (comicModel) in + guard let comicModel = comicModel else { + return + } + self?.displayImage(comic: comicModel) + } } private func previousComic() { @@ -93,19 +102,26 @@ class ComicViewController: UIViewController { currentComic = previousComic } - private func showDetails() { let comicDetailsViewController = ComicDetailsViewController() - comicDetailsViewController.comicId = currentComic?.id + comicDetailsViewController.comic = currentComic comicDetailsViewController.comicImage = currentComic?.image let navigationController = UINavigationController(rootViewController: comicDetailsViewController) present(navigationController, animated: false) } - private func generateRandomNumber() -> Int { - return Int.random(in: 0 ... 2198) - } + private func displayImage(comic: ComicModel) { + networkManager.fetchImage(for: comic) { (image) in + guard let image = image else { + return + } + DispatchQueue.main.async { + self.currentComic = comic + self.comicImageView.image = image + } + } + } // MARK: ACTIONS @@ -120,49 +136,4 @@ class ComicViewController: UIViewController { @objc func handleTapGesture(_ sender: UITapGestureRecognizer) { showDetails() } - - // MARK: Navigation - - private func fetchComicData(completion: @escaping (ComicModel) -> Void) { - - let number = generateRandomNumber() - let urlString = "https://xkcd.com/\(number)/info.0.json" - let url = URL(string: urlString) - let session = URLSession.shared.dataTask(with: url!) { (data, _, _) in - - let jsonDecoder = JSONDecoder() - - guard let data = data else { - print("No data has been returned") - return - - } - do { - let comicInfo = try jsonDecoder.decode(ComicModel.self, from: data) - completion(comicInfo) - - } catch { - print("Failed at decoding") - } - } - - session.resume() - } - - private func displayImage(comic: ComicModel) { - let session = URLSession.shared.dataTask(with: comic.imageURL) { (data, _, _) in - - guard let data = data else { - print("No data has been returned") - return - } - let image = UIImage(data: data) - DispatchQueue.main.async { - self.currentComic = comic - self.comicImageView.image = image - } - - } - session.resume() - } } diff --git a/sources/NetworkError.swift b/sources/NetworkError.swift new file mode 100644 index 0000000..1790c9c --- /dev/null +++ b/sources/NetworkError.swift @@ -0,0 +1,14 @@ +// +// NetworkError.swift +// xkcd +// +// Created by thomas minshull on 2019-10-03. +// + +import Foundation + +enum NetworkError: Error { + case clientError + case serverError + case defaultNetworkError +} diff --git a/sources/NetworkManager.swift b/sources/NetworkManager.swift new file mode 100644 index 0000000..5137e2c --- /dev/null +++ b/sources/NetworkManager.swift @@ -0,0 +1,86 @@ +// +// NetworkManager.swift +// xkcd +// +// Created by thomas minshull on 2019-10-01. +// + +import UIKit + +struct NetworkManager { + func generateRandomId() -> Int { + return Int.random(in: 0 ... 2198) + } + + func fetchRandomComic(completion: @escaping (ComicModel?) -> Void) { + let randomId = generateRandomId() + fetchComicData(id: randomId, completion: completion) + } + + func fetchComicData(id: Int, completion: @escaping (ComicModel?) -> Void) { // swiftlint:disable:this identifier_name + let urlString = "https://xkcd.com/\(id)/info.0.json" + let url = URL(string: urlString)! + let dataTask = URLSession.shared.dataTask(with: url) { (data, _, _) in + + let jsonDecoder = JSONDecoder() + + guard let data = data else { + print("No data has been returned") + return + + } + let comicInfo = try? jsonDecoder.decode(ComicModel.self, from: data) + completion(comicInfo) + } + dataTask.resume() + } + + func fetchImage(for comic: ComicModel, with completion: @escaping ((UIImage?) -> Void)) { + let session = URLSession.shared.dataTask(with: comic.imageURL) { (data, _, _) in + + guard let data = data else { + print("No data has been returned") + return + } + + let image = UIImage(data: data) + completion(image) + } + session.resume() + } + + func fetchTags(completion: @escaping ((Result<[Tag], Error>) -> Void)) { + let urlString = "https://ivggashpl0.execute-api.us-west-2.amazonaws.com/staging/tags" + let url = URL(string: urlString)! + let dataTask = URLSession.shared.dataTask(with: url) { (data, response, _) in + + let jsonDecoder = JSONDecoder() + + guard let data = data else { + guard let response = response as? HTTPURLResponse else { + completion(.failure(NetworkError.defaultNetworkError)) + return + } + + switch response.statusCode { + case (400 ..< 500): + completion(.failure(NetworkError.clientError)) + case (500 ..< 600): + completion(.failure(NetworkError.serverError)) + default: + completion(.failure(NetworkError.defaultNetworkError)) + } + + return + } + + guard let tags = try? jsonDecoder.decode([Tag].self, from: data) else { + completion(.failure(XKCDError.failedToParseTagArray)) + return + } + + completion(.success(tags)) + } + dataTask.resume() + } +} diff --git a/sources/RandomComicViewController.swift b/sources/RandomComicViewController.swift index d7349dd..ec855c2 100644 --- a/sources/RandomComicViewController.swift +++ b/sources/RandomComicViewController.swift @@ -9,61 +9,20 @@ import UIKit class RandomComicViewController: UIViewController { - @IBOutlet weak var comicImage: UIImageView! + let networkManager = NetworkManager() - override func viewDidLoad() { - super.viewDidLoad() - } + @IBOutlet weak var comicImage: UIImageView! @IBAction func generateComic(_ sender: UIButton) { - - fetchComicData(completion: displayImage(comic:)) - - } - - func generateRandomNumber() -> Int { - return Int.random(in: 0 ... 2198) - } - - func fetchComicData(completion: @escaping (ComicModel) -> Void) { - let number = generateRandomNumber() - let urlString = "https://xkcd.com/\(number)/info.0.json" - let url = URL(string: urlString) - let session = URLSession.shared.dataTask(with: url!) { (data, _, _) in - - let jsonDecoder = JSONDecoder() - - guard let data = data else { - print("No data has been returned") - return - - } - do { - let comicInfo = try jsonDecoder.decode(ComicModel.self, from: data) - completion(comicInfo) - - } catch { - print("Failed at decoding") + let randomId = networkManager.generateRandomId() + networkManager.fetchComicData(id: randomId) { [weak self] (comicModel) in + guard let comicModel = comicModel else { return } + + self?.networkManager.fetchImage(for: comicModel) { (image) in + DispatchQueue.main.async { + self?.comicImage.image = image + } } } - - session.resume() } - - func displayImage(comic: ComicModel) { - let session = URLSession.shared.dataTask(with: comic.imageURL) { (data, _, _) in - - guard let data = data else { - print("No data has been returned") - return - } - let image = UIImage(data: data) - DispatchQueue.main.async { - self.comicImage.image = image - } - - } - session.resume() - } - } diff --git a/sources/Tag.swift b/sources/Tag.swift new file mode 100644 index 0000000..0bba8e0 --- /dev/null +++ b/sources/Tag.swift @@ -0,0 +1,20 @@ +// +// Tag.swift +// xkcd +// +// Created by thomas minshull on 2019-10-03. +// + +import Foundation + +struct Tag: Codable { + let title: String + let comicId: [Int] + + enum CodingKeys: String, CodingKey { + case title + case comicId = "id" + } +} + +extension Tag: Hashable { } diff --git a/sources/XKCDError.swift b/sources/XKCDError.swift index 78466c3..7e0a1a6 100644 --- a/sources/XKCDError.swift +++ b/sources/XKCDError.swift @@ -9,4 +9,5 @@ import Foundation enum XKCDError: Error { case failedToParseURL + case failedToParseTagArray } diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29