├── README.md ├── GitHubFollowers ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── Icon.png │ │ ├── icon_20pt.png │ │ ├── icon_29pt.png │ │ ├── icon_40pt.png │ │ ├── icon_76pt.png │ │ ├── icon_20pt@2x.png │ │ ├── icon_20pt@3x.png │ │ ├── icon_29pt-1.png │ │ ├── icon_29pt@2x.png │ │ ├── icon_29pt@3x.png │ │ ├── icon_40pt@2x.png │ │ ├── icon_40pt@3x.png │ │ ├── icon_60pt@2x.png │ │ ├── icon_60pt@3x.png │ │ ├── icon_76pt@2x.png │ │ ├── icon_83.5@2x.png │ │ ├── icon_20pt@2x-1.png │ │ ├── icon_29pt@2x-1.png │ │ ├── icon_40pt@2x-1.png │ │ └── Contents.json │ ├── gh-logo.imageset │ │ ├── gh-logo@2x.png │ │ ├── gh-logo@3x.png │ │ ├── gh-logo-dark@2x.png │ │ ├── gh-logo-dark@3x.png │ │ └── Contents.json │ ├── empty-state-logo.imageset │ │ ├── empty-state-logo@2x.png │ │ ├── empty-state-logo@3x.png │ │ ├── empty-state-logo-dark@2x.png │ │ ├── empty-state-logo-dark@3x.png │ │ └── Contents.json │ └── avatar-placeholder.imageset │ │ ├── avatar-placeholder@2x.png │ │ ├── avatar-placeholder@3x.png │ │ ├── avatar-placeholder-dark@2x.png │ │ ├── avatar-placeholder-dark@3x.png │ │ └── Contents.json ├── Model │ ├── Follower.swift │ └── User.swift ├── Controller │ ├── Search │ │ ├── SearchVC +TextFieldDelegate.swift │ │ └── SearchVC.swift │ ├── FavoritesVC.swift │ ├── UIViewController +Ext.swift │ ├── UserInfoVC.swift │ ├── GFAlertVC.swift │ ├── GFUserInfoHeaderVC.swift │ └── FollowersVC.swift ├── Utilities │ ├── Constants.swift │ ├── GFError.swift │ └── UIHelper.swift ├── View │ ├── GFSecondaryTitleLabel.swift │ ├── GFBodyLabel.swift │ ├── GFTitleLabel.swift │ ├── GFTextField.swift │ ├── GFButton.swift │ ├── GFAvatarImageView.swift │ ├── GFEmptyStateView.swift │ ├── Cells │ │ └── FollowerCell.swift │ └── GFItemInfoView.swift ├── AppDelegate.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── SceneDelegate.swift ├── Info.plist └── Managers │ └── NetworkManager.swift ├── .swiftlint.yml ├── GitHubFollowers.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcuserdata │ └── max.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── project.pbxproj └── .gitignore /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Followers 2 | -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - identifier_name 3 | - trailing_whitespace 4 | - vertical_whitespace 5 | 6 | line_length: 200 7 | function_body_length: 50 8 | -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/Icon.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/gh-logo.imageset/gh-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/gh-logo.imageset/gh-logo@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/gh-logo.imageset/gh-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/gh-logo.imageset/gh-logo@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_20pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_20pt.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_40pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_40pt.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_76pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_76pt.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_20pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_20pt@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_20pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_20pt@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt-1.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_40pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_40pt@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_40pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_40pt@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_60pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_60pt@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_60pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_60pt@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_76pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_76pt@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_83.5@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/gh-logo.imageset/gh-logo-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/gh-logo.imageset/gh-logo-dark@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/gh-logo.imageset/gh-logo-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/gh-logo.imageset/gh-logo-dark@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_20pt@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_20pt@2x-1.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_29pt@2x-1.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_40pt@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/AppIcon.appiconset/icon_40pt@2x-1.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/empty-state-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/empty-state-logo@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/empty-state-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/empty-state-logo@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/avatar-placeholder@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/avatar-placeholder@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/avatar-placeholder@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/avatar-placeholder@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/empty-state-logo-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/empty-state-logo-dark@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/empty-state-logo-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/empty-state-logo-dark@3x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/avatar-placeholder-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/avatar-placeholder-dark@2x.png -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/avatar-placeholder-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxnabokow/GitHubFollowers/HEAD/GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/avatar-placeholder-dark@3x.png -------------------------------------------------------------------------------- /GitHubFollowers.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /GitHubFollowers.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /GitHubFollowers/Model/Follower.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Follower.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/1/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Follower: Codable, Hashable { 12 | 13 | var login: String 14 | var avatarUrl: String 15 | 16 | } 17 | -------------------------------------------------------------------------------- /GitHubFollowers.xcodeproj/xcuserdata/max.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | GitHubFollowers.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /GitHubFollowers/Controller/Search/SearchVC +TextFieldDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchVC +TextFieldDelegate.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/1/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension SearchVC: UITextFieldDelegate { 12 | 13 | func textFieldShouldReturn(_ textField: UITextField) -> Bool { 14 | pushFollowersVC() 15 | return true 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /GitHubFollowers/Utilities/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 2/8/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum SFSymbols { 12 | static let location = "mappin.and.ellipse" 13 | static let repos = "folder" 14 | static let gists = "text.alignLeft" 15 | static let followers = "heart" 16 | static let following = "person.2" 17 | } 18 | -------------------------------------------------------------------------------- /GitHubFollowers/Model/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/1/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct User: Codable { 12 | 13 | let login: String 14 | let avatarUrl: String 15 | 16 | var name: String? 17 | var location: String? 18 | var bio: String? 19 | 20 | let publicRepos: Int 21 | let publicGists: Int 22 | 23 | let htmlUrl: String 24 | 25 | let following: Int 26 | let followers: Int 27 | let createdAt: String 28 | 29 | } 30 | -------------------------------------------------------------------------------- /GitHubFollowers/Utilities/GFError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFError.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/4/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum GFError: String, Error { 12 | 13 | case invalidUsername = "This username created an invalid request. Please try again." 14 | case unableToComplete = "Unable to complete your request. Please check your internet connection" 15 | case invalidResponse = "Invalid response from the server. Please try again." 16 | case invalidData = "The data received from the server was invalid. Please try again." 17 | 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | 3 | ## User settings 4 | xcuserdata/ 5 | *.xcuserstate 6 | 7 | .DS_Store 8 | 9 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 10 | *.xcscmblueprint 11 | *.xccheckout 12 | 13 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 14 | build/ 15 | DerivedData/ 16 | *.moved-aside 17 | *.pbxuser 18 | !default.pbxuser 19 | *.mode1v3 20 | !default.mode1v3 21 | *.mode2v3 22 | !default.mode2v3 23 | *.perspectivev3 24 | !default.perspectivev3 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | 29 | ## App packaging 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | .build/ 39 | 40 | iOSInjectionProject/ 41 | -------------------------------------------------------------------------------- /GitHubFollowers/Controller/FavoritesVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoritesListVC.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 12/28/19. 6 | // Copyright © 2019 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class FavoritesVC: UIViewController { 12 | 13 | // MARK: - UI Elements 14 | 15 | // MARK: - Properties 16 | 17 | // MARK: - Lifecycle 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | 22 | setupUI() 23 | setupLayout() 24 | } 25 | 26 | // MARK: - UI Setup 27 | 28 | fileprivate func setupUI() { 29 | view.backgroundColor = .systemBlue 30 | } 31 | 32 | fileprivate func setupLayout() { 33 | 34 | } 35 | 36 | // MARK: - Selectors 37 | 38 | // MARK: - Helper Functions 39 | 40 | } 41 | -------------------------------------------------------------------------------- /GitHubFollowers/Utilities/UIHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIHelper.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/15/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct UIHelper { 12 | 13 | static func createThreeColumnFlowLayout(in view: UIView) -> UICollectionViewFlowLayout { 14 | let layout = UICollectionViewFlowLayout() 15 | 16 | let width = view.bounds.width 17 | let padding: CGFloat = 12 18 | let minimumItemSpacing: CGFloat = 10 19 | let availableWidth = width - (padding * 2) - (minimumItemSpacing * 2) 20 | let itemWidth = availableWidth / 3 21 | 22 | layout.sectionInset = .init(top: padding, left: padding, bottom: padding, right: padding) 23 | layout.itemSize = .init(width: itemWidth, height: itemWidth + 40) 24 | 25 | return layout 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /GitHubFollowers/View/GFSecondaryTitleLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFSecondaryTitleLabel.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 2/8/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFSecondaryTitleLabel: UILabel { 12 | 13 | // MARK: - Init 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | 18 | setupUI() 19 | } 20 | 21 | init(fontSize: CGFloat) { 22 | super.init(frame: .zero) 23 | font = UIFont.systemFont(ofSize: fontSize, weight: .medium) 24 | 25 | setupUI() 26 | } 27 | 28 | // MARK: - UI Setup 29 | 30 | fileprivate func setupUI() { 31 | textColor = .secondaryLabel 32 | adjustsFontSizeToFitWidth = true 33 | minimumScaleFactor = 0.9 34 | lineBreakMode = .byTruncatingTail 35 | } 36 | 37 | required init?(coder: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /GitHubFollowers/View/GFBodyLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFBodyLabel.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/1/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFBodyLabel: UILabel { 12 | 13 | // MARK: - Init 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | 18 | setupUI() 19 | } 20 | 21 | init(textAlignment: NSTextAlignment) { 22 | super.init(frame: .zero) 23 | self.textAlignment = textAlignment 24 | 25 | setupUI() 26 | } 27 | 28 | // MARK: - UI Setup 29 | 30 | fileprivate func setupUI() { 31 | textColor = .secondaryLabel 32 | font = .preferredFont(forTextStyle: .body) 33 | adjustsFontSizeToFitWidth = true 34 | minimumScaleFactor = 0.75 35 | lineBreakMode = .byWordWrapping 36 | } 37 | 38 | required init?(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /GitHubFollowers/View/GFTitleLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFTitleLabel.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/1/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFTitleLabel: UILabel { 12 | 13 | // MARK: - Init 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | 18 | setupUI() 19 | } 20 | 21 | init(textAlignment: NSTextAlignment, fontSize: CGFloat) { 22 | super.init(frame: .zero) 23 | self.textAlignment = textAlignment 24 | self.font = .systemFont(ofSize: fontSize, weight: .bold) 25 | 26 | setupUI() 27 | } 28 | 29 | // MARK: - UI Setup 30 | 31 | fileprivate func setupUI() { 32 | textColor = .label 33 | adjustsFontSizeToFitWidth = true 34 | minimumScaleFactor = 0.9 35 | lineBreakMode = .byTruncatingTail 36 | } 37 | 38 | required init?(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /GitHubFollowers/View/GFTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFTextField.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 12/28/19. 6 | // Copyright © 2019 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFTextField: UITextField { 12 | 13 | // MARK: - Init 14 | override init(frame: CGRect) { 15 | super.init(frame: frame) 16 | 17 | setupUI() 18 | } 19 | 20 | // MARK: - UI Setup 21 | fileprivate func setupUI() { 22 | backgroundColor = .tertiarySystemBackground 23 | placeholder = "Enter a username" 24 | autocorrectionType = .no 25 | autocapitalizationType = .none 26 | returnKeyType = .go 27 | 28 | layer.cornerRadius = 12 29 | layer.borderWidth = 1 30 | layer.borderColor = UIColor.systemGray3.cgColor 31 | 32 | textColor = .label 33 | tintColor = .systemGreen 34 | textAlignment = .center 35 | font = .preferredFont(forTextStyle: .title2) 36 | adjustsFontSizeToFitWidth = true 37 | minimumFontSize = 12 38 | } 39 | 40 | required init?(coder: NSCoder) { 41 | fatalError("init(coder:) has not been implemented") 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/gh-logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "1x", 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ] 16 | }, 17 | { 18 | "idiom" : "universal", 19 | "filename" : "gh-logo@2x.png", 20 | "scale" : "2x" 21 | }, 22 | { 23 | "idiom" : "universal", 24 | "filename" : "gh-logo-dark@2x.png", 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "universal", 35 | "filename" : "gh-logo@3x.png", 36 | "scale" : "3x" 37 | }, 38 | { 39 | "idiom" : "universal", 40 | "filename" : "gh-logo-dark@3x.png", 41 | "appearances" : [ 42 | { 43 | "appearance" : "luminosity", 44 | "value" : "dark" 45 | } 46 | ], 47 | "scale" : "3x" 48 | } 49 | ], 50 | "info" : { 51 | "version" : 1, 52 | "author" : "xcode" 53 | } 54 | } -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/empty-state-logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "1x", 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ] 16 | }, 17 | { 18 | "idiom" : "universal", 19 | "filename" : "empty-state-logo@2x.png", 20 | "scale" : "2x" 21 | }, 22 | { 23 | "idiom" : "universal", 24 | "filename" : "empty-state-logo-dark@2x.png", 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "universal", 35 | "filename" : "empty-state-logo@3x.png", 36 | "scale" : "3x" 37 | }, 38 | { 39 | "idiom" : "universal", 40 | "filename" : "empty-state-logo-dark@3x.png", 41 | "appearances" : [ 42 | { 43 | "appearance" : "luminosity", 44 | "value" : "dark" 45 | } 46 | ], 47 | "scale" : "3x" 48 | } 49 | ], 50 | "info" : { 51 | "version" : 1, 52 | "author" : "xcode" 53 | } 54 | } -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/avatar-placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "1x", 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ] 16 | }, 17 | { 18 | "idiom" : "universal", 19 | "filename" : "avatar-placeholder@2x.png", 20 | "scale" : "2x" 21 | }, 22 | { 23 | "idiom" : "universal", 24 | "filename" : "avatar-placeholder-dark@2x.png", 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "universal", 35 | "filename" : "avatar-placeholder@3x.png", 36 | "scale" : "3x" 37 | }, 38 | { 39 | "idiom" : "universal", 40 | "filename" : "avatar-placeholder-dark@3x.png", 41 | "appearances" : [ 42 | { 43 | "appearance" : "luminosity", 44 | "value" : "dark" 45 | } 46 | ], 47 | "scale" : "3x" 48 | } 49 | ], 50 | "info" : { 51 | "version" : 1, 52 | "author" : "xcode" 53 | } 54 | } -------------------------------------------------------------------------------- /GitHubFollowers/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 12/28/19. 6 | // Copyright © 2019 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | // Override point for customization after application launch. 16 | return true 17 | } 18 | 19 | // MARK: UISceneSession Lifecycle 20 | 21 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 22 | // Called when a new scene session is being created. 23 | // Use this method to select a configuration to create the new scene with. 24 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 25 | } 26 | 27 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 28 | // Called when the user discards a scene session. 29 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 30 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /GitHubFollowers/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /GitHubFollowers/View/GFButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFButton.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 12/28/19. 6 | // Copyright © 2019 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFButton: UIButton { 12 | 13 | // MARK: - Init 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | 18 | setupUI() 19 | } 20 | 21 | init(backgroundColor: UIColor, title: String) { 22 | super.init(frame: .zero) 23 | self.backgroundColor = backgroundColor 24 | self.setTitle(title, for: .normal) 25 | 26 | setupUI() 27 | 28 | addTarget(self, action: #selector(animatePressDown), for: .touchDown) 29 | addTarget(self, action: #selector(animatePressUp), for: .touchDragExit) 30 | addTarget(self, action: #selector(animatePressDown), for: .touchDragEnter) 31 | addTarget(self, action: #selector(animatePressUp), for: .touchUpInside) 32 | } 33 | 34 | // MARK: - Helper Functions 35 | 36 | @objc fileprivate func animatePressDown() { 37 | UIButton.animate(withDuration: 0.1, animations: { 38 | self.transform = CGAffineTransform(scaleX: 0.975, y: 0.96) 39 | }) 40 | } 41 | 42 | @objc fileprivate func animatePressUp() { 43 | UIButton.animate(withDuration: 0.1, animations: { 44 | self.transform = .identity 45 | }) 46 | } 47 | 48 | // MARK: - UI Setup 49 | 50 | fileprivate func setupUI() { 51 | layer.cornerRadius = 12 52 | setTitleColor(.white, for: .normal) 53 | titleLabel?.font = .preferredFont(forTextStyle: .headline) 54 | } 55 | 56 | required init?(coder: NSCoder) { 57 | fatalError("init(coder:) has not been implemented") 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /GitHubFollowers/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 12/28/19. 6 | // Copyright © 2019 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 12 | 13 | var window: UIWindow? 14 | 15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 16 | 17 | guard let windowScene = scene as? UIWindowScene else { return } 18 | 19 | window = UIWindow(frame: windowScene.coordinateSpace.bounds) 20 | window?.windowScene = windowScene 21 | window?.rootViewController = createTabBarC() 22 | window?.makeKeyAndVisible() 23 | 24 | configureNavigationBar() 25 | } 26 | 27 | func createTabBarC() -> UITabBarController { 28 | let tabBarC = UITabBarController() 29 | UITabBar.appearance().tintColor = .systemGreen 30 | tabBarC.viewControllers = [createSearchNC(), createFavoritesNC()] 31 | 32 | return tabBarC 33 | } 34 | 35 | func configureNavigationBar() { 36 | UINavigationBar.appearance().tintColor = .systemGreen 37 | } 38 | 39 | func createSearchNC() -> UINavigationController { 40 | let searchVC = SearchVC() 41 | searchVC.title = "Search" 42 | searchVC.tabBarItem = UITabBarItem(tabBarSystemItem: .search, tag: 0) 43 | 44 | return UINavigationController(rootViewController: searchVC) 45 | } 46 | 47 | func createFavoritesNC() -> UINavigationController { 48 | let favoritesVC = FavoritesVC() 49 | favoritesVC.title = "Favorites" 50 | favoritesVC.tabBarItem = UITabBarItem(tabBarSystemItem: .favorites, tag: 1) 51 | 52 | return UINavigationController(rootViewController: favoritesVC) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /GitHubFollowers/View/GFAvatarImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFAvatarImageView.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/10/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFAvatarImageView: UIImageView { 12 | 13 | fileprivate let placeholderImage = #imageLiteral(resourceName: "avatar-placeholder") 14 | let cache = NetworkManager.shared.cache 15 | 16 | // MARK: - Init 17 | 18 | override init(frame: CGRect) { 19 | super.init(frame: frame) 20 | 21 | setupUI() 22 | } 23 | 24 | // MARK: - UI Setup 25 | 26 | fileprivate func setupUI() { 27 | layer.cornerRadius = 10 28 | clipsToBounds = true 29 | image = placeholderImage 30 | } 31 | 32 | // MARK: - Helper Functions 33 | 34 | func downloadImage(from urlString: String) { 35 | 36 | let cacheKey = NSString(string: urlString) 37 | 38 | if let image = cache.object(forKey: cacheKey) { 39 | self.image = image 40 | return 41 | } 42 | 43 | guard let url = URL(string: urlString) else { return } 44 | 45 | URLSession.shared.dataTask(with: url) { [weak self] (data, response, error) in 46 | guard let self = self else { return } 47 | 48 | if error != nil { return } 49 | guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { return } 50 | guard let data = data else { return } 51 | 52 | guard let image = UIImage(data: data) else { return } 53 | 54 | self.cache.setObject(image, forKey: cacheKey) 55 | 56 | DispatchQueue.main.async { 57 | self.image = image 58 | } 59 | 60 | }.resume() 61 | } 62 | 63 | required init?(coder: NSCoder) { 64 | fatalError("init(coder:) has not been implemented") 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /GitHubFollowers/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | 50 | UISupportedInterfaceOrientations~ipad 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationPortraitUpsideDown 54 | UIInterfaceOrientationLandscapeLeft 55 | UIInterfaceOrientationLandscapeRight 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /GitHubFollowers/Controller/UIViewController +Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController +Ext.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/1/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | // swiftlint:disable:next private_over_fileprivate 12 | fileprivate var containerView: UIView! 13 | 14 | extension UIViewController { 15 | 16 | func presentGFAlertOnMainThread(title: String, message: String, buttonTitle: String) { 17 | DispatchQueue.main.async { 18 | UIImpactFeedbackGenerator(style: .medium).impactOccurred() 19 | let alertVC = GFAlertVC(title: title, message: message, buttonTitle: buttonTitle) 20 | alertVC.modalPresentationStyle = .overFullScreen 21 | alertVC.modalTransitionStyle = .crossDissolve 22 | 23 | self.present(alertVC, animated: true) 24 | } 25 | } 26 | 27 | func showLoadingView() { 28 | containerView = UIView(frame: view.bounds) 29 | containerView.backgroundColor = .systemBackground 30 | containerView.alpha = 0 31 | 32 | view.addSubview(containerView) 33 | UIView.animate(withDuration: 0.25) { 34 | containerView.alpha = 0.8 35 | } 36 | 37 | let activityIndicator = UIActivityIndicatorView(style: .large) 38 | 39 | containerView.addSubview(activityIndicator) 40 | activityIndicator.translatesAutoresizingMaskIntoConstraints = false 41 | 42 | NSLayoutConstraint.activate([ 43 | activityIndicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), 44 | activityIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor) 45 | ]) 46 | 47 | activityIndicator.startAnimating() 48 | } 49 | 50 | func dismissLoadingView() { 51 | 52 | DispatchQueue.main.async { 53 | containerView.removeFromSuperview() 54 | containerView = nil 55 | } 56 | } 57 | 58 | func showEmptyStateView(in view: UIView, with message: String) { 59 | let emptyStateView = GFEmptyStateView(message: message) 60 | emptyStateView.frame = view.bounds 61 | view.addSubview(emptyStateView) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /GitHubFollowers/View/GFEmptyStateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFEmptyStateView.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/16/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFEmptyStateView: UIView { 12 | 13 | // MARK: - UI Elements 14 | 15 | fileprivate let messageLabel: UILabel = { 16 | let l = GFTitleLabel(textAlignment: .center, fontSize: 24) 17 | l.numberOfLines = 3 18 | l.textColor = .secondaryLabel 19 | return l 20 | }() 21 | 22 | fileprivate let logoImageView: UIImageView = { 23 | let iv = UIImageView(image: #imageLiteral(resourceName: "empty-state-logo")) 24 | return iv 25 | }() 26 | 27 | // MARK: - Init 28 | 29 | override init(frame: CGRect) { 30 | super.init(frame: frame) 31 | 32 | setupLayout() 33 | } 34 | 35 | init(message: String) { 36 | super.init(frame: .zero) 37 | messageLabel.text = message 38 | setupLayout() 39 | } 40 | 41 | // MARK: - UI Setup 42 | 43 | fileprivate func setupLayout() { 44 | addSubview(messageLabel) 45 | messageLabel.translatesAutoresizingMaskIntoConstraints = false 46 | NSLayoutConstraint.activate([ 47 | messageLabel.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -150), 48 | messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40), 49 | messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40), 50 | messageLabel.heightAnchor.constraint(equalToConstant: 200) 51 | ]) 52 | 53 | addSubview(logoImageView) 54 | logoImageView.translatesAutoresizingMaskIntoConstraints = false 55 | NSLayoutConstraint.activate([ 56 | logoImageView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1.3), 57 | logoImageView.heightAnchor.constraint(equalTo: logoImageView.widthAnchor), 58 | logoImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 170), 59 | logoImageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 40) 60 | ]) 61 | } 62 | 63 | required init?(coder: NSCoder) { 64 | fatalError("init(coder:) has not been implemented") 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /GitHubFollowers/View/Cells/FollowerCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FollowerCell.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/10/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class FollowerCell: UICollectionViewCell { 12 | 13 | // MARK: - UI Elements 14 | 15 | fileprivate let avatarImageView = GFAvatarImageView(frame: .zero) 16 | 17 | fileprivate let usernameLabel = GFTitleLabel(textAlignment: .center, fontSize: 14) 18 | 19 | // MARK: - Properties 20 | 21 | static let reuseId = "FollowerCell" 22 | 23 | // MARK: - Init 24 | 25 | override init(frame: CGRect) { 26 | super.init(frame: frame) 27 | 28 | setupUI() 29 | setupLayout() 30 | } 31 | 32 | func configure(follower: Follower) { 33 | usernameLabel.text = follower.login 34 | avatarImageView.downloadImage(from: follower.avatarUrl) 35 | } 36 | 37 | // MARK: - UI Setup 38 | 39 | fileprivate func setupUI() { 40 | 41 | } 42 | 43 | fileprivate func setupLayout() { 44 | 45 | let padding: CGFloat = 8 46 | 47 | addSubview(avatarImageView) 48 | avatarImageView.translatesAutoresizingMaskIntoConstraints = false 49 | NSLayoutConstraint.activate([ 50 | avatarImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding), 51 | avatarImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), 52 | avatarImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), 53 | avatarImageView.heightAnchor.constraint(equalTo: avatarImageView.widthAnchor) 54 | ]) 55 | 56 | addSubview(usernameLabel) 57 | usernameLabel.translatesAutoresizingMaskIntoConstraints = false 58 | NSLayoutConstraint.activate([ 59 | usernameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 12), 60 | usernameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), 61 | usernameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), 62 | usernameLabel.heightAnchor.constraint(equalToConstant: 20) 63 | ]) 64 | 65 | } 66 | 67 | required init?(coder: NSCoder) { 68 | fatalError("init(coder:) has not been implemented") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /GitHubFollowers/Managers/NetworkManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkManager.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/4/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class NetworkManager { 12 | static let shared = NetworkManager() 13 | private let baseUrl = "https://api.github.com/users/" 14 | let cache = NSCache() 15 | 16 | private init() {} 17 | 18 | func getFollowers(for username: String, pageNr: Int, completion: @escaping (Result<[Follower], GFError>) -> Void) { 19 | let endpoint = baseUrl + "\(username)/followers?per_page=100&page=\(pageNr)" 20 | 21 | guard let url = URL(string: endpoint) else { 22 | completion(.failure(.invalidUsername)) 23 | return 24 | } 25 | 26 | URLSession.shared.dataTask(with: url) { (data, response, error) in 27 | if error != nil { 28 | completion(.failure(.unableToComplete)) 29 | } 30 | 31 | guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { 32 | completion(.failure(.invalidResponse)) 33 | return 34 | } 35 | 36 | guard let data = data else { 37 | completion(.failure(.invalidData)) 38 | return 39 | } 40 | 41 | do { 42 | let decoder = JSONDecoder() 43 | decoder.keyDecodingStrategy = .convertFromSnakeCase 44 | let followers = try decoder.decode([Follower].self, from: data) 45 | completion(.success(followers)) 46 | } catch { 47 | completion(.failure(.invalidData)) 48 | } 49 | }.resume() 50 | } 51 | 52 | func getUserInfo(for username: String, completion: @escaping (Result) -> Void) { 53 | let endpoint = baseUrl + "\(username)" 54 | 55 | guard let url = URL(string: endpoint) else { 56 | completion(.failure(.invalidUsername)) 57 | return 58 | } 59 | 60 | URLSession.shared.dataTask(with: url) { (data, response, error) in 61 | if error != nil { 62 | completion(.failure(.unableToComplete)) 63 | } 64 | 65 | guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { 66 | completion(.failure(.invalidResponse)) 67 | return 68 | } 69 | 70 | guard let data = data else { 71 | completion(.failure(.invalidData)) 72 | return 73 | } 74 | 75 | do { 76 | let decoder = JSONDecoder() 77 | decoder.keyDecodingStrategy = .convertFromSnakeCase 78 | let user = try decoder.decode(User.self, from: data) 79 | completion(.success(user)) 80 | } catch { 81 | completion(.failure(.invalidData)) 82 | } 83 | }.resume() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /GitHubFollowers/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "icon_20pt@2x-1.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "icon_20pt@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "icon_29pt.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "icon_29pt@2x-1.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "icon_29pt@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "icon_40pt@2x-1.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "icon_40pt@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "icon_60pt@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "icon_60pt@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "icon_20pt.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "icon_20pt@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "icon_29pt-1.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "icon_29pt@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "icon_40pt.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "icon_40pt@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "icon_76pt.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "icon_76pt@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "icon_83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } -------------------------------------------------------------------------------- /GitHubFollowers/View/GFItemInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFItemInfoView.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 2/8/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum ItemInfoType { 12 | case repos, gists, followers, following 13 | } 14 | 15 | class GFItemInfoView: UIView { 16 | 17 | fileprivate let symbolImageView = UIImageView() 18 | fileprivate let titleLabel = GFTitleLabel(textAlignment: .left, fontSize: 14) 19 | fileprivate let countLabel = GFTitleLabel(textAlignment: .center, fontSize: 14) 20 | 21 | 22 | // MARK: - Init 23 | 24 | override init(frame: CGRect) { 25 | super.init(frame: frame) 26 | 27 | setupUI() 28 | setupLayout() 29 | } 30 | 31 | // MARK: - UI Setup 32 | 33 | fileprivate func setupUI() { 34 | symbolImageView.contentMode = .scaleAspectFill 35 | symbolImageView.tintColor = .label 36 | } 37 | 38 | fileprivate func setupLayout() { 39 | addSubview(symbolImageView) 40 | symbolImageView.translatesAutoresizingMaskIntoConstraints = false 41 | NSLayoutConstraint.activate([ 42 | symbolImageView.topAnchor.constraint(equalTo: topAnchor), 43 | symbolImageView.leadingAnchor.constraint(equalTo: leadingAnchor), 44 | symbolImageView.widthAnchor.constraint(equalToConstant: 20), 45 | symbolImageView.heightAnchor.constraint(equalToConstant: 20) 46 | ]) 47 | 48 | addSubview(titleLabel) 49 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 50 | NSLayoutConstraint.activate([ 51 | titleLabel.centerYAnchor.constraint(equalTo: symbolImageView.centerYAnchor), 52 | titleLabel.leadingAnchor.constraint(equalTo: symbolImageView.trailingAnchor, constant: 12), 53 | titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), 54 | titleLabel.heightAnchor.constraint(equalToConstant: 18) 55 | ]) 56 | 57 | addSubview(countLabel) 58 | countLabel.translatesAutoresizingMaskIntoConstraints = false 59 | NSLayoutConstraint.activate([ 60 | countLabel.topAnchor.constraint(equalTo: symbolImageView.bottomAnchor, constant: 4), 61 | countLabel.leadingAnchor.constraint(equalTo: leadingAnchor), 62 | countLabel.trailingAnchor.constraint(equalTo: trailingAnchor), 63 | countLabel.heightAnchor.constraint(equalToConstant: 18) 64 | ]) 65 | } 66 | 67 | func set(itemInfoType: ItemInfoType, withCount count: Int) { 68 | switch itemInfoType { 69 | case .repos: 70 | symbolImageView.image = UIImage(systemName: SFSymbols.repos) 71 | titleLabel.text = "Public Repos" 72 | case .gists: 73 | symbolImageView.image = UIImage(systemName: SFSymbols.gists) 74 | titleLabel.text = "Public Gists" 75 | case .followers: 76 | symbolImageView.image = UIImage(systemName: SFSymbols.followers) 77 | titleLabel.text = "Followers" 78 | case .following: 79 | symbolImageView.image = UIImage(systemName: SFSymbols.following) 80 | titleLabel.text = "Following" 81 | } 82 | 83 | countLabel.text = String(count) 84 | } 85 | 86 | required init?(coder: NSCoder) { 87 | fatalError("init(coder:) has not been implemented") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /GitHubFollowers/Controller/UserInfoVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserInfoVC.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/16/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class UserInfoVC: UIViewController { 12 | 13 | // MARK: - UI Elements 14 | fileprivate let headerView = UIView() 15 | fileprivate let itemView1 = UIView() 16 | fileprivate let itemView2 = UIView() 17 | 18 | fileprivate var itemViews: [UIView] = [] 19 | 20 | // MARK: - Properties 21 | 22 | var username: String? 23 | 24 | // MARK: - Lifecycle 25 | 26 | override func viewDidLoad() { 27 | super.viewDidLoad() 28 | 29 | setupUI() 30 | setupLayout() 31 | setupDoneButton() 32 | getUserInfo() 33 | } 34 | 35 | // MARK: - UI Setup 36 | 37 | fileprivate func setupUI() { 38 | view.backgroundColor = .systemBackground 39 | 40 | itemView1.backgroundColor = .systemPink 41 | itemView2.backgroundColor = .systemBlue 42 | } 43 | 44 | fileprivate func setupLayout() { 45 | 46 | itemViews = [headerView, itemView1, itemView2] 47 | 48 | let padding: CGFloat = 20 49 | let itemHeight: CGFloat = 140 50 | 51 | for itemView in itemViews { 52 | view.addSubview(itemView) 53 | itemView.translatesAutoresizingMaskIntoConstraints = false 54 | 55 | NSLayoutConstraint.activate([ 56 | itemView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), 57 | itemView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -padding) 58 | ]) 59 | } 60 | 61 | NSLayoutConstraint.activate([ 62 | headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 63 | headerView.heightAnchor.constraint(equalToConstant: 180), 64 | 65 | itemView1.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: padding), 66 | itemView1.heightAnchor.constraint(equalToConstant: itemHeight), 67 | 68 | itemView2.topAnchor.constraint(equalTo: itemView1.bottomAnchor, constant: padding), 69 | itemView2.heightAnchor.constraint(equalToConstant: itemHeight) 70 | ]) 71 | } 72 | 73 | fileprivate func setupDoneButton() { 74 | let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped)) 75 | navigationItem.rightBarButtonItem = doneButton 76 | } 77 | 78 | fileprivate func add(childVC: UIViewController, to containerView: UIView) { 79 | addChild(childVC) 80 | containerView.addSubview(childVC.view) 81 | childVC.view.frame = containerView.bounds 82 | } 83 | 84 | // MARK: - Selectors 85 | 86 | @objc func doneButtonTapped() { 87 | dismiss(animated: true) 88 | } 89 | 90 | // MARK: - Helper Functions 91 | fileprivate func getUserInfo() { 92 | guard let username = username else { return } 93 | 94 | NetworkManager.shared.getUserInfo(for: username) { [weak self] (result) in 95 | guard let self = self else { return } 96 | 97 | switch result { 98 | 99 | case .success(let user): 100 | DispatchQueue.main.async { 101 | self.add(childVC: GFUserInfoHeaderVC(user: user), to: self.headerView) 102 | } 103 | case .failure(let error): 104 | self.presentGFAlertOnMainThread(title: "Something went wrong", message: error.rawValue, buttonTitle: "OK") 105 | 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /GitHubFollowers/Controller/Search/SearchVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchVC.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 12/28/19. 6 | // Copyright © 2019 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SearchVC: UIViewController { 12 | 13 | // MARK: - UI Elements 14 | 15 | fileprivate let logoImageView: UIImageView = { 16 | let iv = UIImageView(image: #imageLiteral(resourceName: "gh-logo")) 17 | return iv 18 | }() 19 | 20 | fileprivate let usernameTextField = GFTextField() 21 | 22 | fileprivate let getFollowersButton: UIButton = { 23 | let b = GFButton(backgroundColor: .systemGreen, title: "Get Followers") 24 | b.addTarget(self, action: #selector(pushFollowersVC), for: .touchUpInside) 25 | return b 26 | }() 27 | 28 | // MARK: - Properties 29 | 30 | var isUsernameEntered: Bool { return !usernameTextField.text!.isEmpty } 31 | 32 | // MARK: - Lifecycle 33 | 34 | override func viewDidLoad() { 35 | super.viewDidLoad() 36 | 37 | usernameTextField.delegate = self 38 | 39 | setupUI() 40 | setupLayout() 41 | } 42 | 43 | override func viewWillAppear(_ animated: Bool) { 44 | super.viewWillAppear(animated) 45 | 46 | navigationController?.setNavigationBarHidden(true, animated: true) 47 | } 48 | 49 | // MARK: - Selectors 50 | 51 | @objc func pushFollowersVC() { 52 | guard isUsernameEntered else { 53 | presentGFAlertOnMainThread(title: "Empty Username", message: "Please enter a username. We need to know who to look for.", buttonTitle: "OK") 54 | return 55 | } 56 | 57 | view.endEditing(true) 58 | let followersVC = FollowersVC() 59 | followersVC.username = usernameTextField.text 60 | followersVC.title = usernameTextField.text 61 | navigationController?.pushViewController(followersVC, animated: true) 62 | } 63 | 64 | // MARK: - Helper Functions 65 | 66 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 67 | view.endEditing(true) 68 | } 69 | 70 | // MARK: - UI Setup 71 | 72 | fileprivate func setupUI() { 73 | view.backgroundColor = .systemBackground 74 | } 75 | 76 | fileprivate func setupLayout() { 77 | view.addSubview(logoImageView) 78 | logoImageView.translatesAutoresizingMaskIntoConstraints = false 79 | NSLayoutConstraint.activate([ 80 | logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 80), 81 | logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 82 | logoImageView.widthAnchor.constraint(equalToConstant: 200), 83 | logoImageView.heightAnchor.constraint(equalToConstant: 200) 84 | ]) 85 | 86 | view.addSubview(usernameTextField) 87 | usernameTextField.translatesAutoresizingMaskIntoConstraints = false 88 | NSLayoutConstraint.activate([ 89 | usernameTextField.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 48), 90 | usernameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50), 91 | usernameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -50), 92 | usernameTextField.heightAnchor.constraint(equalToConstant: 50) 93 | ]) 94 | 95 | view.addSubview(getFollowersButton) 96 | getFollowersButton.translatesAutoresizingMaskIntoConstraints = false 97 | NSLayoutConstraint.activate([ 98 | getFollowersButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50), 99 | getFollowersButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50), 100 | getFollowersButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -50), 101 | getFollowersButton.heightAnchor.constraint(equalToConstant: 50) 102 | ]) 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /GitHubFollowers/Controller/GFAlertVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFAlertVC.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/1/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFAlertVC: UIViewController { 12 | 13 | // MARK: - UI Elements 14 | 15 | fileprivate let containerView: UIView = { 16 | let v = UIView() 17 | v.backgroundColor = .systemBackground 18 | v.layer.cornerRadius = 16 19 | v.layer.borderWidth = 2 20 | v.layer.borderColor = UIColor.white.cgColor 21 | return v 22 | }() 23 | 24 | fileprivate lazy var titleLabel: UILabel = { 25 | let l = GFTitleLabel(textAlignment: .center, fontSize: 28) 26 | l.text = alertTitle ?? "Something went wrong." 27 | return l 28 | }() 29 | 30 | fileprivate lazy var messageLabel: UILabel = { 31 | let l = GFBodyLabel(textAlignment: .center) 32 | l.text = message ?? "Unable to complete request" 33 | l.numberOfLines = 4 34 | return l 35 | }() 36 | 37 | fileprivate lazy var actionButton: UIButton = { 38 | let b = GFButton(backgroundColor: .systemPink, title: "OK") 39 | b.setTitle(buttonTitle ?? "OK", for: .normal) 40 | b.addTarget(self, action: #selector(dismissVC), for: .touchUpInside) 41 | return b 42 | }() 43 | 44 | // MARK: - Properties 45 | 46 | var alertTitle: String? 47 | var message: String? 48 | var buttonTitle: String? 49 | 50 | let padding: CGFloat = 20 51 | 52 | // MARK: - Init 53 | 54 | init(title: String, message: String, buttonTitle: String) { 55 | super.init(nibName: nil, bundle: nil) 56 | 57 | self.alertTitle = title 58 | self.message = message 59 | self.buttonTitle = buttonTitle 60 | } 61 | 62 | // MARK: - Lifecycle 63 | 64 | override func viewDidLoad() { 65 | super.viewDidLoad() 66 | 67 | setupUI() 68 | setupLayout() 69 | } 70 | 71 | // MARK: - Selectors 72 | 73 | @objc fileprivate func dismissVC() { 74 | dismiss(animated: true) 75 | } 76 | 77 | // MARK: - Helper Functions 78 | 79 | // MARK: - UI Setup 80 | 81 | fileprivate func setupUI() { 82 | view.backgroundColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.75) 83 | } 84 | 85 | fileprivate func setupLayout() { 86 | view.addSubview(containerView) 87 | containerView.translatesAutoresizingMaskIntoConstraints = false 88 | NSLayoutConstraint.activate([ 89 | containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor), 90 | containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor), 91 | containerView.widthAnchor.constraint(equalToConstant: 280), 92 | containerView.heightAnchor.constraint(equalToConstant: 220) 93 | ]) 94 | 95 | containerView.addSubview(titleLabel) 96 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 97 | NSLayoutConstraint.activate([ 98 | titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: padding), 99 | titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), 100 | titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), 101 | titleLabel.heightAnchor.constraint(equalToConstant: 28) 102 | ]) 103 | 104 | containerView.addSubview(actionButton) 105 | actionButton.translatesAutoresizingMaskIntoConstraints = false 106 | NSLayoutConstraint.activate([ 107 | actionButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -padding), 108 | actionButton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), 109 | actionButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), 110 | actionButton.heightAnchor.constraint(equalToConstant: 44) 111 | ]) 112 | 113 | containerView.addSubview(messageLabel) 114 | messageLabel.translatesAutoresizingMaskIntoConstraints = false 115 | NSLayoutConstraint.activate([ 116 | messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), 117 | messageLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), 118 | messageLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), 119 | messageLabel.bottomAnchor.constraint(equalTo: actionButton.topAnchor, constant: -12) 120 | ]) 121 | } 122 | 123 | required init?(coder: NSCoder) { 124 | fatalError("init(coder:) has not been implemented") 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /GitHubFollowers/Controller/GFUserInfoHeaderVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GFUserInfoHeaderVC.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 2/8/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class GFUserInfoHeaderVC: UIViewController { 12 | 13 | // MARK: - UI Elements 14 | 15 | fileprivate let avatarImageView = GFAvatarImageView(frame: .zero) 16 | fileprivate let usernameLabel = GFTitleLabel(textAlignment: .left, fontSize: 34) 17 | fileprivate let nameLabel = GFSecondaryTitleLabel(fontSize: 18) 18 | 19 | fileprivate let locationImageView = UIImageView() 20 | 21 | fileprivate let locationLabel = GFSecondaryTitleLabel(fontSize: 18) 22 | fileprivate let bioLabel = GFBodyLabel(textAlignment: .left) 23 | 24 | // MARK: - Properties 25 | 26 | var user: User! 27 | 28 | // MARK: Init 29 | 30 | init(user: User) { 31 | super.init(nibName: nil, bundle: nil) 32 | self.user = user 33 | } 34 | 35 | // MARK: - Lifecycle 36 | 37 | override func viewDidLoad() { 38 | super.viewDidLoad() 39 | 40 | setupUI() 41 | setupLayout() 42 | } 43 | 44 | // MARK: - UI Setup 45 | 46 | fileprivate func setupUI() { 47 | avatarImageView.downloadImage(from: user.avatarUrl) 48 | usernameLabel.text = user.login 49 | nameLabel.text = user.name ?? "" 50 | locationImageView.image = UIImage(systemName: SFSymbols.location) 51 | locationImageView.tintColor = .secondaryLabel 52 | locationLabel.text = user.location ?? "No Location available" 53 | bioLabel.text = user.bio ?? "No Bio available" 54 | bioLabel.numberOfLines = 3 55 | } 56 | 57 | fileprivate func setupLayout() { 58 | let padding: CGFloat = 20 59 | let textImagePadding: CGFloat = 20 60 | 61 | view.addSubview(avatarImageView) 62 | avatarImageView.translatesAutoresizingMaskIntoConstraints = false 63 | NSLayoutConstraint.activate([ 64 | avatarImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: padding), 65 | avatarImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 66 | avatarImageView.widthAnchor.constraint(equalToConstant: 90), 67 | avatarImageView.heightAnchor.constraint(equalToConstant: 90) 68 | ]) 69 | 70 | view.addSubview(usernameLabel) 71 | usernameLabel.translatesAutoresizingMaskIntoConstraints = false 72 | NSLayoutConstraint.activate([ 73 | usernameLabel.topAnchor.constraint(equalTo: avatarImageView.topAnchor), 74 | usernameLabel.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: textImagePadding), 75 | usernameLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor), 76 | usernameLabel.heightAnchor.constraint(equalToConstant: 38) 77 | ]) 78 | 79 | view.addSubview(nameLabel) 80 | nameLabel.translatesAutoresizingMaskIntoConstraints = false 81 | NSLayoutConstraint.activate([ 82 | nameLabel.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor, constant: 8), 83 | nameLabel.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: textImagePadding), 84 | nameLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor), 85 | nameLabel.heightAnchor.constraint(equalToConstant: 20) 86 | ]) 87 | 88 | view.addSubview(locationImageView) 89 | locationImageView.translatesAutoresizingMaskIntoConstraints = false 90 | NSLayoutConstraint.activate([ 91 | locationImageView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), 92 | locationImageView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: textImagePadding), 93 | locationImageView.widthAnchor.constraint(equalToConstant: 20), 94 | locationImageView.heightAnchor.constraint(equalToConstant: 20) 95 | ]) 96 | 97 | view.addSubview(locationLabel) 98 | locationLabel.translatesAutoresizingMaskIntoConstraints = false 99 | NSLayoutConstraint.activate([ 100 | locationLabel.centerYAnchor.constraint(equalTo: locationImageView.centerYAnchor), 101 | locationLabel.leadingAnchor.constraint(equalTo: locationImageView.trailingAnchor, constant: 5), 102 | locationLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor), 103 | locationLabel.heightAnchor.constraint(equalToConstant: 20) 104 | ]) 105 | 106 | view.addSubview(bioLabel) 107 | bioLabel.translatesAutoresizingMaskIntoConstraints = false 108 | NSLayoutConstraint.activate([ 109 | bioLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: textImagePadding), 110 | bioLabel.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), 111 | bioLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor), 112 | bioLabel.heightAnchor.constraint(equalToConstant: 60) 113 | ]) 114 | } 115 | 116 | // MARK: - Selectors 117 | 118 | 119 | 120 | // MARK: - Helper Functions 121 | 122 | 123 | required init?(coder: NSCoder) { 124 | fatalError("init(coder:) has not been implemented") 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /GitHubFollowers/Controller/FollowersVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FollowersVC.swift 3 | // GitHubFollowers 4 | // 5 | // Created by Max Nabokow on 1/1/20. 6 | // Copyright © 2020 Maximilian Nabokow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class FollowersVC: UIViewController { 12 | 13 | // MARK: - UI Elements 14 | 15 | fileprivate lazy var collectionView: UICollectionView = { 16 | let cv = UICollectionView(frame: .zero, collectionViewLayout: UIHelper.createThreeColumnFlowLayout(in: view)) 17 | cv.backgroundColor = .systemBackground 18 | cv.register(FollowerCell.self, forCellWithReuseIdentifier: FollowerCell.reuseId) 19 | cv.delegate = self 20 | return cv 21 | }() 22 | 23 | // MARK: - Properties 24 | 25 | var username: String? 26 | 27 | enum Section { 28 | case main 29 | } 30 | 31 | var dataSource: UICollectionViewDiffableDataSource! 32 | var followers = [Follower]() 33 | var originalFollowers = [Follower]() 34 | var page = 1 35 | var hasMoreFollowers = true 36 | 37 | // MARK: - Lifecycle 38 | 39 | override func viewDidLoad() { 40 | super.viewDidLoad() 41 | 42 | setupUI() 43 | setupLayout() 44 | fetchFollowers(username: username, page: page) 45 | configureDataSource() 46 | configureSearchController() 47 | } 48 | 49 | override func viewWillAppear(_ animated: Bool) { 50 | navigationController?.setNavigationBarHidden(false, animated: true) 51 | } 52 | 53 | // MARK: - UI Setup 54 | 55 | fileprivate func setupUI() { 56 | view.backgroundColor = .systemBackground 57 | navigationController?.navigationBar.prefersLargeTitles = true 58 | } 59 | 60 | fileprivate func setupLayout() { 61 | view.addSubview(collectionView) 62 | collectionView.frame = view.bounds 63 | } 64 | 65 | // MARK: - Selectors 66 | 67 | // MARK: - Helper Functions 68 | 69 | fileprivate func fetchFollowers(username: String?, page: Int) { 70 | showLoadingView() 71 | 72 | guard let username = username else { return } 73 | 74 | NetworkManager.shared.getFollowers(for: username, pageNr: page) { [weak self] (result) in 75 | guard let self = self else { return } 76 | self.dismissLoadingView() 77 | 78 | switch result { 79 | 80 | case .success(let followers): 81 | if followers.count < 100 { self.hasMoreFollowers = false } 82 | self.followers.append(contentsOf: followers) 83 | self.originalFollowers.append(contentsOf: followers) 84 | 85 | if self.followers.isEmpty { 86 | let message = "This user doesn't have any followers. Go follow them!" 87 | DispatchQueue.main.async { 88 | self.showEmptyStateView(in: self.view, with: message) 89 | return 90 | } 91 | } 92 | 93 | self.updateData(with: self.followers) 94 | 95 | case.failure(let error): 96 | self.presentGFAlertOnMainThread(title: "Bad Stuff Happened", message: error.rawValue, buttonTitle: "OK") 97 | } 98 | } 99 | } 100 | 101 | fileprivate func configureDataSource() { 102 | dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { (collectionView, indexPath, follower) -> UICollectionViewCell? in 103 | guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: FollowerCell.reuseId, for: indexPath) as? FollowerCell else { return nil} 104 | cell.configure(follower: follower) 105 | return cell 106 | }) 107 | } 108 | 109 | fileprivate func updateData(with followers: [Follower]) { 110 | var snapshot = NSDiffableDataSourceSnapshot() 111 | snapshot.appendSections([.main]) 112 | snapshot.appendItems(followers) 113 | 114 | DispatchQueue.main.async { 115 | self.dataSource.apply(snapshot, animatingDifferences: true) 116 | } 117 | } 118 | 119 | fileprivate func configureSearchController() { 120 | let searchController = UISearchController() 121 | searchController.searchResultsUpdater = self 122 | searchController.searchBar.delegate = self 123 | searchController.searchBar.placeholder = "Search for a username" 124 | searchController.obscuresBackgroundDuringPresentation = false 125 | navigationItem.searchController = searchController 126 | } 127 | } 128 | 129 | extension FollowersVC: UICollectionViewDelegate { 130 | 131 | func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { 132 | let offsetY = scrollView.contentOffset.y 133 | let contentHeight = scrollView.contentSize.height 134 | let height = scrollView.frame.size.height 135 | 136 | if offsetY > contentHeight - height { 137 | guard hasMoreFollowers else { return } 138 | page += 1 139 | fetchFollowers(username: username, page: page) 140 | } 141 | } 142 | 143 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 144 | let follower = followers[indexPath.item] 145 | 146 | let userInfoVC = UserInfoVC() 147 | userInfoVC.username = follower.login 148 | 149 | let navC = UINavigationController(rootViewController: userInfoVC) 150 | 151 | present(navC, animated: true) 152 | } 153 | } 154 | 155 | extension FollowersVC: UISearchResultsUpdating, UISearchBarDelegate { 156 | 157 | func updateSearchResults(for searchController: UISearchController) { 158 | guard let filter = searchController.searchBar.text, !filter.isEmpty else { return } 159 | 160 | followers = originalFollowers.filter { $0.login.lowercased().contains(filter.lowercased()) } 161 | updateData(with: followers) 162 | } 163 | 164 | func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { 165 | followers = originalFollowers 166 | updateData(with: followers) 167 | } 168 | 169 | } 170 | -------------------------------------------------------------------------------- /GitHubFollowers.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 500D8E4323EF4A6100779E4D /* GFUserInfoHeaderVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500D8E4223EF4A6100779E4D /* GFUserInfoHeaderVC.swift */; }; 11 | 500D8E4523EF4B0400779E4D /* GFSecondaryTitleLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500D8E4423EF4B0400779E4D /* GFSecondaryTitleLabel.swift */; }; 12 | 500D8E4723EFA71100779E4D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500D8E4623EFA71100779E4D /* Constants.swift */; }; 13 | 500D8E4923EFB16E00779E4D /* GFItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500D8E4823EFB16E00779E4D /* GFItemInfoView.swift */; }; 14 | 501CB27423BCC9E600FA6407 /* Follower.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501CB27323BCC9E600FA6407 /* Follower.swift */; }; 15 | 501CB27623BCCAAC00FA6407 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501CB27523BCCAAC00FA6407 /* User.swift */; }; 16 | 506B0EFA23B803A000FE5982 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506B0EF923B803A000FE5982 /* AppDelegate.swift */; }; 17 | 506B0EFC23B803A000FE5982 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506B0EFB23B803A000FE5982 /* SceneDelegate.swift */; }; 18 | 506B0F0323B803AA00FE5982 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 506B0F0223B803AA00FE5982 /* Assets.xcassets */; }; 19 | 506B0F0623B803AA00FE5982 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 506B0F0423B803AA00FE5982 /* LaunchScreen.storyboard */; }; 20 | 506B0F1923B806DC00FE5982 /* SearchVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506B0F1823B806DC00FE5982 /* SearchVC.swift */; }; 21 | 506B0F1D23B809AC00FE5982 /* FavoritesVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506B0F1C23B809AC00FE5982 /* FavoritesVC.swift */; }; 22 | 506B0F2323B80D8400FE5982 /* GFButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506B0F2223B80D8400FE5982 /* GFButton.swift */; }; 23 | 506B0F2623B8134800FE5982 /* GFTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506B0F2523B8134800FE5982 /* GFTextField.swift */; }; 24 | 506CAA3723C9746C00A00E66 /* FollowerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506CAA3623C9746C00A00E66 /* FollowerCell.swift */; }; 25 | 506CAA3923C9750F00A00E66 /* GFAvatarImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506CAA3823C9750F00A00E66 /* GFAvatarImageView.swift */; }; 26 | 506E96F223D0198E00249802 /* UIHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506E96F123D0198E00249802 /* UIHelper.swift */; }; 27 | 506E96F423D02B6900249802 /* GFEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506E96F323D02B6900249802 /* GFEmptyStateView.swift */; }; 28 | 506E96F623D0354400249802 /* UserInfoVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506E96F523D0354400249802 /* UserInfoVC.swift */; }; 29 | 506E96F823D03D5600249802 /* GFError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 506E96F723D03D5600249802 /* GFError.swift */; }; 30 | 50A86C9923C0C025005D014A /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A86C9823C0C025005D014A /* NetworkManager.swift */; }; 31 | 50B9B0E423BC353500819BEB /* SearchVC +TextFieldDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B9B0E323BC353500819BEB /* SearchVC +TextFieldDelegate.swift */; }; 32 | 50B9B0E723BC360600819BEB /* FollowersVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B9B0E623BC360600819BEB /* FollowersVC.swift */; }; 33 | 50B9B0EB23BC3FCC00819BEB /* GFTitleLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B9B0EA23BC3FCC00819BEB /* GFTitleLabel.swift */; }; 34 | 50B9B0ED23BC410300819BEB /* GFBodyLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B9B0EC23BC410300819BEB /* GFBodyLabel.swift */; }; 35 | 50B9B0EF23BC41D000819BEB /* GFAlertVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B9B0EE23BC41D000819BEB /* GFAlertVC.swift */; }; 36 | 50B9B0F123BC48D400819BEB /* UIViewController +Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B9B0F023BC48D400819BEB /* UIViewController +Ext.swift */; }; 37 | /* End PBXBuildFile section */ 38 | 39 | /* Begin PBXFileReference section */ 40 | 500D8E4223EF4A6100779E4D /* GFUserInfoHeaderVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFUserInfoHeaderVC.swift; sourceTree = ""; }; 41 | 500D8E4423EF4B0400779E4D /* GFSecondaryTitleLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFSecondaryTitleLabel.swift; sourceTree = ""; }; 42 | 500D8E4623EFA71100779E4D /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 43 | 500D8E4823EFB16E00779E4D /* GFItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFItemInfoView.swift; sourceTree = ""; }; 44 | 501CB27323BCC9E600FA6407 /* Follower.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Follower.swift; sourceTree = ""; }; 45 | 501CB27523BCCAAC00FA6407 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 46 | 506B0EF623B803A000FE5982 /* GitHubFollowers.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GitHubFollowers.app; sourceTree = BUILT_PRODUCTS_DIR; }; 47 | 506B0EF923B803A000FE5982 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 48 | 506B0EFB23B803A000FE5982 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 49 | 506B0F0223B803AA00FE5982 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 50 | 506B0F0523B803AA00FE5982 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 51 | 506B0F0723B803AA00FE5982 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 52 | 506B0F1823B806DC00FE5982 /* SearchVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchVC.swift; sourceTree = ""; }; 53 | 506B0F1C23B809AC00FE5982 /* FavoritesVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesVC.swift; sourceTree = ""; }; 54 | 506B0F2223B80D8400FE5982 /* GFButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFButton.swift; sourceTree = ""; }; 55 | 506B0F2523B8134800FE5982 /* GFTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFTextField.swift; sourceTree = ""; }; 56 | 506CAA3623C9746C00A00E66 /* FollowerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerCell.swift; sourceTree = ""; }; 57 | 506CAA3823C9750F00A00E66 /* GFAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFAvatarImageView.swift; sourceTree = ""; }; 58 | 506E96F123D0198E00249802 /* UIHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIHelper.swift; sourceTree = ""; }; 59 | 506E96F323D02B6900249802 /* GFEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFEmptyStateView.swift; sourceTree = ""; }; 60 | 506E96F523D0354400249802 /* UserInfoVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfoVC.swift; sourceTree = ""; }; 61 | 506E96F723D03D5600249802 /* GFError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFError.swift; sourceTree = ""; }; 62 | 50A86C9823C0C025005D014A /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; 63 | 50B9B0E323BC353500819BEB /* SearchVC +TextFieldDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchVC +TextFieldDelegate.swift"; sourceTree = ""; }; 64 | 50B9B0E623BC360600819BEB /* FollowersVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersVC.swift; sourceTree = ""; }; 65 | 50B9B0EA23BC3FCC00819BEB /* GFTitleLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFTitleLabel.swift; sourceTree = ""; }; 66 | 50B9B0EC23BC410300819BEB /* GFBodyLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFBodyLabel.swift; sourceTree = ""; }; 67 | 50B9B0EE23BC41D000819BEB /* GFAlertVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GFAlertVC.swift; sourceTree = ""; }; 68 | 50B9B0F023BC48D400819BEB /* UIViewController +Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController +Ext.swift"; sourceTree = ""; }; 69 | /* End PBXFileReference section */ 70 | 71 | /* Begin PBXFrameworksBuildPhase section */ 72 | 506B0EF323B803A000FE5982 /* Frameworks */ = { 73 | isa = PBXFrameworksBuildPhase; 74 | buildActionMask = 2147483647; 75 | files = ( 76 | ); 77 | runOnlyForDeploymentPostprocessing = 0; 78 | }; 79 | /* End PBXFrameworksBuildPhase section */ 80 | 81 | /* Begin PBXGroup section */ 82 | 029B8B1736BD1A02C170117B /* Frameworks */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | ); 86 | name = Frameworks; 87 | sourceTree = ""; 88 | }; 89 | 501CB27223BCC8C000FA6407 /* Model */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | 501CB27323BCC9E600FA6407 /* Follower.swift */, 93 | 501CB27523BCCAAC00FA6407 /* User.swift */, 94 | ); 95 | path = Model; 96 | sourceTree = ""; 97 | }; 98 | 506B0EED23B803A000FE5982 = { 99 | isa = PBXGroup; 100 | children = ( 101 | 506B0EF823B803A000FE5982 /* GitHubFollowers */, 102 | 506B0EF723B803A000FE5982 /* Products */, 103 | 029B8B1736BD1A02C170117B /* Frameworks */, 104 | ); 105 | sourceTree = ""; 106 | }; 107 | 506B0EF723B803A000FE5982 /* Products */ = { 108 | isa = PBXGroup; 109 | children = ( 110 | 506B0EF623B803A000FE5982 /* GitHubFollowers.app */, 111 | ); 112 | name = Products; 113 | sourceTree = ""; 114 | }; 115 | 506B0EF823B803A000FE5982 /* GitHubFollowers */ = { 116 | isa = PBXGroup; 117 | children = ( 118 | 506B0EF923B803A000FE5982 /* AppDelegate.swift */, 119 | 506B0EFB23B803A000FE5982 /* SceneDelegate.swift */, 120 | 501CB27223BCC8C000FA6407 /* Model */, 121 | 506B0F2423B80D8800FE5982 /* View */, 122 | 506B0F2123B80D4C00FE5982 /* Controller */, 123 | 50F4771423C02C0D00B68B7F /* Managers */, 124 | 50A86C9523C0BDC1005D014A /* Utilities */, 125 | 506B0F0223B803AA00FE5982 /* Assets.xcassets */, 126 | 506B0F0423B803AA00FE5982 /* LaunchScreen.storyboard */, 127 | 506B0F0723B803AA00FE5982 /* Info.plist */, 128 | ); 129 | path = GitHubFollowers; 130 | sourceTree = ""; 131 | }; 132 | 506B0F2123B80D4C00FE5982 /* Controller */ = { 133 | isa = PBXGroup; 134 | children = ( 135 | 50B9B0E523BC356100819BEB /* Search */, 136 | 506B0F1C23B809AC00FE5982 /* FavoritesVC.swift */, 137 | 50B9B0E623BC360600819BEB /* FollowersVC.swift */, 138 | 50B9B0EE23BC41D000819BEB /* GFAlertVC.swift */, 139 | 50B9B0F023BC48D400819BEB /* UIViewController +Ext.swift */, 140 | 506E96F523D0354400249802 /* UserInfoVC.swift */, 141 | 500D8E4223EF4A6100779E4D /* GFUserInfoHeaderVC.swift */, 142 | ); 143 | path = Controller; 144 | sourceTree = ""; 145 | }; 146 | 506B0F2423B80D8800FE5982 /* View */ = { 147 | isa = PBXGroup; 148 | children = ( 149 | 506CAA3523C9744600A00E66 /* Cells */, 150 | 506B0F2523B8134800FE5982 /* GFTextField.swift */, 151 | 506B0F2223B80D8400FE5982 /* GFButton.swift */, 152 | 50B9B0EA23BC3FCC00819BEB /* GFTitleLabel.swift */, 153 | 50B9B0EC23BC410300819BEB /* GFBodyLabel.swift */, 154 | 506CAA3823C9750F00A00E66 /* GFAvatarImageView.swift */, 155 | 506E96F323D02B6900249802 /* GFEmptyStateView.swift */, 156 | 500D8E4423EF4B0400779E4D /* GFSecondaryTitleLabel.swift */, 157 | 500D8E4823EFB16E00779E4D /* GFItemInfoView.swift */, 158 | ); 159 | path = View; 160 | sourceTree = ""; 161 | }; 162 | 506CAA3523C9744600A00E66 /* Cells */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | 506CAA3623C9746C00A00E66 /* FollowerCell.swift */, 166 | ); 167 | path = Cells; 168 | sourceTree = ""; 169 | }; 170 | 50A86C9523C0BDC1005D014A /* Utilities */ = { 171 | isa = PBXGroup; 172 | children = ( 173 | 506E96F123D0198E00249802 /* UIHelper.swift */, 174 | 506E96F723D03D5600249802 /* GFError.swift */, 175 | 500D8E4623EFA71100779E4D /* Constants.swift */, 176 | ); 177 | path = Utilities; 178 | sourceTree = ""; 179 | }; 180 | 50B9B0E523BC356100819BEB /* Search */ = { 181 | isa = PBXGroup; 182 | children = ( 183 | 506B0F1823B806DC00FE5982 /* SearchVC.swift */, 184 | 50B9B0E323BC353500819BEB /* SearchVC +TextFieldDelegate.swift */, 185 | ); 186 | path = Search; 187 | sourceTree = ""; 188 | }; 189 | 50F4771423C02C0D00B68B7F /* Managers */ = { 190 | isa = PBXGroup; 191 | children = ( 192 | 50A86C9823C0C025005D014A /* NetworkManager.swift */, 193 | ); 194 | path = Managers; 195 | sourceTree = ""; 196 | }; 197 | /* End PBXGroup section */ 198 | 199 | /* Begin PBXNativeTarget section */ 200 | 506B0EF523B803A000FE5982 /* GitHubFollowers */ = { 201 | isa = PBXNativeTarget; 202 | buildConfigurationList = 506B0F0A23B803AA00FE5982 /* Build configuration list for PBXNativeTarget "GitHubFollowers" */; 203 | buildPhases = ( 204 | 506B0EF223B803A000FE5982 /* Sources */, 205 | 506B0EF323B803A000FE5982 /* Frameworks */, 206 | 506B0EF423B803A000FE5982 /* Resources */, 207 | 50C6327C23D7307D00B9F4B7 /* Run Script: SwiftLint */, 208 | ); 209 | buildRules = ( 210 | ); 211 | dependencies = ( 212 | ); 213 | name = GitHubFollowers; 214 | productName = GitHubFollowers; 215 | productReference = 506B0EF623B803A000FE5982 /* GitHubFollowers.app */; 216 | productType = "com.apple.product-type.application"; 217 | }; 218 | /* End PBXNativeTarget section */ 219 | 220 | /* Begin PBXProject section */ 221 | 506B0EEE23B803A000FE5982 /* Project object */ = { 222 | isa = PBXProject; 223 | attributes = { 224 | LastSwiftUpdateCheck = 1130; 225 | LastUpgradeCheck = 1130; 226 | ORGANIZATIONNAME = "Maximilian Nabokow"; 227 | TargetAttributes = { 228 | 506B0EF523B803A000FE5982 = { 229 | CreatedOnToolsVersion = 11.3; 230 | }; 231 | }; 232 | }; 233 | buildConfigurationList = 506B0EF123B803A000FE5982 /* Build configuration list for PBXProject "GitHubFollowers" */; 234 | compatibilityVersion = "Xcode 9.3"; 235 | developmentRegion = en; 236 | hasScannedForEncodings = 0; 237 | knownRegions = ( 238 | en, 239 | Base, 240 | ); 241 | mainGroup = 506B0EED23B803A000FE5982; 242 | productRefGroup = 506B0EF723B803A000FE5982 /* Products */; 243 | projectDirPath = ""; 244 | projectRoot = ""; 245 | targets = ( 246 | 506B0EF523B803A000FE5982 /* GitHubFollowers */, 247 | ); 248 | }; 249 | /* End PBXProject section */ 250 | 251 | /* Begin PBXResourcesBuildPhase section */ 252 | 506B0EF423B803A000FE5982 /* Resources */ = { 253 | isa = PBXResourcesBuildPhase; 254 | buildActionMask = 2147483647; 255 | files = ( 256 | 506B0F0623B803AA00FE5982 /* LaunchScreen.storyboard in Resources */, 257 | 506B0F0323B803AA00FE5982 /* Assets.xcassets in Resources */, 258 | ); 259 | runOnlyForDeploymentPostprocessing = 0; 260 | }; 261 | /* End PBXResourcesBuildPhase section */ 262 | 263 | /* Begin PBXShellScriptBuildPhase section */ 264 | 50C6327C23D7307D00B9F4B7 /* Run Script: SwiftLint */ = { 265 | isa = PBXShellScriptBuildPhase; 266 | buildActionMask = 2147483647; 267 | files = ( 268 | ); 269 | inputFileListPaths = ( 270 | ); 271 | inputPaths = ( 272 | ); 273 | name = "Run Script: SwiftLint"; 274 | outputFileListPaths = ( 275 | ); 276 | outputPaths = ( 277 | ); 278 | runOnlyForDeploymentPostprocessing = 0; 279 | shellPath = /bin/sh; 280 | shellScript = "if which swiftlint >/dev/null; then\n swiftlint autocorrect\n swiftlint\nelse\n echo \"warning: Swiftlint is not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; 281 | }; 282 | /* End PBXShellScriptBuildPhase section */ 283 | 284 | /* Begin PBXSourcesBuildPhase section */ 285 | 506B0EF223B803A000FE5982 /* Sources */ = { 286 | isa = PBXSourcesBuildPhase; 287 | buildActionMask = 2147483647; 288 | files = ( 289 | 506B0F2623B8134800FE5982 /* GFTextField.swift in Sources */, 290 | 506B0F1D23B809AC00FE5982 /* FavoritesVC.swift in Sources */, 291 | 50B9B0E723BC360600819BEB /* FollowersVC.swift in Sources */, 292 | 500D8E4323EF4A6100779E4D /* GFUserInfoHeaderVC.swift in Sources */, 293 | 50B9B0F123BC48D400819BEB /* UIViewController +Ext.swift in Sources */, 294 | 50A86C9923C0C025005D014A /* NetworkManager.swift in Sources */, 295 | 506E96F423D02B6900249802 /* GFEmptyStateView.swift in Sources */, 296 | 500D8E4523EF4B0400779E4D /* GFSecondaryTitleLabel.swift in Sources */, 297 | 506B0F2323B80D8400FE5982 /* GFButton.swift in Sources */, 298 | 50B9B0E423BC353500819BEB /* SearchVC +TextFieldDelegate.swift in Sources */, 299 | 506E96F823D03D5600249802 /* GFError.swift in Sources */, 300 | 506CAA3723C9746C00A00E66 /* FollowerCell.swift in Sources */, 301 | 506CAA3923C9750F00A00E66 /* GFAvatarImageView.swift in Sources */, 302 | 501CB27623BCCAAC00FA6407 /* User.swift in Sources */, 303 | 506E96F623D0354400249802 /* UserInfoVC.swift in Sources */, 304 | 50B9B0EF23BC41D000819BEB /* GFAlertVC.swift in Sources */, 305 | 501CB27423BCC9E600FA6407 /* Follower.swift in Sources */, 306 | 506B0F1923B806DC00FE5982 /* SearchVC.swift in Sources */, 307 | 50B9B0EB23BC3FCC00819BEB /* GFTitleLabel.swift in Sources */, 308 | 506B0EFA23B803A000FE5982 /* AppDelegate.swift in Sources */, 309 | 506E96F223D0198E00249802 /* UIHelper.swift in Sources */, 310 | 506B0EFC23B803A000FE5982 /* SceneDelegate.swift in Sources */, 311 | 500D8E4923EFB16E00779E4D /* GFItemInfoView.swift in Sources */, 312 | 50B9B0ED23BC410300819BEB /* GFBodyLabel.swift in Sources */, 313 | 500D8E4723EFA71100779E4D /* Constants.swift in Sources */, 314 | ); 315 | runOnlyForDeploymentPostprocessing = 0; 316 | }; 317 | /* End PBXSourcesBuildPhase section */ 318 | 319 | /* Begin PBXVariantGroup section */ 320 | 506B0F0423B803AA00FE5982 /* LaunchScreen.storyboard */ = { 321 | isa = PBXVariantGroup; 322 | children = ( 323 | 506B0F0523B803AA00FE5982 /* Base */, 324 | ); 325 | name = LaunchScreen.storyboard; 326 | sourceTree = ""; 327 | }; 328 | /* End PBXVariantGroup section */ 329 | 330 | /* Begin XCBuildConfiguration section */ 331 | 506B0F0823B803AA00FE5982 /* Debug */ = { 332 | isa = XCBuildConfiguration; 333 | buildSettings = { 334 | ALWAYS_SEARCH_USER_PATHS = NO; 335 | CLANG_ANALYZER_NONNULL = YES; 336 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 337 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 338 | CLANG_CXX_LIBRARY = "libc++"; 339 | CLANG_ENABLE_MODULES = YES; 340 | CLANG_ENABLE_OBJC_ARC = YES; 341 | CLANG_ENABLE_OBJC_WEAK = YES; 342 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 343 | CLANG_WARN_BOOL_CONVERSION = YES; 344 | CLANG_WARN_COMMA = YES; 345 | CLANG_WARN_CONSTANT_CONVERSION = YES; 346 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 347 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 348 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 349 | CLANG_WARN_EMPTY_BODY = YES; 350 | CLANG_WARN_ENUM_CONVERSION = YES; 351 | CLANG_WARN_INFINITE_RECURSION = YES; 352 | CLANG_WARN_INT_CONVERSION = YES; 353 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 354 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 355 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 356 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 357 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 358 | CLANG_WARN_STRICT_PROTOTYPES = YES; 359 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 360 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 361 | CLANG_WARN_UNREACHABLE_CODE = YES; 362 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 363 | COPY_PHASE_STRIP = NO; 364 | DEBUG_INFORMATION_FORMAT = dwarf; 365 | ENABLE_STRICT_OBJC_MSGSEND = YES; 366 | ENABLE_TESTABILITY = YES; 367 | GCC_C_LANGUAGE_STANDARD = gnu11; 368 | GCC_DYNAMIC_NO_PIC = NO; 369 | GCC_NO_COMMON_BLOCKS = YES; 370 | GCC_OPTIMIZATION_LEVEL = 0; 371 | GCC_PREPROCESSOR_DEFINITIONS = ( 372 | "DEBUG=1", 373 | "$(inherited)", 374 | ); 375 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 376 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 377 | GCC_WARN_UNDECLARED_SELECTOR = YES; 378 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 379 | GCC_WARN_UNUSED_FUNCTION = YES; 380 | GCC_WARN_UNUSED_VARIABLE = YES; 381 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 382 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 383 | MTL_FAST_MATH = YES; 384 | ONLY_ACTIVE_ARCH = YES; 385 | SDKROOT = iphoneos; 386 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 387 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 388 | }; 389 | name = Debug; 390 | }; 391 | 506B0F0923B803AA00FE5982 /* Release */ = { 392 | isa = XCBuildConfiguration; 393 | buildSettings = { 394 | ALWAYS_SEARCH_USER_PATHS = NO; 395 | CLANG_ANALYZER_NONNULL = YES; 396 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 397 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 398 | CLANG_CXX_LIBRARY = "libc++"; 399 | CLANG_ENABLE_MODULES = YES; 400 | CLANG_ENABLE_OBJC_ARC = YES; 401 | CLANG_ENABLE_OBJC_WEAK = YES; 402 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 403 | CLANG_WARN_BOOL_CONVERSION = YES; 404 | CLANG_WARN_COMMA = YES; 405 | CLANG_WARN_CONSTANT_CONVERSION = YES; 406 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 407 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 408 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 409 | CLANG_WARN_EMPTY_BODY = YES; 410 | CLANG_WARN_ENUM_CONVERSION = YES; 411 | CLANG_WARN_INFINITE_RECURSION = YES; 412 | CLANG_WARN_INT_CONVERSION = YES; 413 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 414 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 415 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 416 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 417 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 418 | CLANG_WARN_STRICT_PROTOTYPES = YES; 419 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 420 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 421 | CLANG_WARN_UNREACHABLE_CODE = YES; 422 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 423 | COPY_PHASE_STRIP = NO; 424 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 425 | ENABLE_NS_ASSERTIONS = NO; 426 | ENABLE_STRICT_OBJC_MSGSEND = YES; 427 | GCC_C_LANGUAGE_STANDARD = gnu11; 428 | GCC_NO_COMMON_BLOCKS = YES; 429 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 430 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 431 | GCC_WARN_UNDECLARED_SELECTOR = YES; 432 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 433 | GCC_WARN_UNUSED_FUNCTION = YES; 434 | GCC_WARN_UNUSED_VARIABLE = YES; 435 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 436 | MTL_ENABLE_DEBUG_INFO = NO; 437 | MTL_FAST_MATH = YES; 438 | SDKROOT = iphoneos; 439 | SWIFT_COMPILATION_MODE = wholemodule; 440 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 441 | VALIDATE_PRODUCT = YES; 442 | }; 443 | name = Release; 444 | }; 445 | 506B0F0B23B803AA00FE5982 /* Debug */ = { 446 | isa = XCBuildConfiguration; 447 | buildSettings = { 448 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 449 | CODE_SIGN_STYLE = Automatic; 450 | DEVELOPMENT_TEAM = U5KYHQSZAZ; 451 | INFOPLIST_FILE = GitHubFollowers/Info.plist; 452 | LD_RUNPATH_SEARCH_PATHS = ( 453 | "$(inherited)", 454 | "@executable_path/Frameworks", 455 | ); 456 | PRODUCT_BUNDLE_IDENTIFIER = com.mnabokow.GitHubFollowers; 457 | PRODUCT_NAME = "$(TARGET_NAME)"; 458 | SWIFT_VERSION = 5.0; 459 | TARGETED_DEVICE_FAMILY = "1,2"; 460 | }; 461 | name = Debug; 462 | }; 463 | 506B0F0C23B803AA00FE5982 /* Release */ = { 464 | isa = XCBuildConfiguration; 465 | buildSettings = { 466 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 467 | CODE_SIGN_STYLE = Automatic; 468 | DEVELOPMENT_TEAM = U5KYHQSZAZ; 469 | INFOPLIST_FILE = GitHubFollowers/Info.plist; 470 | LD_RUNPATH_SEARCH_PATHS = ( 471 | "$(inherited)", 472 | "@executable_path/Frameworks", 473 | ); 474 | PRODUCT_BUNDLE_IDENTIFIER = com.mnabokow.GitHubFollowers; 475 | PRODUCT_NAME = "$(TARGET_NAME)"; 476 | SWIFT_VERSION = 5.0; 477 | TARGETED_DEVICE_FAMILY = "1,2"; 478 | }; 479 | name = Release; 480 | }; 481 | /* End XCBuildConfiguration section */ 482 | 483 | /* Begin XCConfigurationList section */ 484 | 506B0EF123B803A000FE5982 /* Build configuration list for PBXProject "GitHubFollowers" */ = { 485 | isa = XCConfigurationList; 486 | buildConfigurations = ( 487 | 506B0F0823B803AA00FE5982 /* Debug */, 488 | 506B0F0923B803AA00FE5982 /* Release */, 489 | ); 490 | defaultConfigurationIsVisible = 0; 491 | defaultConfigurationName = Release; 492 | }; 493 | 506B0F0A23B803AA00FE5982 /* Build configuration list for PBXNativeTarget "GitHubFollowers" */ = { 494 | isa = XCConfigurationList; 495 | buildConfigurations = ( 496 | 506B0F0B23B803AA00FE5982 /* Debug */, 497 | 506B0F0C23B803AA00FE5982 /* Release */, 498 | ); 499 | defaultConfigurationIsVisible = 0; 500 | defaultConfigurationName = Release; 501 | }; 502 | /* End XCConfigurationList section */ 503 | }; 504 | rootObject = 506B0EEE23B803A000FE5982 /* Project object */; 505 | } 506 | --------------------------------------------------------------------------------