├── HackerNews
├── ionicons.ttf
├── Assets.xcassets
│ ├── Contents.json
│ ├── expand.imageset
│ │ ├── expand-button.png
│ │ ├── expand-button-2.png
│ │ └── Contents.json
│ ├── right_arrow.imageset
│ │ ├── keyboard-right-arrow-button.png
│ │ ├── keyboard-right-arrow-button-2.png
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── SettingsTableView.swift
├── SettingsNodeType.swift
├── WebViewControllerType.swift
├── OptionType.swift
├── StoryNodeDelegate.swift
├── Collection.swift
├── ASDisplayNode.swift
├── UserCommentNodeDelegate.swift
├── UserAboutBioNodeDelegate.swift
├── AuthRequiredNodeDelegate.swift
├── HNPostStoryDelegate.swift
├── UIColor+Hex.swift
├── UITabBar.swift
├── HNSearchProtocol.swift
├── StoryBarDelegate.swift
├── UINavigationBar.swift
├── AuthDelegate.swift
├── CommentNodeDelegate.swift
├── CommentOptionsNodeDelegate.swift
├── FullButtonNode.swift
├── MiddleButtonNode.swift
├── StoryDetailNodeDelegate.swift
├── UserOptionsNavigationRight.swift
├── CommentsOptionsNavigationRight.swift
├── UIButton.swift
├── MBProgressHUD.swift
├── NSError.swift
├── HNTableNode.swift
├── ShareHelper.swift
├── TextFieldNode.swift
├── GeneralType.swift
├── Utils.swift
├── WebOptionType.swift
├── CommentsOptionsComment.swift
├── Constants.swift
├── UINavigationItem.swift
├── OptionSelectView.swift
├── CommentsTableBackgroundView.swift
├── StoryTypeNode.swift
├── ProfileOptionsType.swift
├── AlertBuilder.swift
├── NSMutableAttributedString.swift
├── AlgoliaResponse.swift
├── OptionSelectViewDelegate.swift
├── HNPostTextCellNode.swift
├── HNDimensions.swift
├── BackgroundDividerNode.swift
├── OptionSelectTableView.swift
├── Localizable.strings
├── ThemeType.swift
├── DataRequest.swift
├── AppDelegate.swift
├── GoogleService-Info.plist
├── SearchSortType.swift
├── HNOptionActionNode.swift
├── CommentsTableView.swift
├── FontSizeType.swift
├── HNPagerViewController.swift
├── StoryType.swift
├── Base.lproj
│ └── LaunchScreen.storyboard
├── OptionSelectNode.swift
├── Info.plist
├── SettingsProvider.swift
├── TextHelper.swift
├── HNOptionHeaderNode.swift
├── UserAboutBioNode.swift
├── SettingsType.swift
├── BaseViewController.swift
├── NavigationController.swift
├── HNAPI.swift
├── SearchTimeRangeType.swift
├── FontType.swift
├── UserAboutNode.swift
├── UserCommentNode.swift
├── HNPostViewController.swift
├── FontManager.swift
├── UserPostsViewController.swift
├── WebViewNode.swift
├── User.swift
├── OptionsViewController.swift
├── FirebaseAPI.swift
├── AuthRequiredNode.swift
├── ItemProvider.swift
├── UserAboutViewController.swift
├── SearchStoriesViewController.swift
├── TabBarController.swift
├── HNPostStoryViewController.swift
├── HistoryViewController.swift
├── StoryNode.swift
├── UserViewController.swift
├── AuthViewController.swift
├── SettingsNode.swift
├── CommentOptionsNode.swift
├── StoryDetailNode.swift
├── StoryBarNode.swift
├── 3DTouchHelper.swift
└── RestAPI.swift
├── screenshots
├── screenshot1.png
├── screenshot2.png
└── screenshot3.png
├── HackerNews.xcodeproj
└── project.xcworkspace
│ └── contents.xcworkspacedata
├── HackerNews.xcworkspace
└── contents.xcworkspacedata
├── .gitignore
├── Podfile
├── LICENSE.md
├── README.md
└── Podfile.lock
/HackerNews/ionicons.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/ionicons.ttf
--------------------------------------------------------------------------------
/HackerNews/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/screenshots/screenshot1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/screenshots/screenshot1.png
--------------------------------------------------------------------------------
/screenshots/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/screenshots/screenshot2.png
--------------------------------------------------------------------------------
/screenshots/screenshot3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/screenshots/screenshot3.png
--------------------------------------------------------------------------------
/HackerNews/Assets.xcassets/expand.imageset/expand-button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/Assets.xcassets/expand.imageset/expand-button.png
--------------------------------------------------------------------------------
/HackerNews/Assets.xcassets/expand.imageset/expand-button-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/Assets.xcassets/expand.imageset/expand-button-2.png
--------------------------------------------------------------------------------
/HackerNews/Assets.xcassets/right_arrow.imageset/keyboard-right-arrow-button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/Assets.xcassets/right_arrow.imageset/keyboard-right-arrow-button.png
--------------------------------------------------------------------------------
/HackerNews/Assets.xcassets/right_arrow.imageset/keyboard-right-arrow-button-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Xzya/Hacker-News-iOS-Client/master/HackerNews/Assets.xcassets/right_arrow.imageset/keyboard-right-arrow-button-2.png
--------------------------------------------------------------------------------
/HackerNews.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/HackerNews/SettingsTableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsTableView.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class SettingsTableView: HNTableNode {
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/HackerNews.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/HackerNews/SettingsNodeType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsNodeType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/5/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum SettingsNodeType {
12 | case dropdown
13 | case action
14 | case open
15 | }
16 |
--------------------------------------------------------------------------------
/HackerNews/WebViewControllerType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebViewControllerType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 12/4/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum WebViewControllerType {
12 | case item(Item)
13 | case link(String)
14 | }
15 |
--------------------------------------------------------------------------------
/HackerNews/OptionType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OptionType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol OptionType {
12 |
13 | var title: String { get }
14 | var icon: IonIconType { get }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/HackerNews/StoryNodeDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoryNodeDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/22/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol StoryNodeDelegate {
12 | @objc optional func storyNode(_ storyNode: StoryNode, didPressOnPosterButton sender: ASDisplayNode)
13 | }
14 |
--------------------------------------------------------------------------------
/HackerNews/Collection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Collection.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/5/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Collection {
12 | subscript (safe index: Index) -> Iterator.Element? {
13 | return index >= startIndex && index < endIndex ? self[index] : nil
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Build generated
2 | build/
3 | DerivedData/
4 |
5 | ## Various settings
6 | *.pbxuser
7 | !default.pbxuser
8 | *.mode1v3
9 | !default.mode1v3
10 | *.mode2v3
11 | !default.mode2v3
12 | *.perspectivev3
13 | !default.perspectivev3
14 | xcuserdata/
15 |
16 | ## Other
17 | *.moved-aside
18 | *.xcuserstate
19 |
20 | ## Obj-C/Swift specific
21 | *.hmap
22 | *.ipa
23 | *.dSYM.zip
24 | *.dSYM
25 |
26 | Pods/
27 |
--------------------------------------------------------------------------------
/HackerNews/ASDisplayNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ASDisplayNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/4/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | extension ASDisplayNode {
12 | func layoutThatFits(size: CGSize) -> ASLayout {
13 | return self.layoutThatFits(.init(min: CGSize.zero, max: size))
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/HackerNews/UserCommentNodeDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserCommentNodeDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 12/4/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol UserCommentNodeDelegate {
12 | @objc optional func userCommentNode(_ userCommentNode: UserCommentNode, didPressLink link: String, withSender sender: ASDisplayNode)
13 | }
14 |
--------------------------------------------------------------------------------
/HackerNews/UserAboutBioNodeDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserAboutBioNodeDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 12/4/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol UserAboutBioNodeDelegate {
12 | @objc optional func userAboutBioNode(_ userAboutBioNode: UserAboutBioNode, didPressLink link: String, withSender sender: ASDisplayNode)
13 | }
14 |
--------------------------------------------------------------------------------
/HackerNews/AuthRequiredNodeDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthRequiredNodeDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol AuthRequiredNodeDelegate {
12 | func authRequiredNode(didPressLoginButton button: ASDisplayNode)
13 | func authRequiredNode(didPressSignupButton button: ASDisplayNode)
14 | }
15 |
--------------------------------------------------------------------------------
/HackerNews/HNPostStoryDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNPostStoryDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/5/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol HNPostStoryDelegate {
12 | func postStory(didPressSubmitButton button: ASDisplayNode, title: String, url: String, text: String)
13 | func postStory(didPressCloseButton button: ASDisplayNode)
14 | }
15 |
--------------------------------------------------------------------------------
/HackerNews/Assets.xcassets/expand.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "expand-button-2.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "filename" : "expand-button.png",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | },
22 | "properties" : {
23 | "template-rendering-intent" : "template"
24 | }
25 | }
--------------------------------------------------------------------------------
/HackerNews/Assets.xcassets/right_arrow.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "keyboard-right-arrow-button-2.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "filename" : "keyboard-right-arrow-button.png",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | },
22 | "properties" : {
23 | "template-rendering-intent" : "template"
24 | }
25 | }
--------------------------------------------------------------------------------
/HackerNews/UIColor+Hex.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIColor+Hex.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/1/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UIColor {
12 |
13 | convenience init(hex: Int) {
14 | let components = (
15 | R: CGFloat((hex >> 16) & 0xff) / 255,
16 | G: CGFloat((hex >> 08) & 0xff) / 255,
17 | B: CGFloat((hex >> 00) & 0xff) / 255
18 | )
19 | self.init(red: components.R, green: components.G, blue: components.B, alpha: 1)
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/HackerNews/UITabBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UITabBar.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/26/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UITabBar {
12 |
13 | func set(topBorderColor color: UIColor, height: CGFloat) {
14 | let borderView = UIView(frame: CGRect(x: 0, y: -0.5, width: frame.width, height: height))
15 | borderView.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin]
16 | borderView.backgroundColor = color
17 |
18 | self.addSubview(borderView)
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/HackerNews/HNSearchProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNSearchProtocol.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/3/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | protocol HNSearchProtocol {
12 |
13 | var query: String { get set }
14 | var sortType: SearchSortType { get set }
15 | var timeRangeType: SearchTimeRangeType { get set }
16 |
17 | func onRefreshNotificationReceived(notification: Notification)
18 |
19 | func refresh(refreshControl: UIRefreshControl?)
20 |
21 | func fetchMoreItems(completion: ((Bool) -> ())?)
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/HackerNews/StoryBarDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoryBarDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/22/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol StoryBarDelegate {
12 | @objc optional func storyBar(_ storyBar: StoryBarNode, didPressShareButtonWithSender sender: ASDisplayNode)
13 | @objc optional func storyBar(_ storyBar: StoryBarNode, didPressCommentsButtonWithSender sender: ASDisplayNode)
14 | @objc optional func storyBar(_ storyBar: StoryBarNode, didPressVoteButtonWithSender sender: ASDisplayNode)
15 | }
16 |
--------------------------------------------------------------------------------
/HackerNews/UINavigationBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UINavigationBar.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/16/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UINavigationBar {
12 |
13 | func set(bottomBorderColor color: UIColor, height: CGFloat) {
14 | let bottomBorderView = UIView(frame: CGRect(x: 0, y: frame.height, width: frame.width, height: height))
15 | bottomBorderView.autoresizingMask = [.flexibleWidth, .flexibleTopMargin]
16 | bottomBorderView.backgroundColor = color
17 |
18 | self.addSubview(bottomBorderView)
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/HackerNews/AuthDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol AuthNodeDelegate {
12 | func authNode(didPressSwitchToSignupButton button: ASDisplayNode)
13 | func authNode(didPressSwitchToLoginButton button: ASDisplayNode)
14 | func authNode(didPressSwitchToForgotPasswordButton button: ASDisplayNode)
15 | func authNode(didPressSubmitButton button: ASDisplayNode, username: String, password: String)
16 | func authNode(didPressCloseButton button: ASDisplayNode)
17 | }
18 |
--------------------------------------------------------------------------------
/HackerNews/CommentNodeDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentNodeDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/23/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol CommentNodeDelegate {
12 | @objc optional func commentNode(_ commentNode: CommentNode, didPressExpandButtonWithSender sender: ASDisplayNode, expanded: Bool)
13 | @objc optional func commentNode(_ commentNode: CommentNode, didPressPosterButtonWithSender sender: ASDisplayNode)
14 | @objc optional func commentNode(_ commentNode: CommentNode, didPressLink link: String, withSender sender: ASDisplayNode)
15 | }
16 |
--------------------------------------------------------------------------------
/HackerNews/CommentOptionsNodeDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentOptionsNodeDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/23/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol CommentOptionsNodeDelegate {
12 | @objc optional func commentOptions(_ commentOptions: CommentOptionsNode, didPressMoreButtonWithSender sender: ASDisplayNode)
13 | @objc optional func commentOptions(_ commentOptions: CommentOptionsNode, didPressReplyButtonWithSender sender: ASDisplayNode)
14 | @objc optional func commentOptions(_ commentOptions: CommentOptionsNode, didPressVoteButtonWithSender sender: ASDisplayNode)
15 | }
16 |
--------------------------------------------------------------------------------
/HackerNews/FullButtonNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthButton.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class FullButtonNode: ASButtonNode {
12 |
13 | // MARK: - Inits
14 |
15 | override init() {
16 | super.init()
17 |
18 | self.setupTheme()
19 | }
20 |
21 | // MARK: - Helpers
22 |
23 | func set(title: String) {
24 | self.setAttributedTitle(
25 | NSAttributedString.init(
26 | string: title,
27 | attributes: Styles.others.fullButton),
28 | for: .normal
29 | )
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/HackerNews/MiddleButtonNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MiddleButtonNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class MiddleButtonNode: ASButtonNode {
12 |
13 | // MARK: - Inits
14 |
15 | override init() {
16 | super.init()
17 |
18 | self.setupTheme()
19 | }
20 |
21 | // MARK: - Helpers
22 |
23 | func set(title: String) {
24 | self.setAttributedTitle(
25 | NSAttributedString.init(
26 | string: title,
27 | attributes: Styles.others.middleButton),
28 | for: .normal
29 | )
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/Podfile:
--------------------------------------------------------------------------------
1 | # Uncomment this line to define a global platform for your project
2 | # platform :ios, '9.0'
3 |
4 | target 'HackerNews' do
5 | # Comment this line if you're not using Swift and don't want to use dynamic frameworks
6 | use_frameworks!
7 |
8 | # Pods for HackerNews
9 | pod 'Firebase/Database'
10 | pod "PromiseKit", "~> 4.1.7"
11 | pod 'NSDate+TimeAgo'
12 | pod 'Alamofire', '~> 4.0'
13 | pod "Texture"
14 | pod 'AMScrollingNavbar'
15 | pod 'PINCache'
16 | pod 'XLPagerTabStrip', '~> 6.0'
17 | pod 'MBProgressHUD'
18 |
19 | end
20 |
21 | post_install do |installer|
22 | installer.pods_project.targets.each do |target|
23 | target.build_configurations.each do |config|
24 | config.build_settings['SWIFT_VERSION'] = '3.0'
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/HackerNews/StoryDetailNodeDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoryDetailNodeDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/24/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | @objc protocol StoryDetailNodeDelegate {
12 | @objc optional func storyDetail(_ storyDetail: StoryDetailNode, didPressPosterButtonWithSender sender: ASDisplayNode)
13 | @objc optional func storyDetail(_ storyDetail: StoryDetailNode, didPressTitleWithSender sender: ASDisplayNode)
14 | @objc optional func storyDetail(_ storyDetail: StoryDetailNode, didPressSiteButtonWithSender sender: ASDisplayNode)
15 | @objc optional func storyDetail(_ storyDetail: StoryDetailNode, didPressLink link: String, withSender sender: ASDisplayNode)
16 | }
17 |
--------------------------------------------------------------------------------
/HackerNews/UserOptionsNavigationRight.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserOptionsNavigationRight.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/6/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum UserOptionsNavigationRight: OptionType {
12 | case openInBrowser
13 |
14 | var title: String {
15 | switch self {
16 | case .openInBrowser:
17 | return "kOpenInBrowser".localized
18 | }
19 | }
20 |
21 | var icon: IonIconType {
22 | switch self {
23 | case .openInBrowser:
24 | return .ion_earth
25 | }
26 | }
27 |
28 | static var values: [UserOptionsNavigationRight] {
29 | return [
30 | openInBrowser
31 | ]
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/HackerNews/CommentsOptionsNavigationRight.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentsOptionsNavigationRight.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum CommentsOptionsNavigationRight: OptionType {
12 | case openInBrowser
13 |
14 | var title: String {
15 | switch self {
16 | case .openInBrowser:
17 | return "kOpenInBrowser".localized
18 | }
19 | }
20 |
21 | var icon: IonIconType {
22 | switch self {
23 | case .openInBrowser:
24 | return .ion_earth
25 | }
26 | }
27 |
28 | static var values: [CommentsOptionsNavigationRight] {
29 | return [
30 | openInBrowser
31 | ]
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/HackerNews/UIButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIButton.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/20/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UIButton {
12 |
13 | func setTitleWithoutAnimation(_ title: String?, for state: UIControlState) {
14 | UIView.setAnimationsEnabled(false)
15 | self.setTitle(title, for: state)
16 | self.layoutIfNeeded()
17 | UIView.setAnimationsEnabled(true)
18 | }
19 |
20 | func setAttributedTitleWithoutAnimation(_ title: NSAttributedString?, for state: UIControlState) {
21 | UIView.setAnimationsEnabled(false)
22 | self.setAttributedTitle(title, for: state)
23 | self.layoutIfNeeded()
24 | UIView.setAnimationsEnabled(true)
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/HackerNews/MBProgressHUD.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MBProgressHUD.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import MBProgressHUD
10 |
11 | extension MBProgressHUD {
12 |
13 | static func showLoader(view: UIView, animated: Bool = true, interactionDisabled: Bool = true) {
14 | if interactionDisabled {
15 | view.isUserInteractionEnabled = false
16 | }
17 | MBProgressHUD.showAdded(to: view, animated: animated)
18 | }
19 |
20 | static func hideLoader(view: UIView, animated: Bool = true, interactionEnabled: Bool = true) {
21 | if interactionEnabled {
22 | view.isUserInteractionEnabled = true
23 | }
24 | MBProgressHUD.hide(for: view, animated: animated)
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/HackerNews/NSError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSError.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/11/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Firebase
11 |
12 | extension NSError {
13 |
14 | static func from(item: Item) -> NSError {
15 | return NSError(domain: "HACKER_NEWS", code: -1, userInfo: [
16 | "id": item.id
17 | ])
18 | }
19 |
20 | static func from(snapshot: DataSnapshot) -> NSError {
21 | return NSError(domain: "FIREBASE", code: -1, userInfo: [
22 | "snapshot": snapshot.debugDescription
23 | ])
24 | }
25 |
26 | static func from(string: String) -> NSError {
27 | return NSError(domain: "HACKER_NEWS", code: -1, userInfo: [
28 | NSLocalizedDescriptionKey: string
29 | ])
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/HackerNews/HNTableNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNTableView.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class HNTableNode: ASTableNode {
12 |
13 | // MARK: - Lifecycle
14 |
15 | init() {
16 | super.init(style: .plain)
17 |
18 | self.setup()
19 | }
20 |
21 | // MARK: - Setup Helpers
22 |
23 | func setup() {
24 | self.setupTableView()
25 | self.setupTheme()
26 | }
27 |
28 | func setupTableView() {
29 | self.view.separatorStyle = .singleLine
30 | self.view.separatorInset = UIEdgeInsets.zero
31 | self.view.tableFooterView = UIView()
32 | self.view.alwaysBounceVertical = true
33 | self.view.layoutMargins = UIEdgeInsets.zero
34 | self.clipsToBounds = true
35 | }
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/HackerNews/ShareHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ShareHelper.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/10/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class ShareHelper {
12 |
13 | static func share(items: [Any], fromViewController viewController: UIViewController, sourceView: UIView?) {
14 | let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
15 | if activityVC.responds(to: #selector(getter: UIViewController.popoverPresentationController)) {
16 | activityVC.popoverPresentationController?.sourceView = sourceView ?? viewController.view
17 | activityVC.popoverPresentationController?.sourceRect = activityVC.popoverPresentationController?.sourceView?.bounds ?? CGRect.zero
18 | }
19 | viewController.present(activityVC, animated: true, completion: nil)
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/HackerNews/TextFieldNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TextFieldNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class TextFieldNode: ASEditableTextNode {
12 |
13 | // MARK: - Inits
14 |
15 | override init() {
16 | super.init()
17 |
18 | self.setupTheme()
19 | }
20 |
21 | override init(textKitComponents: ASTextKitComponents, placeholderTextKitComponents: ASTextKitComponents) {
22 | super.init(textKitComponents: textKitComponents, placeholderTextKitComponents: placeholderTextKitComponents)
23 | }
24 |
25 | // MARK: - Helpers
26 |
27 | func set(placeholderText: String) {
28 | self.attributedPlaceholderText = NSAttributedString(
29 | string: placeholderText,
30 | attributes: Styles.textField.placeholder
31 | )
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/HackerNews/GeneralType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GeneralType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum GeneralType: OptionType {
12 | case cancel
13 | case none
14 | case close
15 |
16 | var title: String {
17 | switch self {
18 | case .cancel:
19 | return "Cancel"
20 | case .none:
21 | return ""
22 | case .close:
23 | return "Close"
24 | }
25 | }
26 |
27 | var icon: IonIconType {
28 | switch self {
29 | case .cancel:
30 | return .ion_close
31 | case .none:
32 | return .ion_ios_help
33 | case .close:
34 | return .ion_checkmark_round
35 | }
36 | }
37 |
38 | static var values: [GeneralType] {
39 | return [
40 | none,
41 | cancel,
42 | close,
43 | ]
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/HackerNews/Utils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Utils.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class Utils {
12 |
13 | static func nibName(forClassType type: AnyClass) -> String {
14 | return type.description().components(separatedBy: ".").last ?? ""
15 | }
16 |
17 | static func nib(forClassType type: AnyClass) -> UINib {
18 | return UINib.init(nibName: Utils.nibName(forClassType: type), bundle: nil)
19 | }
20 |
21 | static func loadNib(forClassType type: AnyClass) -> Any? {
22 | return Bundle.main.loadNibNamed(Utils.nibName(forClassType: type), owner: nil, options: nil)?.first
23 | }
24 |
25 | static func openURL(urlString: String) {
26 | if let url = URL(string: urlString) {
27 | UIApplication.shared.openURL(url)
28 | }
29 | }
30 |
31 | static func copy(string: String) {
32 | UIPasteboard.general.string = string
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/HackerNews/WebOptionType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebOptionType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/3/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum WebOptionType: OptionType {
12 | case refresh
13 | case share
14 | case openInBrowser
15 |
16 | var title: String {
17 | switch self {
18 | case .refresh:
19 | return "kRefresh".localized
20 | case .share:
21 | return "kShare".localized
22 | case .openInBrowser:
23 | return "kOpenInBrowser".localized
24 | }
25 | }
26 |
27 | var icon: IonIconType {
28 | switch self {
29 | case .refresh:
30 | return .ion_refresh
31 | case .share:
32 | return .ion_ios_upload
33 | case .openInBrowser:
34 | return .ion_earth
35 | }
36 | }
37 |
38 | static var values: [WebOptionType] {
39 | return [
40 | refresh,
41 | share,
42 | openInBrowser,
43 | ]
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/HackerNews/CommentsOptionsComment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentsOptionsComment.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum CommentsOptionsComment: OptionType {
12 | case share
13 | case copy
14 | case openInBrowser
15 |
16 | var title: String {
17 | switch self {
18 | case .share:
19 | return "kShare".localized
20 | case .copy:
21 | return "kCopy".localized
22 | case .openInBrowser:
23 | return "kOpenInBrowser".localized
24 | }
25 | }
26 |
27 | var icon: IonIconType {
28 | switch self {
29 | case .share:
30 | return .ion_ios_upload
31 | case .copy:
32 | return .ion_ios_copy
33 | case .openInBrowser:
34 | return .ion_earth
35 | }
36 | }
37 |
38 | static var values: [CommentsOptionsComment] {
39 | return [
40 | share,
41 | copy,
42 | openInBrowser,
43 | ]
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/HackerNews/Constants.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Constants.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/9/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class Constants {
12 |
13 | static let kFirebaseURL = "https://hacker-news.firebaseio.com/v0/"
14 | static let kLoginURL = "https://news.ycombinator.com/login"
15 | static let kHackerNewsURL = "https://news.ycombinator.com/"
16 | static let kForgotPasswordURL = "https://news.ycombinator.com/forgot"
17 |
18 | static let kTextLinkAttributeName = "TextLinkAttributeName"
19 |
20 | static let kItemExpiratinTime: TimeInterval = 60
21 | static let kHomePreloadItemCount: Int = 10
22 | static let kCommentsPreloadItemCount: Int = 25
23 |
24 | static let kClearCacheNotification: Notification = Notification(name: Notification.Name("kClearCacheNotification"))
25 | static let kSearchNotification: Notification = Notification(name: Notification.Name("kSearchNotification"))
26 | static let kLoginNotification: Notification = Notification(name: Notification.Name("kLoginNotification"))
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Mihail Cristian Dumitru
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
22 |
--------------------------------------------------------------------------------
/HackerNews/UINavigationItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UINavigationItem.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/20/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UINavigationItem {
12 |
13 | func set(icon: IonIconType, forButton button: UIBarButtonItem) {
14 | button.title = icon.rawValue
15 | button.setTitleTextAttributes([
16 | NSFontAttributeName: UIFont.ionIconFont(ofSize: FontManager.currentFont.titleSize)
17 | ], for: .normal)
18 | }
19 |
20 | func set(leftButtonIcon icon: IonIconType, target: Any?, action: Selector?) {
21 | let button = UIBarButtonItem(title: nil, style: .plain, target: target, action: action)
22 | self.set(icon: icon, forButton: button)
23 | self.leftBarButtonItem = button
24 | }
25 |
26 | func set(rightButtonIcon icon: IonIconType, target: Any?, action: Selector?) {
27 | let button = UIBarButtonItem(title: nil, style: .plain, target: target, action: action)
28 | self.set(icon: icon, forButton: button)
29 | self.rightBarButtonItem = button
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/HackerNews/OptionSelectView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OptionSelectView.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/14/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class OptionSelectView: ASDisplayNode {
12 |
13 | // MARK: - Properties
14 |
15 | var tableView = OptionSelectTableView()
16 |
17 | // MARK: - Inits
18 |
19 | override init() {
20 | super.init()
21 |
22 | self.automaticallyManagesSubnodes = true
23 | }
24 |
25 | // MARK: - Layout
26 |
27 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
28 |
29 | self.style.width = .init(unit: .fraction, value: 1)
30 | self.style.height = .init(unit: .fraction, value: 1)
31 |
32 | let insetStack = ASInsetLayoutSpec(
33 | insets: UIEdgeInsetsMake(
34 | HNDimensions.padding / 2 + 20,
35 | HNDimensions.padding / 2,
36 | HNDimensions.padding / 2,
37 | HNDimensions.padding / 2),
38 | child: self.tableView)
39 |
40 | return insetStack
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/HackerNews/CommentsTableBackgroundView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentsTableBackgroundView.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 12/4/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class CommentsTableBackgroundView: UIView {
12 |
13 | // MARK: - Lifecycle
14 |
15 | override init(frame: CGRect) {
16 | super.init(frame: frame)
17 |
18 | self.setup()
19 | }
20 |
21 | required init?(coder aDecoder: NSCoder) {
22 | super.init(coder: aDecoder)
23 |
24 | self.setup()
25 | }
26 |
27 | // MARK: - Setup Helpers
28 |
29 | func setup() {
30 | let width = UIScreen.main.bounds.height // get the height because of landscape
31 |
32 | for i in 1..<(Int(floor(width / HNDimensions.comments.paddingPerLevel))) {
33 | let divider = UIView(frame: CGRect(x: CGFloat(i) * HNDimensions.comments.paddingPerLevel, y: 0, width: 1, height: 0))
34 | divider.backgroundColor = Styles.comment.divider
35 | divider.autoresizingMask = [.flexibleHeight]
36 | self.addSubview(divider)
37 | }
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/HackerNews/StoryTypeNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoryTypeNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/22/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AsyncDisplayKit
11 |
12 | class StoryTypeNode: ASCellNode {
13 |
14 | // MARK: - Properties
15 |
16 | var titleNode: ASTextNode!
17 |
18 | // MARK: - Inits
19 |
20 | init(withTitle title: String) {
21 | super.init()
22 |
23 | // title
24 | self.titleNode = ASTextNode()
25 | self.titleNode.attributedText = NSAttributedString(
26 | string: title,
27 | attributes: Styles.storyType.title)
28 | self.addSubnode(self.titleNode)
29 |
30 | self.setupTheme()
31 | }
32 |
33 | // MARK: - Layout
34 |
35 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
36 | return ASInsetLayoutSpec(
37 | insets: UIEdgeInsetsMake(
38 | HNDimensions.padding / 1.5,
39 | HNDimensions.padding,
40 | HNDimensions.padding / 1.5,
41 | HNDimensions.padding),
42 | child: self.titleNode)
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/HackerNews/ProfileOptionsType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProfileOptionsType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum ProfileOptionsType: OptionType {
12 | case history
13 | case posts
14 | case comments
15 | case logout
16 |
17 | var title: String {
18 | switch self {
19 | case .history:
20 | return "History"
21 | case .posts:
22 | return "My posts"
23 | case .comments:
24 | return "My comments"
25 | case .logout:
26 | return "Logout"
27 | }
28 | }
29 |
30 | var icon: IonIconType {
31 | switch self {
32 | case .history:
33 | return .ion_ios_clock
34 | case .posts:
35 | return .ion_ios_list
36 | case .comments:
37 | return .ion_chatbox
38 | case .logout:
39 | return .ion_log_out
40 | }
41 | }
42 |
43 | static var values: [ProfileOptionsType] {
44 | return [
45 | history,
46 | posts,
47 | comments,
48 | logout
49 | ]
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/HackerNews/AlertBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AlertBuilder.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class AlertBuilder {
12 |
13 | // MARK: - Properties
14 |
15 | var alertController = UIAlertController(title: nil, message: nil, preferredStyle: .alert)
16 |
17 | // MARK: - Helpers
18 |
19 | func set(title: String?) -> Self {
20 | self.alertController.title = title
21 | return self
22 | }
23 |
24 | func set(message: String?) -> Self {
25 | self.alertController.message = message
26 | return self
27 | }
28 |
29 | func add(actionWithTitle title: String?, style: UIAlertActionStyle = .default, handler: ((UIAlertAction) -> Void)? = nil) -> Self {
30 | self.alertController.addAction(
31 | UIAlertAction(
32 | title: title,
33 | style: style,
34 | handler: handler)
35 | )
36 | return self
37 | }
38 |
39 | func present(sender: UIViewController, animated: Bool = true, completion: (() -> Void)? = nil) {
40 | sender.present(self.alertController, animated: animated, completion: completion)
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/HackerNews/NSMutableAttributedString.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSMutableAttributedString.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/24/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension NSMutableAttributedString {
12 |
13 | func detectLinks() -> NSMutableAttributedString {
14 | do {
15 | let urlDetector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
16 |
17 | urlDetector.enumerateMatches(in: self.string, options: [], range: NSMakeRange(0, self.string.characters.count)) { (result, flags, stop) in
18 |
19 | if let result = result {
20 | if result.resultType == .link {
21 | if let absoluteString = result.url?.absoluteString {
22 | var attributes = Styles.others.link
23 | attributes[Constants.kTextLinkAttributeName] = NSURL(string: absoluteString)
24 | self.addAttributes(attributes, range: result.range)
25 | }
26 | }
27 | }
28 | }
29 | } catch let error {
30 | print(error)
31 | }
32 |
33 | return self
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/HackerNews/AlgoliaResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AlgoliaResponse.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/5/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class AlgoliaResponse: NSObject {
12 |
13 | // MARK: - Properties
14 |
15 | var hits: [Item] = []
16 | var hitCount: Int = 0
17 | var page: Int = 0
18 | var pageCount: Int = 0
19 | var hitsPerPage: Int = 0
20 |
21 | // MARK: - Inits
22 |
23 | override init() {
24 | super.init()
25 |
26 | self.setData(withJSON: [:])
27 | }
28 |
29 | init(withJSON json: [String: AnyObject]) {
30 | super.init()
31 |
32 | self.setData(withJSON: json)
33 | }
34 |
35 | func setData(withJSON json: [String: AnyObject]) {
36 | self.hitCount = json["nbHits"] as? Int ?? self.hitCount
37 | self.page = json["page"] as? Int ?? self.page
38 | self.pageCount = json["nbPages"] as? Int ?? self.pageCount
39 | self.hitsPerPage = json["hitsPerPage"] as? Int ?? self.hitsPerPage
40 |
41 | if let hits = json["hits"] as? [[String: AnyObject]] {
42 | for hit in hits {
43 | self.hits.append(Item(withAlgoliaJSON: hit))
44 | }
45 | }
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/HackerNews/OptionSelectViewDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OptionSelectViewDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/21/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import AsyncDisplayKit
11 |
12 | protocol OptionSelectViewDelegate {
13 | func optionSelectView(optionSelectView: OptionsViewController, numberOfItemsInSection section: Int) -> Int
14 | func numberOfSections(in optionSelectView: OptionsViewController) -> Int
15 | func optionSelectView(optionSelectView: OptionsViewController, didSelectItemAtIndexPath indexPath: IndexPath)
16 | func optionSelectView(optionSelectView: OptionsViewController, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock
17 | func optionSelectView(optionSelectView: OptionsViewController, viewForHeaderInSection section: Int) -> UIView?
18 | func optionSelectView(optionSelectView: OptionsViewController, heightForHeaderInSection section: Int) -> CGFloat
19 | }
20 |
21 | extension OptionSelectViewDelegate {
22 | func optionSelectView(optionSelectView: OptionsViewController, viewForHeaderInSection section: Int) -> UIView? {
23 | return nil
24 | }
25 | func optionSelectView(optionSelectView: OptionsViewController, heightForHeaderInSection section: Int) -> CGFloat {
26 | return 0
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/HackerNews/HNPostTextCellNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNTextCellNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/24/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class HNPostTextCellNode: ASCellNode {
12 |
13 | // MARK: - Properties
14 |
15 | var textNode = TextFieldNode()
16 |
17 | var delegate: ASEditableTextNodeDelegate? {
18 | set {
19 | self.textNode.delegate = newValue
20 | }
21 | get {
22 | return self.textNode.delegate
23 | }
24 | }
25 |
26 | // MARK: - Inits
27 |
28 | override init() {
29 | super.init()
30 |
31 | self.automaticallyManagesSubnodes = true
32 |
33 | self.selectionStyle = .none
34 | }
35 |
36 | // MARK: - Layout
37 |
38 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
39 |
40 | let insetContentStack = ASInsetLayoutSpec(
41 | insets: UIEdgeInsetsMake(HNDimensions.padding,
42 | HNDimensions.padding / 2,
43 | HNDimensions.padding,
44 | HNDimensions.padding / 2
45 | ),
46 | child: self.textNode)
47 |
48 | return insetContentStack
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/HackerNews/HNDimensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNDimensions.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/2/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | /**
12 | * HNDimensions holds the default dimensions for the nodes.
13 | */
14 | class HNDimensions {
15 |
16 | /**
17 | * Comments dimensions
18 | */
19 | open class comments {
20 |
21 | /**
22 | * Left padding per comment level (depth)
23 | */
24 | static let paddingPerLevel: CGFloat = 16
25 |
26 | }
27 |
28 | /**
29 | * Item bar
30 | */
31 | open class itemBar {
32 |
33 | /**
34 | * Item bar height
35 | */
36 | static let height: CGFloat = 40
37 |
38 | /**
39 | * Item bar divider height
40 | */
41 | static let dividerHeight: CGFloat = 16
42 |
43 | }
44 |
45 | /**
46 | * Navigation bar (in auth and post story/comment)
47 | */
48 | open class navigation {
49 |
50 | /**
51 | * Navigation bar height
52 | */
53 | static let height: CGFloat = 44
54 |
55 | /**
56 | * Status bar height
57 | */
58 | static let statusBarHeight: CGFloat = 20
59 |
60 | }
61 |
62 | /**
63 | * Default padding
64 | */
65 | static let padding: CGFloat = 16
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/HackerNews/BackgroundDividerNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BackgroundDividerNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 12/4/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class BackgroundDividerNode: ASDisplayNode {
12 |
13 | // MARK: - Properties
14 |
15 | var dividers: [ASDisplayNode] = []
16 |
17 | var level: Int = 0
18 |
19 | // MARK: - Lifecycle
20 |
21 | init(level: Int) {
22 | super.init()
23 |
24 | self.level = level
25 | }
26 |
27 | // MARK: - Helpers
28 |
29 | func createRequiredDividers() {
30 | while self.dividers.count <= self.level {
31 | let node = ASDisplayNode()
32 | self.addSubnode(node)
33 | self.dividers.append(node)
34 | }
35 | }
36 |
37 | func arrangeDividers() {
38 | for i in 0.. ThemeType? {
57 | for type in values {
58 | if type.value == value {
59 | return type
60 | }
61 | }
62 | return nil
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/HackerNews/DataRequest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataRequest.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/21/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Alamofire
11 | import PromiseKit
12 |
13 | extension DataRequest {
14 |
15 | func responseJSON() -> Promise<(DataResponse, [String: AnyObject])> {
16 | return Promise(resolvers: { (fulfill, reject) in
17 | self.responseJSON { response in
18 | switch response.result {
19 | case .success(let value):
20 | if let json = value as? [String: AnyObject] {
21 | fulfill(response, json)
22 | return;
23 | }
24 | reject(NSError.from(string: "Could not parse the data."))
25 | case .failure(let error):
26 | reject(error)
27 | }
28 | }
29 | })
30 | }
31 |
32 | func responseString() -> Promise<(DataResponse, String)> {
33 | return Promise(resolvers: { (fulfill, reject) in
34 | self.responseString { (response) in
35 | switch response.result {
36 | case .success(let value):
37 | fulfill(response, value)
38 | case .failure(let error):
39 | reject(error)
40 | }
41 | }
42 | })
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/HackerNews/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/9/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Firebase
11 |
12 | @UIApplicationMain
13 | class AppDelegate: UIResponder, UIApplicationDelegate {
14 |
15 | // MARK: - Properties
16 |
17 | var window: UIWindow?
18 | static var tabBarController: TabBarController!
19 | var launchedShortcutItem: Any?
20 |
21 | // MARK: - Delegates
22 |
23 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
24 |
25 | self.setup()
26 |
27 | return app(application, didFinishLaunchingWithOptions: launchOptions)
28 | }
29 |
30 | // MARK: - Helpers
31 |
32 | func setup() {
33 | ThemeManager.set(theme: ThemeManager.current)
34 | FontManager.set(font: FontManager.currentFont)
35 | self.setupTabBarController()
36 | self.setupWindow()
37 | self.setupFirebase()
38 | }
39 |
40 | func setupTabBarController() {
41 | AppDelegate.tabBarController = TabBarController()
42 | }
43 |
44 | func setupWindow() {
45 | self.window = UIWindow(frame: UIScreen.main.bounds)
46 | self.window?.rootViewController = AppDelegate.tabBarController
47 | self.window?.makeKeyAndVisible()
48 | }
49 |
50 | func setupFirebase() {
51 | FirebaseApp.configure()
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/HackerNews/GoogleService-Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | AD_UNIT_ID_FOR_BANNER_TEST
6 | ca-app-pub-3940256099942544/2934735716
7 | AD_UNIT_ID_FOR_INTERSTITIAL_TEST
8 | ca-app-pub-3940256099942544/4411468910
9 | CLIENT_ID
10 | 378846131057-3qak3gab1l08sfpoje0dahbt8piq6cvh.apps.googleusercontent.com
11 | REVERSED_CLIENT_ID
12 | com.googleusercontent.apps.378846131057-3qak3gab1l08sfpoje0dahbt8piq6cvh
13 | API_KEY
14 | AIzaSyBN1qskozD8jnkLfvQ0WJ4RblXQrPjLgBw
15 | GCM_SENDER_ID
16 | 378846131057
17 | PLIST_VERSION
18 | 1
19 | BUNDLE_ID
20 | com.xzya.HackerNews
21 | PROJECT_ID
22 | hackernews-97d33
23 | STORAGE_BUCKET
24 | hackernews-97d33.appspot.com
25 | IS_ADS_ENABLED
26 |
27 | IS_ANALYTICS_ENABLED
28 |
29 | IS_APPINVITE_ENABLED
30 |
31 | IS_GCM_ENABLED
32 |
33 | IS_SIGNIN_ENABLED
34 |
35 | GOOGLE_APP_ID
36 | 1:378846131057:ios:65fc4772c827de68
37 | DATABASE_URL
38 | https://hacker-news.firebaseio.com/
39 |
40 |
41 |
--------------------------------------------------------------------------------
/HackerNews/SearchSortType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchSortType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/3/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum SearchSortType: OptionType {
12 | case relevance
13 | case date
14 |
15 | var title: String {
16 | switch self {
17 | case .relevance:
18 | return "Most relevant"
19 | case .date:
20 | return "Most recent"
21 | }
22 | }
23 |
24 | var icon: IonIconType {
25 | switch self {
26 | case .relevance:
27 | return .ion_ios_checkmark
28 | case .date:
29 | return .ion_ios_clock
30 | }
31 | }
32 |
33 | var value: String {
34 | switch self {
35 | case .relevance:
36 | return "relevant"
37 | case .date:
38 | return "date"
39 | }
40 | }
41 |
42 | var url: String {
43 | switch self {
44 | case .relevance:
45 | return "/search"
46 | case .date:
47 | return "/search_by_date"
48 | }
49 | }
50 |
51 | static var values: [SearchSortType] {
52 | return [
53 | relevance,
54 | date,
55 | ]
56 | }
57 |
58 | static func initWithValue(value: String) -> SearchSortType? {
59 | for type in values {
60 | if type.value == value {
61 | return type
62 | }
63 | }
64 | return nil
65 | }
66 |
67 | static let label: String = "Sort by"
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/HackerNews/HNOptionActionNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OptionActionNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/3/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | /**
12 | * Full width button with centered text.
13 | * Used for buttons like cancel/close in the options select controller.
14 | */
15 | class HNOptionActionNode: ASCellNode {
16 |
17 | // MARK: - Properties
18 |
19 | var titleNode = ASTextNode()
20 |
21 | // MARK: - Inits
22 |
23 | init(optionType type: OptionType) {
24 | super.init()
25 |
26 | self.automaticallyManagesSubnodes = true
27 |
28 | // title
29 | self.titleNode.attributedText = NSAttributedString(
30 | string: type.title,
31 | attributes: Styles.options.title
32 | )
33 | self.titleNode.style.alignSelf = .center
34 |
35 | self.setupTheme()
36 | }
37 |
38 | // MARK: - Layout
39 |
40 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
41 |
42 | let centered = ASCenterLayoutSpec(
43 | centeringOptions: .XY,
44 | sizingOptions: [],
45 | child: self.titleNode
46 | )
47 |
48 | let insetTitle = ASInsetLayoutSpec(
49 | insets: UIEdgeInsetsMake(
50 | HNDimensions.padding / 1.5,
51 | HNDimensions.padding,
52 | HNDimensions.padding / 1.5,
53 | HNDimensions.padding),
54 | child: centered)
55 |
56 | return insetTitle
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/HackerNews/CommentsTableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentsTableView.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/23/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class CommentsTableView: ASTableNode {
12 |
13 | // MARK: - Properties
14 |
15 | var selectedIndexPaths: [IndexPath] = []
16 | var collapsedIndexPaths: [IndexPath] = []
17 |
18 | // MARK: - Lifecycle
19 |
20 | init() {
21 | super.init(style: .plain)
22 |
23 | self.setup()
24 | }
25 |
26 | // MARK: - Setup Helpers
27 |
28 | func setup() {
29 | // self.setupBackView()
30 | self.setupTableView()
31 | self.setupTheme()
32 | }
33 |
34 | func setupTableView() {
35 | self.view.separatorStyle = .none
36 | self.view.separatorInset = UIEdgeInsets.zero
37 | self.view.tableFooterView = UIView()
38 | self.view.alwaysBounceVertical = true
39 | self.view.layoutMargins = UIEdgeInsets.zero
40 | self.allowsMultipleSelection = true
41 | self.clipsToBounds = true
42 | self.view.backgroundView?.clipsToBounds = true
43 | }
44 |
45 | func setupBackView() {
46 | let backView = UIView(frame: UIScreen.main.bounds)
47 |
48 | for i in 1..<(Int(floor(UIScreen.main.bounds.width / HNDimensions.comments.paddingPerLevel))) {
49 | let divider = UIView(frame: CGRect(x: CGFloat(i) * HNDimensions.comments.paddingPerLevel, y: 0, width: 1, height: UIScreen.main.bounds.height))
50 | divider.backgroundColor = Styles.comment.divider
51 | backView.addSubview(divider)
52 | }
53 |
54 | self.view.backgroundView = backView
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/HackerNews/FontSizeType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FontSizeType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum FontSizeType: OptionType {
12 | case smallest
13 | case small
14 | case `default`
15 | case large
16 | case largest
17 |
18 | var value: String {
19 | switch self {
20 | case .smallest:
21 | return "smaller"
22 | case .small:
23 | return "small"
24 | case .default:
25 | return "default"
26 | case .large:
27 | return "large"
28 | case .largest:
29 | return "largest"
30 | }
31 | }
32 |
33 | var title: String {
34 | switch self {
35 | case .smallest:
36 | return "Smallest"
37 | case .small:
38 | return "Small"
39 | case .default:
40 | return "Default"
41 | case .large:
42 | return "Large"
43 | case .largest:
44 | return "Largest"
45 | }
46 | }
47 |
48 | var icon: IonIconType {
49 | switch self {
50 | default:
51 | return .ion_arrow_resize
52 | }
53 | }
54 |
55 | static var values: [FontSizeType] {
56 | return [
57 | smallest,
58 | small,
59 | `default`,
60 | large,
61 | largest
62 | ]
63 | }
64 |
65 | static func initWithValue(value: String) -> FontSizeType? {
66 | for type in values {
67 | if type.value == value {
68 | return type
69 | }
70 | }
71 | return nil
72 | }
73 |
74 | }
75 |
--------------------------------------------------------------------------------
/HackerNews/HNPagerViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNPagerViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/2/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import XLPagerTabStrip
11 |
12 | class HNPagerViewController: ButtonBarPagerTabStripViewController {
13 |
14 | // MARK: - Lifecycle
15 |
16 | override func viewDidLoad() {
17 | self.setupPager()
18 |
19 | super.viewDidLoad()
20 | }
21 |
22 | // MARK: - Setup Helpers
23 |
24 | func setupPager() {
25 | self.settings.style.buttonBarBackgroundColor = Styles.pager.buttonBarBackground
26 | self.settings.style.buttonBarItemBackgroundColor = Styles.pager.buttonBarItemBackground
27 | self.settings.style.selectedBarBackgroundColor = Styles.pager.selectedBarBackground
28 | self.settings.style.buttonBarItemFont = Styles.pager.buttonBarItemFont
29 | self.settings.style.selectedBarHeight = 1.5
30 | self.settings.style.buttonBarMinimumLineSpacing = 0
31 | self.settings.style.buttonBarItemTitleColor = Styles.pager.buttonBarItemTitleSelected
32 | self.settings.style.buttonBarItemsShouldFillAvailiableWidth = true
33 | self.settings.style.buttonBarLeftContentInset = 0
34 | self.settings.style.buttonBarRightContentInset = 0
35 |
36 | self.changeCurrentIndex = { (oldCell: ButtonBarViewCell?, newCell: ButtonBarViewCell?, animated: Bool) -> Void in
37 |
38 | oldCell?.label.textColor = Styles.pager.buttonBarItemTitle
39 | newCell?.label.textColor = Styles.pager.buttonBarItemTitleSelected
40 | }
41 |
42 | self.pagerBehaviour = .common(skipIntermediateViewControllers: true)
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/HackerNews/StoryType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoryType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum StoryType: OptionType {
12 | case top
13 | case new
14 | case best
15 | case ask
16 | case show
17 | case job
18 |
19 | var title: String {
20 | switch self {
21 | case .top:
22 | return "Top"
23 | case .new:
24 | return "New"
25 | case .best:
26 | return "Best"
27 | case .ask:
28 | return "Ask"
29 | case .show:
30 | return "Show"
31 | case .job:
32 | return "Job"
33 | }
34 | }
35 |
36 | var icon: IonIconType {
37 | switch self {
38 | default:
39 | return .ion_ios_star
40 | }
41 | }
42 |
43 | var value: String {
44 | switch self {
45 | case .top:
46 | return "topstories"
47 | case .new:
48 | return "newstories"
49 | case .best:
50 | return "beststories"
51 | case .ask:
52 | return "askstories"
53 | case .show:
54 | return "showstories"
55 | case .job:
56 | return "jobstories"
57 | }
58 | }
59 |
60 | static var values: [StoryType] {
61 | return [
62 | top,
63 | new,
64 | best,
65 | ask,
66 | show,
67 | job,
68 | ]
69 | }
70 |
71 | static func initWithValue(value: String) -> StoryType? {
72 | for type in values {
73 | if type.value == value {
74 | return type
75 | }
76 | }
77 | return nil
78 | }
79 |
80 | }
81 |
--------------------------------------------------------------------------------
/HackerNews/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 |
27 |
28 |
--------------------------------------------------------------------------------
/HackerNews/OptionSelectNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OptionSelectNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/23/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class OptionSelectNode: ASCellNode {
12 |
13 | // MARK: - Properties
14 |
15 | var iconNode: ASTextNode!
16 | var titleNode: ASTextNode!
17 |
18 | // MARK: - Inits
19 |
20 | init(optionType type: OptionType, selected: Bool = false) {
21 | super.init()
22 |
23 | // icon
24 | self.iconNode = ASTextNode()
25 | self.iconNode.attributedText = IonIcon.textIcon(
26 | type: type.icon,
27 | color: selected ? Styles.options.highlightedIcon : Styles.options.icon
28 | )
29 | self.addSubnode(self.iconNode)
30 |
31 | // title
32 | self.titleNode = ASTextNode()
33 | self.titleNode.attributedText = NSAttributedString(
34 | string: type.title,
35 | attributes: Styles.options.title
36 | )
37 | self.addSubnode(self.titleNode)
38 |
39 | self.setupTheme()
40 | }
41 |
42 | // MARK: - Layout
43 |
44 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
45 |
46 | let mainStack = ASStackLayoutSpec(
47 | direction: .horizontal,
48 | spacing: HNDimensions.padding,
49 | justifyContent: .start,
50 | alignItems: .center,
51 | children: [self.iconNode, self.titleNode])
52 |
53 | let insetTitle = ASInsetLayoutSpec(
54 | insets: UIEdgeInsetsMake(
55 | HNDimensions.padding / 1.5,
56 | HNDimensions.padding,
57 | HNDimensions.padding / 1.5,
58 | HNDimensions.padding),
59 | child: mainStack)
60 |
61 | return insetTitle
62 | }
63 |
64 | }
65 |
--------------------------------------------------------------------------------
/HackerNews/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "size" : "20x20",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "size" : "20x20",
51 | "scale" : "2x"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "size" : "29x29",
56 | "scale" : "1x"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "size" : "29x29",
61 | "scale" : "2x"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "size" : "40x40",
66 | "scale" : "1x"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "size" : "40x40",
71 | "scale" : "2x"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "size" : "76x76",
76 | "scale" : "1x"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "size" : "76x76",
81 | "scale" : "2x"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "size" : "83.5x83.5",
86 | "scale" : "2x"
87 | }
88 | ],
89 | "info" : {
90 | "version" : 1,
91 | "author" : "xcode"
92 | }
93 | }
--------------------------------------------------------------------------------
/HackerNews/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | NSAppTransportSecurity
24 |
25 | NSAllowsArbitraryLoads
26 |
27 | NSAllowsArbitraryLoadsInWebContent
28 |
29 |
30 | UIAppFonts
31 |
32 | ionicons.ttf
33 |
34 | UILaunchStoryboardName
35 | LaunchScreen
36 | UIRequiredDeviceCapabilities
37 |
38 | armv7
39 |
40 | UIStatusBarTintParameters
41 |
42 | UINavigationBar
43 |
44 | Style
45 | UIBarStyleDefault
46 | Translucent
47 |
48 |
49 |
50 | UISupportedInterfaceOrientations
51 |
52 | UIInterfaceOrientationPortrait
53 |
54 | UISupportedInterfaceOrientations~ipad
55 |
56 | UIInterfaceOrientationPortrait
57 | UIInterfaceOrientationPortraitUpsideDown
58 | UIInterfaceOrientationLandscapeLeft
59 | UIInterfaceOrientationLandscapeRight
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/HackerNews/SettingsProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsProvider.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import PINCache
11 |
12 | class SettingsProvider {
13 |
14 | static func get(ofType type: SettingsType) -> Any? {
15 | return PINCache.shared.object(forKey: type.value)
16 | }
17 |
18 | static func set(value: String, forType type: SettingsType) {
19 | PINCache.shared.setObject(value as NSCoding, forKey: type.value)
20 | }
21 |
22 | static var storyType: StoryType {
23 | get {
24 | return StoryType.initWithValue(value: (self.get(ofType: .defaultStoryType) as? String ?? "")) ?? .top
25 | }
26 | set {
27 | self.set(value: newValue.value, forType: .defaultStoryType)
28 | }
29 | }
30 |
31 | static var font: FontType {
32 | get {
33 | return FontType.initWithValue(value: (self.get(ofType: .font) as? String ?? "")) ?? .default
34 | }
35 | set {
36 | self.set(value: newValue.value, forType: .font)
37 |
38 | FontManager.set(fontType: newValue, andSizeType: self.fontSize)
39 | }
40 | }
41 |
42 | static var fontSize: FontSizeType {
43 | get {
44 | return FontSizeType.initWithValue(value: (self.get(ofType: .fontSize) as? String ?? "")) ?? .default
45 | }
46 | set {
47 | self.set(value: newValue.value, forType: .fontSize)
48 |
49 | FontManager.set(fontType: self.font, andSizeType: newValue)
50 | }
51 | }
52 |
53 | static var theme: ThemeType {
54 | get {
55 | return ThemeType.initWithValue(value: (self.get(ofType: .theme) as? String ?? "")) ?? .dark
56 | }
57 | set {
58 | self.set(value: newValue.value, forType: .theme)
59 |
60 | ThemeManager.set(themeType: newValue)
61 | }
62 | }
63 |
64 | }
65 |
--------------------------------------------------------------------------------
/HackerNews/TextHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TextHelper.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/19/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class TextHelper {
12 |
13 | static func text(commentButtonWithString string: String) -> NSAttributedString {
14 | let attributedString = IonIcon.subtitleIcon(type: .ion_chatbox)
15 | attributedString.append(NSAttributedString(
16 | string: string,
17 | attributes: Styles.storyBar.button))
18 |
19 | return attributedString
20 | }
21 |
22 | static func text(shareButtonWithString string: String) -> NSAttributedString {
23 | let attributedString = IonIcon.subtitleIcon(type: .ion_ios_upload)
24 | attributedString.append(NSAttributedString(
25 | string: string,
26 | attributes: Styles.storyBar.button))
27 |
28 | return attributedString
29 | }
30 |
31 | static func text(voteButtonWithString string: String) -> NSAttributedString {
32 | let attributedString = IonIcon.subtitleIcon(type: .ion_arrow_up_a)
33 | attributedString.append(NSAttributedString(
34 | string: string,
35 | attributes: Styles.storyBar.button))
36 |
37 | return attributedString
38 | }
39 |
40 | static func text(replyButtonWithString string: String) -> NSAttributedString {
41 | let attributedString = IonIcon.subtitleIcon(type: .ion_reply)
42 | attributedString.append(NSAttributedString(
43 | string: string,
44 | attributes: Styles.storyBar.button))
45 |
46 | return attributedString
47 | }
48 |
49 | static func text(moreButtonWithString string: String) -> NSAttributedString {
50 | let attributedString = IonIcon.subtitleIcon(type: .ion_more)
51 | attributedString.append(NSAttributedString(
52 | string: string,
53 | attributes: Styles.storyBar.button))
54 |
55 | return attributedString
56 | }
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hacker News iOS Client
2 |
3 | This is a [Hacker News](https://news.ycombinator.com/) iOS client which I wrote some time ago.
4 |
5 | It supports most of the things you would expect from a news client with some exceptions (posting stories and commenting is not supported currently), though I wanted to include many more features.
6 |
7 | Unfortunately, I lost interest in the project, and I don't think I will work on it any time soon, so I'm open sourcing the code in case someone finds it usefull.
8 |
9 | ## Screenshots
10 |
11 | 
12 | 
13 | 
14 |
15 | ## Features
16 |
17 | - See posts by top/new/best/ask/show/job
18 | - View link inside the app
19 | - View post comments
20 | - Search, powered by Algolia
21 | - Stories
22 | - Comments
23 | - Filter by time period
24 | - Sort by relevance/recent
25 | - Login/Signup
26 | - View own posts
27 | - View own comments
28 | - History of viewed posts (stored locally on the device)
29 | - Own upvotes
30 | - View other people's profile
31 | - About
32 | - Posts
33 | - Comments
34 | - Themes
35 | - Change font/size
36 |
37 | ## Not supported
38 |
39 | - Post story
40 | - Reply to post
41 |
42 | ## Getting started
43 |
44 | ### Requirements
45 |
46 | - XCode 8+
47 | - iOS 9+
48 | - [CocoaPods](https://cocoapods.org/)
49 |
50 | ### Running the project
51 |
52 | Install the dependencies
53 |
54 | ```bash
55 | pod install
56 | ```
57 |
58 | Open `HackerNews.xcworkspace`.
59 |
60 | ## Built with
61 |
62 | - [AsyncDisplayKit
63 | ](https://github.com/facebookarchive/AsyncDisplayKit) (now [Texture](https://github.com/texturegroup/texture/)) - Layouts
64 | - [Alamofire](https://github.com/Alamofire/Alamofire) - Networking
65 | - [PromiseKit](https://github.com/mxcl/PromiseKit) - JavaScript style promises for easier async operations
66 | - [PINCache](https://github.com/pinterest/PINCache) - Caching
67 |
68 | ## License
69 |
70 | Open sourced under the [MIT license](./LICENSE.md).
--------------------------------------------------------------------------------
/HackerNews/HNOptionHeaderNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNOptionHeaderNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/4/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | /**
12 | * Header node for an option.
13 | */
14 | class HNOptionHeaderNode: ASCellNode {
15 |
16 | // MARK: - Properties
17 |
18 | var titleNode = ASTextNode()
19 | var topDividerNode = ASDisplayNode()
20 | var bottomDividerNode = ASDisplayNode()
21 |
22 | // MARK: - Inits
23 |
24 | init(withTitle title: String) {
25 | super.init()
26 |
27 | self.automaticallyManagesSubnodes = true
28 |
29 | // title
30 | self.titleNode.attributedText = NSAttributedString(
31 | string: title,
32 | attributes: Styles.options.header
33 | )
34 | self.titleNode.style.alignSelf = .start
35 |
36 | self.setupTheme()
37 | }
38 |
39 | // MARK: - Layout
40 |
41 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
42 |
43 | // grow horizontally
44 | self.topDividerNode.style.flexGrow = 1
45 | self.bottomDividerNode.style.flexGrow = 1
46 |
47 | // height 1
48 | self.topDividerNode.style.preferredSize = CGSize(width: 0, height: 1)
49 | self.bottomDividerNode.style.preferredSize = CGSize(width: 0, height: 1)
50 |
51 | // align the title to the left
52 | self.titleNode.style.alignSelf = .start
53 |
54 | let insetTitle = ASInsetLayoutSpec(
55 | insets: UIEdgeInsetsMake(
56 | HNDimensions.padding / 1.5,
57 | HNDimensions.padding,
58 | HNDimensions.padding / 1.5,
59 | HNDimensions.padding),
60 | child: self.titleNode)
61 |
62 | let mainStack = ASStackLayoutSpec(
63 | direction: .vertical,
64 | spacing: 0,
65 | justifyContent: .center,
66 | alignItems: .stretch,
67 | children: [self.topDividerNode, insetTitle, self.bottomDividerNode]
68 | )
69 |
70 | return mainStack
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/HackerNews/UserAboutBioNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserAboutBioNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/6/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class UserAboutBioNode: ASCellNode {
12 |
13 | // MARK: - Properties
14 |
15 | var aboutNode: ASTextNode!
16 |
17 | weak var delegate: UserAboutBioNodeDelegate?
18 |
19 | // MARK: - Inits
20 |
21 | init(withUser user: User) {
22 | super.init()
23 |
24 | self.selectionStyle = .none
25 |
26 | // about
27 | self.aboutNode = ASTextNode()
28 | self.aboutNode.attributedText = NSMutableAttributedString(
29 | string: user.about,
30 | attributes: Styles.user.aboutBio).detectLinks()
31 | self.aboutNode.isUserInteractionEnabled = true
32 | self.aboutNode.linkAttributeNames = [Constants.kTextLinkAttributeName]
33 | self.aboutNode.passthroughNonlinkTouches = true
34 | self.aboutNode.delegate = self
35 | self.addSubnode(self.aboutNode)
36 |
37 | self.setupTheme()
38 | }
39 |
40 | // MARK: - Layout
41 |
42 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
43 |
44 | let insetStack = ASInsetLayoutSpec(
45 | insets: UIEdgeInsetsMake(
46 | HNDimensions.padding / 2,
47 | HNDimensions.padding,
48 | HNDimensions.padding / 2,
49 | HNDimensions.padding),
50 | child: self.aboutNode)
51 |
52 | return insetStack
53 | }
54 |
55 | }
56 |
57 | // MARK: - ASTextNodeDelegate
58 |
59 | extension UserAboutBioNode: ASTextNodeDelegate {
60 |
61 | func textNode(_ textNode: ASTextNode, shouldHighlightLinkAttribute attribute: String, value: Any, at point: CGPoint) -> Bool {
62 | return true
63 | }
64 |
65 | func textNode(_ textNode: ASTextNode, tappedLinkAttribute attribute: String, value: Any, at point: CGPoint, textRange: NSRange) {
66 | if let value = value as? URL {
67 | self.delegate?.userAboutBioNode?(self, didPressLink: value.absoluteString, withSender: textNode)
68 | }
69 | }
70 |
71 | }
72 |
--------------------------------------------------------------------------------
/HackerNews/SettingsType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum SettingsType: OptionType {
12 | case defaultStoryType
13 | case font
14 | case fontSize
15 | case theme
16 | case clearCache
17 |
18 | case none
19 |
20 | var value: String {
21 | switch self {
22 | case .defaultStoryType:
23 | return "defaultStoryType"
24 | case .font:
25 | return "font"
26 | case .fontSize:
27 | return "fontSize"
28 | case .theme:
29 | return "theme"
30 | case .clearCache:
31 | return "clearCache"
32 | default:
33 | return ""
34 | }
35 | }
36 |
37 | var title: String {
38 | switch self {
39 | case .defaultStoryType:
40 | return "Default story type"
41 | case .font:
42 | return "Font"
43 | case .fontSize:
44 | return "Font size"
45 | case .theme:
46 | return "Theme"
47 | case .clearCache:
48 | return "Clear cache"
49 | default:
50 | return ""
51 | }
52 | }
53 |
54 | var icon: IonIconType {
55 | switch self {
56 | case .defaultStoryType:
57 | return .ion_social_rss
58 | case .font:
59 | return .ion_ios_list
60 | case .fontSize:
61 | return .ion_arrow_resize
62 | case .theme:
63 | return .ion_android_color_palette
64 | case .clearCache:
65 | return .ion_trash_a
66 | default:
67 | return .ion_ios_help
68 | }
69 | }
70 |
71 | static var values: [SettingsType] {
72 | return [
73 | defaultStoryType,
74 | font,
75 | fontSize,
76 | theme,
77 | clearCache
78 | ]
79 | }
80 |
81 | static func initWithValue(value: String) -> SettingsType? {
82 | for type in values {
83 | if type.value == value {
84 | return type
85 | }
86 | }
87 | return nil
88 | }
89 |
90 | }
91 |
--------------------------------------------------------------------------------
/HackerNews/BaseViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/9/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import AMScrollingNavbar
11 | import MBProgressHUD
12 | import AsyncDisplayKit
13 |
14 | class BaseViewController: ASViewController {
15 |
16 | // MARK: - Lifecycle
17 |
18 | override func viewDidLoad() {
19 | super.viewDidLoad()
20 |
21 | self.setupTheme()
22 | }
23 |
24 | // MARK: - Properties
25 |
26 | override var nibName: String? {
27 | get {
28 | // return self.classForCoder.description().components(separatedBy: ".").last ?? super.nibName
29 | return nil
30 | }
31 | }
32 |
33 | var navController: NavigationController {
34 | return self.navigationController as! NavigationController
35 | }
36 |
37 | // MARK: - Helpers
38 |
39 | func authenticated() -> Bool {
40 | if !User.sharedInstance.isLoggedIn() {
41 | self.present(AuthViewController(state: .login), animated: true, completion: nil)
42 | return false
43 | }
44 | return true
45 | }
46 |
47 | }
48 |
49 | extension BaseViewController {
50 |
51 | func navigationController(followScrollView scrollView: UIView, delay: Double) {
52 | if let navigationController = navigationController as? ScrollingNavigationController {
53 | navigationController.followScrollView(scrollView, delay: delay)
54 | }
55 | }
56 |
57 | func stopFollowingScrollView() {
58 | if let navigationController = navigationController as? ScrollingNavigationController {
59 | navigationController.stopFollowingScrollView()
60 | }
61 | }
62 |
63 | func showNavbar(animated: Bool = true) {
64 | if let navigationController = navigationController as? ScrollingNavigationController {
65 | navigationController.showNavbar(animated: animated)
66 | }
67 | }
68 |
69 | }
70 |
71 | extension BaseViewController {
72 |
73 | func showLoader() {
74 | MBProgressHUD.showLoader(view: self.view)
75 | }
76 |
77 | func hideLoader() {
78 | MBProgressHUD.hideLoader(view: self.view)
79 | }
80 |
81 | }
82 |
--------------------------------------------------------------------------------
/HackerNews/NavigationController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/9/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import AMScrollingNavbar
11 |
12 | class NavigationController: ScrollingNavigationController {
13 |
14 | // MARK: - Inits
15 |
16 | init() {
17 | super.init(rootViewController: HomeViewController())
18 |
19 | self.setup()
20 | }
21 |
22 | override init(rootViewController: UIViewController) {
23 | super.init(rootViewController: rootViewController)
24 |
25 | self.setup()
26 | }
27 |
28 | override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
29 | super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
30 |
31 | self.setup()
32 | }
33 |
34 | required init?(coder aDecoder: NSCoder) {
35 | super.init(coder: aDecoder)
36 |
37 | self.setup()
38 | }
39 |
40 | // MARK: - Setup Helpers
41 |
42 | func setup() {
43 | self.setupTheme()
44 | }
45 |
46 | // MARK: - Helpers
47 |
48 | func push(viewController: UIViewController, animated: Bool = true) {
49 | self.pushViewController(viewController, animated: animated)
50 | }
51 |
52 | func pop(toViewControllerOfType type: AnyClass, animated: Bool = true) -> Bool {
53 | for viewController in self.viewControllers {
54 | if viewController.classForCoder == type {
55 | let _ = self.popToViewController(viewController, animated: animated)
56 | return true
57 | }
58 | }
59 | return false
60 | }
61 |
62 | func back(animated: Bool = true) {
63 | let _ = self.popViewController(animated: animated)
64 | }
65 |
66 | func displayHome(animated: Bool = true) {
67 | if !self.pop(toViewControllerOfType: HomeViewController.classForCoder(), animated: animated) {
68 | self.push(viewController: HomeViewController(), animated: animated)
69 | }
70 | }
71 |
72 | func present(viewController: UIViewController, animated: Bool = true, completionHandler handler: (() -> Void)? = nil) {
73 | self.present(viewController: viewController, animated: animated, completionHandler: handler)
74 | }
75 |
76 | }
77 |
--------------------------------------------------------------------------------
/HackerNews/HNAPI.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNAPI.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/9/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import PromiseKit
11 | import Alamofire
12 |
13 | class HNAPI {
14 |
15 | static let HNAPIQueue = DispatchQueue(label: "HNAPIQueue")
16 |
17 | static func stories(ofType type: StoryType) -> Promise<[Int]> {
18 | return FirebaseAPI.stories(ofType: type)
19 | }
20 |
21 | static func item(id: Int) -> Promise- {
22 | return FirebaseAPI.item(id: id)
23 | }
24 |
25 | static func itemDetails(id: Int) -> Promise
- {
26 | return RestAPI.item(id: id)
27 | }
28 |
29 | static func user(id: String) -> Promise {
30 | return FirebaseAPI.user(id: id)
31 | }
32 |
33 | static func userPosts(id: String, page: Int = 0) -> Promise {
34 | return RestAPI.userPosts(id: id, page: page)
35 | }
36 |
37 | static func userComments(id: String, page: Int = 0) -> Promise {
38 | return RestAPI.userComments(id: id, page: page)
39 | }
40 |
41 | static func searchStories(query: String, timeRange: SearchTimeRangeType = .all, sortBy: SearchSortType = .relevance, page: Int = 0) -> Promise {
42 | return RestAPI.searchStories(query: query, timeRange: timeRange, sortBy: sortBy, page: page)
43 | }
44 |
45 | static func searchComments(query: String, timeRange: SearchTimeRangeType = .all, sortBy: SearchSortType = .relevance, page: Int = 0) -> Promise {
46 | return RestAPI.searchComments(query: query, timeRange: timeRange, sortBy: sortBy, page: page)
47 | }
48 |
49 | static func vote(item: Item, up: Bool = true) -> Promise {
50 | return SiteAPI.vote(item: item, up: up)
51 | }
52 |
53 | static func login(username: String, password: String) -> Promise {
54 | return SiteAPI.login(username: username, password: password)
55 | }
56 |
57 | static func register(username: String, password: String) -> Promise {
58 | return SiteAPI.register(username: username, password: password)
59 | }
60 |
61 | static func postStory(title: String, url: String?, text: String?) -> Promise {
62 | return SiteAPI.postStory(title: title, url: url, text: text)
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/HackerNews/SearchTimeRangeType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchTimeType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/3/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum SearchTimeRangeType: OptionType {
12 | case all
13 | case day
14 | case week
15 | case month
16 | case year
17 |
18 | var title: String {
19 | switch self {
20 | case .all:
21 | return "All time"
22 | case .day:
23 | return "Last 24h"
24 | case .week:
25 | return "Last week"
26 | case .month:
27 | return "Last month"
28 | case .year:
29 | return "Last year"
30 | }
31 | }
32 |
33 | var icon: IonIconType {
34 | switch self {
35 | default:
36 | return .ion_ios_star
37 | }
38 | }
39 |
40 | var value: String {
41 | switch self {
42 | case .all:
43 | return "all"
44 | case .day:
45 | return "day"
46 | case .week:
47 | return "week"
48 | case .month:
49 | return "month"
50 | case .year:
51 | return "year"
52 | }
53 | }
54 |
55 | var startTime: Date? {
56 | let calendar = Calendar.current
57 |
58 | switch self {
59 |
60 | case .all:
61 |
62 | // in case we want all results, simply ignore the time
63 | return nil
64 |
65 | case .day:
66 |
67 | return calendar.date(byAdding: .day, value: -1, to: Date(), wrappingComponents: false)
68 |
69 | case .week:
70 |
71 | return calendar.date(byAdding: .day, value: -7, to: Date(), wrappingComponents: false)
72 |
73 | case .month:
74 |
75 | return calendar.date(byAdding: .month, value: -1, to: Date(), wrappingComponents: false)
76 |
77 | case .year:
78 |
79 | return calendar.date(byAdding: .year, value: -1, to: Date(), wrappingComponents: false)
80 | }
81 |
82 | }
83 |
84 | static var values: [SearchTimeRangeType] {
85 | return [
86 | all,
87 | day,
88 | week,
89 | month,
90 | year,
91 | ]
92 | }
93 |
94 | static func initWithValue(value: String) -> SearchTimeRangeType? {
95 | for type in values {
96 | if type.value == value {
97 | return type
98 | }
99 | }
100 | return nil
101 | }
102 |
103 | static let label: String = "Since"
104 |
105 | }
106 |
--------------------------------------------------------------------------------
/HackerNews/FontType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FontType.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum FontType: OptionType {
12 | case `default`
13 | case helvetica
14 | case helveticaNeue
15 | case georgia
16 | case timesNewRoman
17 | case trebuchetMS
18 | case courier
19 | case courierNew
20 | case arial
21 | case avenir
22 |
23 | var value: String {
24 | switch self {
25 | case .default:
26 | return "default"
27 | case .helvetica:
28 | return "helvetica"
29 | case .helveticaNeue:
30 | return "helveticaNeue"
31 | case .georgia:
32 | return "georgia"
33 | case .timesNewRoman:
34 | return "timesNewRoman"
35 | case .trebuchetMS:
36 | return "trebuchetMS"
37 | case .courier:
38 | return "courier"
39 | case .courierNew:
40 | return "courierNew"
41 | case .arial:
42 | return "arial"
43 | case .avenir:
44 | return "avenir"
45 | }
46 | }
47 |
48 | var title: String {
49 | switch self {
50 | case .default:
51 | return "Default"
52 | case .helvetica:
53 | return "Helvetica"
54 | case .helveticaNeue:
55 | return "Helvetica Neue"
56 | case .georgia:
57 | return "Georgia"
58 | case .timesNewRoman:
59 | return "Times New Roman"
60 | case .trebuchetMS:
61 | return "Trebuchet MS"
62 | case .courier:
63 | return "Courier"
64 | case .courierNew:
65 | return "Courier New"
66 | case .arial:
67 | return "Arial"
68 | case .avenir:
69 | return "Avenir"
70 | }
71 | }
72 |
73 | var icon: IonIconType {
74 | switch self {
75 | default:
76 | return .ion_ios_list
77 | }
78 | }
79 |
80 | static var values: [FontType] {
81 | return [
82 | `default`,
83 | helvetica,
84 | helveticaNeue,
85 | georgia,
86 | timesNewRoman,
87 | trebuchetMS,
88 | courier,
89 | arial,
90 | avenir,
91 | ]
92 | }
93 |
94 | static func initWithValue(value: String) -> FontType? {
95 | for type in values {
96 | if type.value == value {
97 | return type
98 | }
99 | }
100 | return nil
101 | }
102 |
103 | }
104 |
--------------------------------------------------------------------------------
/HackerNews/UserAboutNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserAboutNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/6/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class UserAboutNode: ASCellNode {
12 |
13 | // MARK: - Properties
14 |
15 | var karmaLabelNode: ASTextNode!
16 | var karmaNode: ASTextNode!
17 | var createdLabelNode: ASTextNode!
18 | var createdNode: ASTextNode!
19 |
20 | // MARK: - Inits
21 |
22 | init(withUser user: User) {
23 | super.init()
24 |
25 | self.selectionStyle = .none
26 |
27 | // karma label
28 | self.karmaLabelNode = ASTextNode()
29 | self.karmaLabelNode.attributedText = NSAttributedString(
30 | string: "KARMA",
31 | attributes: Styles.user.aboutLabel)
32 | self.addSubnode(self.karmaLabelNode)
33 |
34 | // karma
35 | self.karmaNode = ASTextNode()
36 | self.karmaNode.attributedText = NSAttributedString(
37 | string: "\(user.karma)",
38 | attributes: Styles.user.aboutValue)
39 | self.addSubnode(self.karmaNode)
40 |
41 | // created label
42 | self.createdLabelNode = ASTextNode()
43 | self.createdLabelNode.attributedText = NSAttributedString(
44 | string: "MEMBER SINCE",
45 | attributes: Styles.user.aboutLabel)
46 | self.addSubnode(self.createdLabelNode)
47 |
48 | // created
49 | self.createdNode = ASTextNode()
50 | self.createdNode.attributedText = NSAttributedString(
51 | string: "\(NSDate.init(timeIntervalSince1970: user.created).timeAgo() ?? "")",
52 | attributes: Styles.user.aboutValue)
53 | self.addSubnode(self.createdNode)
54 |
55 | self.setupTheme()
56 | }
57 |
58 | // MARK: - Layout
59 |
60 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
61 |
62 | let karmaStack = ASStackLayoutSpec(
63 | direction: .vertical,
64 | spacing: HNDimensions.padding / 4,
65 | justifyContent: .start,
66 | alignItems: .center,
67 | children: [self.karmaLabelNode, self.karmaNode])
68 |
69 | let createdStack = ASStackLayoutSpec(
70 | direction: .vertical,
71 | spacing: HNDimensions.padding / 4,
72 | justifyContent: .start,
73 | alignItems: .center,
74 | children: [self.createdLabelNode, self.createdNode])
75 |
76 | let mainStack = ASStackLayoutSpec(
77 | direction: .horizontal,
78 | spacing: 0,
79 | justifyContent: .spaceBetween,
80 | alignItems: .center,
81 | children: [karmaStack, createdStack])
82 |
83 | let insetStack = ASInsetLayoutSpec(
84 | insets: UIEdgeInsetsMake(
85 | HNDimensions.padding / 2,
86 | HNDimensions.padding,
87 | HNDimensions.padding / 2,
88 | HNDimensions.padding),
89 | child: mainStack)
90 |
91 | return insetStack
92 | }
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/HackerNews/UserCommentNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserCommentNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/6/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class UserCommentNode: ASCellNode {
12 |
13 | // MARK: - Properties
14 |
15 | var posterNode = ASTextNode()
16 | var titleNode = ASTextNode()
17 | var textNode = ASTextNode()
18 |
19 | weak var delegate: UserCommentNodeDelegate?
20 |
21 | // MARK: - Inits
22 |
23 | init(withItem item: Item) {
24 | super.init()
25 |
26 | self.automaticallyManagesSubnodes = true
27 | self.selectionStyle = .none
28 |
29 | // poster
30 | self.posterNode.attributedText = NSAttributedString(
31 | string: "\(item.by) • \(NSDate.init(timeIntervalSince1970: item.time).timeAgo() ?? "")",
32 | attributes: Styles.story.poster
33 | )
34 | self.posterNode.truncationMode = .byTruncatingTail
35 | self.posterNode.maximumNumberOfLines = 1
36 | self.posterNode.style.flexShrink = 1
37 |
38 | // title
39 | self.titleNode.attributedText = NSAttributedString(
40 | string: item.storyTitle,
41 | attributes: Styles.storyDetails.title
42 | )
43 |
44 | // text
45 | self.textNode.attributedText = NSMutableAttributedString(
46 | string: item.text,
47 | attributes: Styles.storyDetails.text).detectLinks()
48 | self.textNode.isUserInteractionEnabled = true
49 | self.textNode.linkAttributeNames = [Constants.kTextLinkAttributeName]
50 | self.textNode.passthroughNonlinkTouches = true
51 | self.textNode.delegate = self
52 |
53 | self.setupTheme()
54 | }
55 |
56 | // MARK: - Layout
57 |
58 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
59 |
60 | let mainStack = ASStackLayoutSpec(
61 | direction: .vertical,
62 | spacing: HNDimensions.padding / 2,
63 | justifyContent: .start,
64 | alignItems: .start,
65 | children: [self.posterNode, self.titleNode, self.textNode])
66 |
67 | let insetStack = ASInsetLayoutSpec(
68 | insets: UIEdgeInsetsMake(
69 | HNDimensions.padding / 2,
70 | HNDimensions.padding,
71 | HNDimensions.padding / 2,
72 | HNDimensions.padding),
73 | child: mainStack)
74 |
75 | return insetStack
76 | }
77 |
78 | }
79 |
80 | // MARK: - ASTextNodeDelegate
81 |
82 | extension UserCommentNode: ASTextNodeDelegate {
83 |
84 | func textNode(_ textNode: ASTextNode, shouldHighlightLinkAttribute attribute: String, value: Any, at point: CGPoint) -> Bool {
85 | return true
86 | }
87 |
88 | func textNode(_ textNode: ASTextNode, tappedLinkAttribute attribute: String, value: Any, at point: CGPoint, textRange: NSRange) {
89 | if let value = value as? URL {
90 | self.delegate?.userCommentNode?(self, didPressLink: value.absoluteString, withSender: textNode)
91 | }
92 | }
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/HackerNews/HNPostViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNPostViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/24/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class HNPostViewController: BaseViewController {
12 |
13 | // MARK: - IBOutlets
14 |
15 | var tableView = HNTableNode()
16 |
17 | // MARK: - Lifecycle
18 |
19 | init() {
20 | super.init(node: self.tableView)
21 | }
22 |
23 | required init?(coder aDecoder: NSCoder) {
24 | super.init(coder: aDecoder)
25 | }
26 |
27 | override func viewDidLoad() {
28 | super.viewDidLoad()
29 |
30 | self.setup()
31 | }
32 |
33 | override func viewWillAppear(_ animated: Bool) {
34 | super.viewWillAppear(animated)
35 |
36 | self.registerForKeyboardNotifications()
37 | }
38 |
39 | override func viewWillDisappear(_ animated: Bool) {
40 | super.viewWillDisappear(animated)
41 |
42 | NotificationCenter.default.removeObserver(self)
43 | }
44 |
45 | // MARK: - Setup Helpers
46 |
47 | func setup() {
48 | self.setupTableView()
49 |
50 | self.setupNavigationItem()
51 | }
52 |
53 | func setupTableView() {
54 | self.tableView.dataSource = self
55 | self.tableView.delegate = self
56 | }
57 |
58 | func setupNavigationItem() {
59 | self.navigationItem.set(leftButtonIcon: .ion_close, target: self, action: #selector(self.onCloseButtonPressed))
60 | self.navigationItem.set(rightButtonIcon: .ion_checkmark_round, target: self, action: #selector(self.onPostButtonPressed))
61 | self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
62 | }
63 |
64 | func registerForKeyboardNotifications() {
65 | NotificationCenter.default.addObserver(self, selector: #selector(self.onKeyboardDidShow(notification:)), name: .UIKeyboardDidShow, object: nil)
66 | NotificationCenter.default.addObserver(self, selector: #selector(self.onKeyboardWillHide(notification:)), name: .UIKeyboardWillHide, object: nil)
67 | }
68 |
69 | // MARK: - Helpers
70 |
71 | func onCloseButtonPressed() {
72 | self.dismiss(animated: true, completion: nil)
73 | }
74 |
75 | func onPostButtonPressed() {
76 | }
77 |
78 | func onKeyboardDidShow(notification: Notification) {
79 | }
80 |
81 | func onKeyboardWillHide(notification: Notification) {
82 | }
83 |
84 | }
85 |
86 | // MARK: - ASTableDataSource, ASTableDelegate
87 |
88 | extension HNPostViewController: ASTableDataSource, ASTableDelegate {
89 |
90 | func numberOfSections(in tableView: UITableView) -> Int {
91 | return 0
92 | }
93 |
94 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
95 | return 0
96 | }
97 |
98 | func tableView(_ tableView: ASTableView, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
99 | return {
100 | return ASCellNode()
101 | }
102 | }
103 |
104 | }
105 |
--------------------------------------------------------------------------------
/HackerNews/FontManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FontManager.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/19/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class Font {
12 |
13 | static let kIconSizeOffset: CGFloat = 4
14 |
15 | var titleSize: CGFloat = 17
16 | var textSize: CGFloat = 15
17 | var subtitleSize: CGFloat = 11
18 |
19 | var titleFont: UIFont
20 | var textFont: UIFont
21 | var subtitleFont: UIFont
22 |
23 | var titleIconFont: UIFont
24 | var textIconFont: UIFont
25 | var subtitleIconFont: UIFont
26 |
27 | init(fontName: String, offset: CGFloat) {
28 | self.titleSize += offset
29 | self.textSize += offset
30 | self.subtitleSize += offset
31 |
32 | if let titleFont = UIFont(name: fontName, size: self.titleSize) {
33 | self.titleFont = titleFont
34 | } else {
35 | self.titleFont = UIFont.systemFont(ofSize: self.titleSize)
36 | }
37 |
38 | if let textFont = UIFont(name: fontName, size: self.textSize) {
39 | self.textFont = textFont
40 | } else {
41 | self.textFont = UIFont.systemFont(ofSize: self.textSize)
42 | }
43 |
44 | if let subtitleFont = UIFont(name: fontName, size: self.subtitleSize) {
45 | self.subtitleFont = subtitleFont
46 | } else {
47 | self.subtitleFont = UIFont.systemFont(ofSize: self.subtitleSize)
48 | }
49 |
50 | self.titleIconFont = UIFont.ionIconFont(ofSize: self.titleSize + Font.kIconSizeOffset)
51 | self.textIconFont = UIFont.ionIconFont(ofSize: self.textSize + Font.kIconSizeOffset)
52 | self.subtitleIconFont = UIFont.ionIconFont(ofSize: self.subtitleSize + Font.kIconSizeOffset)
53 | }
54 |
55 | static let normal: Font = {
56 | return Font(fontName: UIFont.systemFont(ofSize: 1).fontName, offset: 1)
57 | }()
58 |
59 | }
60 |
61 | class FontManager {
62 |
63 | static var currentFont: Font = {
64 | return FontManager.get(ofFontType: SettingsProvider.font, andSizeType: SettingsProvider.fontSize)
65 | }()
66 |
67 | static func set(font: Font) {
68 | self.currentFont = font
69 | }
70 |
71 | static func get(ofFontType type: FontType, andSizeType sizeType: FontSizeType) -> Font {
72 | var fontName = ""
73 | var fontOffset: CGFloat = 1
74 |
75 | switch type {
76 | case .default:
77 | fontName = UIFont.systemFont(ofSize: 1).fontName
78 | default:
79 | fontName = type.title
80 | }
81 |
82 | switch sizeType {
83 | case .smallest:
84 | fontOffset = -2
85 | case .small:
86 | fontOffset = -1
87 | case .default:
88 | fontOffset = 1
89 | case .large:
90 | fontOffset = 4
91 | case .largest:
92 | fontOffset = 6
93 | }
94 |
95 | return Font(fontName: fontName, offset: fontOffset)
96 | }
97 |
98 | static func set(fontType type: FontType, andSizeType sizeType: FontSizeType) {
99 | self.set(font: self.get(ofFontType: type, andSizeType: sizeType))
100 | }
101 |
102 | }
103 |
--------------------------------------------------------------------------------
/HackerNews/UserPostsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserPostsViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/5/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import AsyncDisplayKit
11 | import PromiseKit
12 | import XLPagerTabStrip
13 |
14 | class UserPostsViewController: StoriesViewController {
15 |
16 | // MARK: - Properties
17 |
18 | var user: String = ""
19 |
20 | var state: AlgoliaResponse?
21 |
22 | // MARK: - Lifecycle
23 |
24 | init(withUser user: String) {
25 | self.user = user
26 |
27 | super.init()
28 | }
29 |
30 | required init?(coder aDecoder: NSCoder) {
31 | super.init(coder: aDecoder)
32 | }
33 |
34 | // MARK: - Setup Helpers
35 |
36 | override func setup() {
37 | super.setup()
38 | self.setupNavigationItem()
39 |
40 | self.refresh()
41 | }
42 |
43 | func setupNavigationItem() {
44 | self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
45 | }
46 |
47 | // MARK: - Helpers
48 |
49 | override func refresh(refreshControl: UIRefreshControl? = nil) {
50 | DispatchQueue.default.async {
51 | self.state = nil
52 |
53 | // remove all existing items
54 | self.removeAllRows(completion: { (finished) in
55 | // load new items
56 | self.fetchMoreItems(completion: { (finished) in
57 | self.refreshControl.endRefreshing()
58 | self.isFetching = false
59 | })
60 | })
61 | }
62 | }
63 |
64 | override func fetchMoreItems(completion: ((Bool) -> ())? = nil) {
65 | guard !self.isFetching else {
66 | completion?(true)
67 | return;
68 | }
69 |
70 | var page = 0
71 |
72 | if let state = self.state {
73 | if state.page + 1 >= state.pageCount {
74 | completion?(true)
75 | return;
76 | }
77 |
78 | page = state.page + 1
79 | }
80 |
81 | self.isFetching = true
82 |
83 | HNAPI.userPosts(id: self.user, page: page)
84 | .then { result -> Promise in
85 | self.state = result
86 |
87 | return ItemProvider.add(items: result.hits)
88 | .then { Void -> Void in
89 | self.append(items: result.hits, completion: completion)
90 | }
91 | }.catch { error in
92 | print(error) // TODO: - Handle this
93 | completion?(false)
94 | }
95 | }
96 |
97 | }
98 |
99 | // MARK: - IndicatorInfoProvider
100 |
101 | extension UserPostsViewController: IndicatorInfoProvider {
102 |
103 | func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
104 | return IndicatorInfo(title: "Posts")
105 | }
106 |
107 | }
108 |
109 | // MARK: - ASTableView
110 |
111 | extension UserPostsViewController {
112 |
113 | override func shouldBatchFetch(for tableView: ASTableView) -> Bool {
114 | return true
115 | }
116 |
117 | }
118 |
--------------------------------------------------------------------------------
/HackerNews/WebViewNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebViewNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/23/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AsyncDisplayKit
11 |
12 | class WebViewNode: ASDisplayNode {
13 |
14 | // MARK: - Properties
15 |
16 | var webView = UIWebView()
17 | var webViewNode = ASDisplayNode()
18 | var storyBarNode: StoryBarNode!
19 |
20 | var delegate: UIWebViewDelegate? {
21 | set {
22 | self.webView.delegate = newValue
23 | }
24 | get {
25 | return self.webView.delegate
26 | }
27 | }
28 |
29 | var storyBarDelegate: StoryBarDelegate? {
30 | set {
31 | self.storyBarNode?.delegate = newValue
32 | }
33 | get {
34 | return self.storyBarNode?.delegate
35 | }
36 | }
37 |
38 | // MARK: - Inits
39 |
40 | init(withItem item: Item) {
41 | super.init()
42 |
43 | self.commonInit()
44 |
45 | // story bar node
46 | self.storyBarNode = StoryBarNode(withItem: item)
47 | }
48 |
49 | override init() {
50 | super.init()
51 |
52 | self.commonInit()
53 | }
54 |
55 | func commonInit() {
56 | self.automaticallyManagesSubnodes = true
57 | self.autoresizingMask = [UIViewAutoresizing.flexibleWidth, UIViewAutoresizing.flexibleHeight]
58 |
59 | // web view
60 | self.webViewNode = ASDisplayNode { () -> UIView in
61 | self.webView.scalesPageToFit = true
62 | self.webView.autoresizingMask = [UIViewAutoresizing.flexibleWidth, UIViewAutoresizing.flexibleHeight]
63 | return self.webView
64 | }
65 |
66 | self.setupTheme()
67 | }
68 |
69 | // MARK: - Helpers
70 |
71 | func setupTheme() {
72 | self.backgroundColor = UIColor.clear
73 | }
74 |
75 | // MARK: - Layout
76 |
77 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
78 |
79 | // story bar
80 | self.storyBarNode?.style.height = .init(unit: .points, value: HNDimensions.itemBar.height)
81 |
82 | // web view
83 | if self.storyBarNode != nil {
84 | self.webViewNode.style.maxSize = CGSize(width: constrainedSize.max.width, height: constrainedSize.max.height - HNDimensions.itemBar.height)
85 | } else {
86 | self.webViewNode.style.maxSize = CGSize(width: constrainedSize.max.width, height: constrainedSize.max.height)
87 | }
88 |
89 | let mainStack = ASStackLayoutSpec(
90 | direction: .vertical,
91 | spacing: 0,
92 | justifyContent: .spaceAround,
93 | alignItems: .stretch,
94 | children: [self.webViewNode])
95 | mainStack.style.width = .init(unit: .fraction, value: 1)
96 | mainStack.style.height = .init(unit: .fraction, value: 1)
97 |
98 | if self.storyBarNode != nil {
99 | mainStack.children?.append(self.storyBarNode)
100 | }
101 |
102 | let insetStack = ASInsetLayoutSpec(
103 | insets: UIEdgeInsetsMake(0, 0, 0, 0),
104 | child: mainStack)
105 |
106 | return insetStack
107 | }
108 |
109 | }
110 |
--------------------------------------------------------------------------------
/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - Alamofire (4.4.0)
3 | - AMScrollingNavbar (3.3.3)
4 | - Firebase/Core (4.0.0):
5 | - FirebaseAnalytics (= 4.0.0)
6 | - FirebaseCore (= 4.0.0)
7 | - Firebase/Database (4.0.0):
8 | - Firebase/Core
9 | - FirebaseDatabase (= 4.0.0)
10 | - FirebaseAnalytics (4.0.0):
11 | - FirebaseCore (~> 4.0)
12 | - FirebaseInstanceID (~> 2.0)
13 | - GoogleToolboxForMac/NSData+zlib (~> 2.1)
14 | - FirebaseCore (4.0.0):
15 | - GoogleToolboxForMac/NSData+zlib (~> 2.1)
16 | - FirebaseDatabase (4.0.0):
17 | - FirebaseAnalytics (~> 4.0)
18 | - FirebaseInstanceID (2.0.0):
19 | - FirebaseCore (~> 4.0)
20 | - GoogleToolboxForMac/Defines (2.1.1)
21 | - GoogleToolboxForMac/NSData+zlib (2.1.1):
22 | - GoogleToolboxForMac/Defines (= 2.1.1)
23 | - MBProgressHUD (1.0.0)
24 | - NSDate+TimeAgo (1.0.6)
25 | - PINCache (3.0.1-beta.4):
26 | - PINCache/Arc-exception-safe (= 3.0.1-beta.4)
27 | - PINCache/Core (= 3.0.1-beta.4)
28 | - PINCache/Arc-exception-safe (3.0.1-beta.4):
29 | - PINCache/Core
30 | - PINCache/Core (3.0.1-beta.4):
31 | - PINOperation (= 1.0.3)
32 | - PINOperation (1.0.3)
33 | - PINRemoteImage/Core (3.0.0-beta.9):
34 | - PINOperation
35 | - PINRemoteImage/iOS (3.0.0-beta.9):
36 | - PINRemoteImage/Core
37 | - PINRemoteImage/PINCache (3.0.0-beta.9):
38 | - PINCache (= 3.0.1-beta.4)
39 | - PINRemoteImage/Core
40 | - PromiseKit (4.1.7):
41 | - PromiseKit/Foundation (= 4.1.7)
42 | - PromiseKit/QuartzCore (= 4.1.7)
43 | - PromiseKit/UIKit (= 4.1.7)
44 | - PromiseKit/CorePromise (4.1.7)
45 | - PromiseKit/Foundation (4.1.7):
46 | - PromiseKit/CorePromise
47 | - PromiseKit/QuartzCore (4.1.7):
48 | - PromiseKit/CorePromise
49 | - PromiseKit/UIKit (4.1.7):
50 | - PromiseKit/CorePromise
51 | - Texture (2.3.2):
52 | - Texture/PINRemoteImage (= 2.3.2)
53 | - Texture/Core (2.3.2)
54 | - Texture/PINRemoteImage (2.3.2):
55 | - PINRemoteImage/iOS (= 3.0.0-beta.9)
56 | - PINRemoteImage/PINCache
57 | - Texture/Core
58 | - XLPagerTabStrip (6.0.0)
59 |
60 | DEPENDENCIES:
61 | - Alamofire (~> 4.0)
62 | - AMScrollingNavbar
63 | - Firebase/Database
64 | - MBProgressHUD
65 | - NSDate+TimeAgo
66 | - PINCache
67 | - PromiseKit (~> 4.1.7)
68 | - Texture
69 | - XLPagerTabStrip (~> 6.0)
70 |
71 | SPEC CHECKSUMS:
72 | Alamofire: dc44b1600b800eb63da6a19039a0083d62a6a62d
73 | AMScrollingNavbar: 6a2c01a912b96aa36581268759f18d85b3d1575e
74 | Firebase: 284eea779b73fdff309791817da7c68bff8dd572
75 | FirebaseAnalytics: 6f08e746f7d66f5452931bc2e822b5df9c66b64a
76 | FirebaseCore: 85ad466044c2f013cdb167f85d426d15b128114a
77 | FirebaseDatabase: d829b3a8c3e2ac7a16773c5df226966b0805dfc2
78 | FirebaseInstanceID: 9fbf536668f4d3f0880e7438456dabd1376e294b
79 | GoogleToolboxForMac: 8e329f1b599f2512c6b10676d45736bcc2cbbeb0
80 | MBProgressHUD: 4890f671c94e8a0f3cf959aa731e9de2f036d71a
81 | NSDate+TimeAgo: 35601c619b2d59290055e4fe76e61d97677a2360
82 | PINCache: a89c8f609232afac77128557bd12c861844423d7
83 | PINOperation: ac23db44796d4a564ecf9b5ea7163510f579b71d
84 | PINRemoteImage: e4e02e15a6f96cedbcef2abf1c9c1228dc9897d7
85 | PromiseKit: 779f2e41faf62d854e7593026ddbcb0bb5c5002d
86 | Texture: 18664254419f35de27af76399b3b265cbad5740a
87 | XLPagerTabStrip: c46e802a8dcac29edbcea573de8fe920e052455b
88 |
89 | PODFILE CHECKSUM: 1dcabce92461c70b8e51f1b5dfb73cd33bed31f0
90 |
91 | COCOAPODS: 1.2.1
92 |
--------------------------------------------------------------------------------
/HackerNews/User.swift:
--------------------------------------------------------------------------------
1 | //
2 | // User.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/29/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class User: NSObject, NSCoding {
12 |
13 | // MARK: - Properties
14 |
15 | var id: String = ""
16 | var about: String = ""
17 | var created: TimeInterval = 0
18 | var karma: Int = 0
19 |
20 | var posts: [Item] = []
21 | var comments: [Item] = []
22 |
23 | static var sharedInstance: User = {
24 | return ItemProvider.currentUser()
25 | }()
26 |
27 | // MARK: - Inits
28 |
29 | override init() {
30 | super.init()
31 |
32 | self.setData(withJSON: [:])
33 | }
34 |
35 | init(withJSON json: [String: AnyObject]) {
36 | super.init()
37 |
38 | self.setData(withJSON: json)
39 | }
40 |
41 | init(username: String) {
42 | super.init()
43 |
44 | self.setData(withJSON: [:])
45 |
46 | self.id = username
47 | }
48 |
49 | func setData(withJSON json: [String: AnyObject]) {
50 | self.id = json["id"] as? String ?? self.id
51 | self.about = json["about"] as? String ?? self.about
52 | self.created = json["created"] as? TimeInterval ?? self.created
53 | self.karma = json["karma"] as? Int ?? self.karma
54 |
55 | self.about = self.about.stringByDecodingXMLEntities().stringByRemovingHTMLTags()
56 | }
57 |
58 | // MARK: - Coder
59 |
60 | required public init?(coder aDecoder: NSCoder) {
61 | super.init()
62 |
63 | self.id = aDecoder.decodeObject(forKey: "id") as? String ?? self.id
64 | self.about = aDecoder.decodeObject(forKey: "about") as? String ?? self.about
65 | self.created = aDecoder.decodeDouble(forKey: "created")
66 | self.karma = aDecoder.decodeInteger(forKey: "karma")
67 |
68 | self.posts = aDecoder.decodeObject(forKey: "posts") as? [Item] ?? self.posts
69 | self.comments = aDecoder.decodeObject(forKey: "comments") as? [Item] ?? self.comments
70 | }
71 |
72 | public func encode(with aCoder: NSCoder) {
73 | aCoder.encode(self.id, forKey: "id")
74 | aCoder.encode(self.about, forKey: "about")
75 | aCoder.encode(self.created, forKey: "created")
76 | aCoder.encode(self.karma, forKey: "karma")
77 |
78 | aCoder.encode(self.posts, forKey: "posts")
79 | aCoder.encode(self.comments, forKey: "comments")
80 | }
81 |
82 | // MARK: - Helpers
83 |
84 | static func userWebURL(user: String) -> String {
85 | return "https://news.ycombinator.com/user?id=\(user)"
86 | }
87 |
88 | func isLoggedIn() -> Bool {
89 | if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: Constants.kHackerNewsURL)!) {
90 | for cookie in cookies {
91 | if cookie.name == "user" && !cookie.name.isEmpty {
92 | return true
93 | }
94 | }
95 | }
96 |
97 | return false
98 | }
99 |
100 | func logout() {
101 | if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: Constants.kHackerNewsURL)!) {
102 | for cookie in cookies {
103 | HTTPCookieStorage.shared.deleteCookie(cookie)
104 | }
105 | }
106 | User.sharedInstance = User()
107 | ItemProvider.set(currentUser: User.sharedInstance)
108 | ItemProvider.clear(toDate: Date())
109 | }
110 |
111 | }
112 |
--------------------------------------------------------------------------------
/HackerNews/OptionsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OptionsViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/14/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class OptionsViewController: BaseViewController {
12 |
13 | // MARK: - IBOutlets
14 |
15 | var tableView: OptionSelectTableView!
16 | var optionsView = OptionSelectView()
17 |
18 | // MARK: - Properties
19 |
20 | var delegate: OptionSelectViewDelegate?
21 |
22 | // MARK: - Lifecycle
23 |
24 | init() {
25 | super.init(node: self.optionsView)
26 |
27 | self.tableView = self.optionsView.tableView
28 |
29 | self.setupTransition()
30 | }
31 |
32 | init(delegate: OptionSelectViewDelegate) {
33 | super.init(node: self.optionsView)
34 |
35 | self.tableView = self.optionsView.tableView
36 | self.delegate = delegate
37 |
38 | self.setupTransition()
39 | }
40 |
41 | required init?(coder aDecoder: NSCoder) {
42 | super.init(coder: aDecoder)
43 |
44 | self.setupTransition()
45 | }
46 |
47 | override func viewDidLoad() {
48 | super.viewDidLoad()
49 |
50 | self.setup()
51 | }
52 |
53 | // MARK: - Setup Helpers
54 |
55 | func setup() {
56 | self.setupTableView()
57 | self.setupTheme()
58 | }
59 |
60 | func setupTableView() {
61 | self.tableView.dataSource = self
62 | self.tableView.delegate = self
63 | }
64 |
65 | func setupTransition() {
66 | self.modalPresentationStyle = .overCurrentContext
67 | self.modalTransitionStyle = .crossDissolve
68 | }
69 |
70 | // MARK: - Helpers
71 |
72 | func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
73 | self.dismiss(animated: animated, completion: completion)
74 | }
75 |
76 | override func touchesBegan(_ touches: Set, with event: UIEvent?) {
77 | self.hide()
78 | }
79 |
80 | }
81 |
82 | // MARK: - ASTableDataSource, ASTableDelegate
83 |
84 | extension OptionsViewController: ASTableDataSource, ASTableDelegate {
85 |
86 | func numberOfSections(in tableView: UITableView) -> Int {
87 | return self.delegate?.numberOfSections(in: self) ?? 0
88 | }
89 |
90 | func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
91 | return self.delegate?.optionSelectView(optionSelectView: self, viewForHeaderInSection: section)
92 | }
93 |
94 | func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
95 | return self.delegate?.optionSelectView(optionSelectView: self, heightForHeaderInSection: section) ?? 0
96 | }
97 |
98 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
99 | return (self.delegate?.optionSelectView(optionSelectView: self, numberOfItemsInSection: section) ?? 0)
100 | }
101 |
102 | func tableView(_ tableView: ASTableView, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
103 | return self.delegate?.optionSelectView(optionSelectView: self, nodeBlockForRowAt: indexPath) ?? {
104 | return ASCellNode()
105 | }
106 | }
107 |
108 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
109 | self.tableView.deselectRow(at: indexPath, animated: true)
110 |
111 | self.hide(animated: true) { [weak self] in
112 | if let weakSelf = self {
113 | weakSelf.delegate?.optionSelectView(optionSelectView: weakSelf, didSelectItemAtIndexPath: indexPath)
114 | }
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/HackerNews/FirebaseAPI.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FirebaseAPI.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/21/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Firebase
11 | import PromiseKit
12 |
13 | class FirebaseAPI {
14 |
15 | static var ref: DatabaseReference = {
16 | return Database.database().reference(fromURL: Constants.kFirebaseURL)
17 | }()
18 |
19 | static var topstoriesRef: DatabaseReference {
20 | return ref.child("topstories")
21 | }
22 |
23 | static var newstoriesRef: DatabaseReference {
24 | return ref.child("newstories")
25 | }
26 |
27 | static var beststoriesRef: DatabaseReference {
28 | return ref.child("beststories")
29 | }
30 |
31 | static var askstoriesRef: DatabaseReference {
32 | return ref.child("askstories")
33 | }
34 |
35 | static var showstoriesRef: DatabaseReference {
36 | return ref.child("showstories")
37 | }
38 |
39 | static var jobstoriesRef: DatabaseReference {
40 | return ref.child("jobstories")
41 | }
42 |
43 | static var itemRef: DatabaseReference {
44 | return ref.child("item")
45 | }
46 |
47 | static var userRef: DatabaseReference {
48 | return ref.child("user")
49 | }
50 |
51 | static let FirebaseAPIQueue = DispatchQueue(label: "FirebaseAPIQueue")
52 |
53 | static func item(id: Int) -> Promise
- {
54 | return Promise.init(resolvers: { (fullfill, reject) in
55 | FirebaseAPIQueue.async {
56 | self.itemRef.child("\(id)").observeSingleEvent(of: .value, with: { (snapshot) in
57 | FirebaseAPIQueue.async {
58 | if let json = snapshot.value as? [String: AnyObject] {
59 | fullfill(Item(withFirebaseJSON: json))
60 | return;
61 | }
62 | reject(NSError.from(snapshot: snapshot))
63 | }
64 | }) { (error) in
65 | reject(error)
66 | }
67 | }
68 | })
69 | }
70 |
71 | static func user(id: String) -> Promise {
72 | return Promise.init(resolvers: { (fullfill, reject) in
73 | FirebaseAPIQueue.async {
74 | self.userRef.child("\(id)").observeSingleEvent(of: .value, with: { (snapshot) in
75 | FirebaseAPIQueue.async {
76 | if let json = snapshot.value as? [String: AnyObject] {
77 | fullfill(User(withJSON: json))
78 | return;
79 | }
80 | reject(NSError.from(snapshot: snapshot))
81 | }
82 | }) { (error) in
83 | reject(error)
84 | }
85 | }
86 | })
87 | }
88 |
89 | static func stories(ofType type: StoryType) -> Promise<[Int]> {
90 | return Promise.init(resolvers: { (fullfill, reject) in
91 | FirebaseAPIQueue.async {
92 | self.ref.child(type.value).observeSingleEvent(of: .value, with: { (snapshot) in
93 | FirebaseAPIQueue.async {
94 | if let stories = snapshot.value as? [Int] {
95 | fullfill(stories)
96 | return;
97 | }
98 | reject(NSError.from(snapshot: snapshot))
99 | }
100 | }) { (error) in
101 | reject(error)
102 | }
103 | }
104 | })
105 | }
106 |
107 | }
108 |
--------------------------------------------------------------------------------
/HackerNews/AuthRequiredNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthNeededNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/12/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class AuthRequiredNode: ASDisplayNode {
12 |
13 | // MARK: - Properties
14 |
15 | var titleTextNode = ASTextNode()
16 | var loginButton = FullButtonNode()
17 | var signupButton = FullButtonNode()
18 |
19 | weak var delegate: AuthRequiredNodeDelegate?
20 |
21 | // MARK: - Inits
22 |
23 | override init() {
24 | super.init()
25 |
26 | self.automaticallyManagesSubnodes = true
27 |
28 | // title
29 | self.titleTextNode.attributedText = NSAttributedString(
30 | string: "kAuthRequiredTitle".localized,
31 | attributes: Styles.auth.title
32 | )
33 |
34 | // login
35 | self.loginButton.set(title: "kLoginButton".localized)
36 | self.loginButton.isUserInteractionEnabled = true
37 | self.loginButton.addTarget(self, action: #selector(self.loginButtonPressed), forControlEvents: .touchUpInside)
38 |
39 | // signup
40 | self.signupButton.set(title: "kSignupButton".localized)
41 | self.signupButton.isUserInteractionEnabled = true
42 | self.signupButton.addTarget(self, action: #selector(self.signupButtonPressed), forControlEvents: .touchUpInside)
43 |
44 | self.setupTheme()
45 | }
46 |
47 | // MARK: - Layout
48 |
49 | override func layout() {
50 | super.layout()
51 |
52 | self.titleTextNode.position = CGPoint(x: self.calculatedSize.width / 2, y: self.calculatedSize.height / 2 - self.loginButton.calculatedSize.height / 2)
53 | }
54 |
55 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
56 |
57 | let bottomStack = ASStackLayoutSpec(
58 | direction: .horizontal,
59 | spacing: HNDimensions.padding / 2,
60 | justifyContent: .spaceBetween,
61 | alignItems: .center,
62 | children: [self.loginButton, self.signupButton])
63 | bottomStack.style.width = .init(unit: .fraction, value: 1)
64 |
65 | self.loginButton.style.flexGrow = 0.5
66 | self.signupButton.style.flexGrow = 0.5
67 |
68 | self.loginButton.contentEdgeInsets = UIEdgeInsetsMake(
69 | HNDimensions.padding, 0,
70 | HNDimensions.padding, 0)
71 | self.signupButton.contentEdgeInsets = UIEdgeInsetsMake(
72 | HNDimensions.padding, 0,
73 | HNDimensions.padding, 0)
74 |
75 | let titleSpec = ASCenterLayoutSpec(
76 | horizontalPosition: .center,
77 | verticalPosition: .center,
78 | sizingOption: .minimumSize,
79 | child: self.titleTextNode)
80 |
81 | let mainSpec = ASStackLayoutSpec(
82 | direction: .vertical,
83 | spacing: 0,
84 | justifyContent: .end,
85 | alignItems: .center,
86 | children: [titleSpec, bottomStack])
87 |
88 | let insetSpec = ASInsetLayoutSpec(
89 | insets: UIEdgeInsetsMake(
90 | HNDimensions.padding,
91 | HNDimensions.padding,
92 | HNDimensions.padding,
93 | HNDimensions.padding),
94 | child: mainSpec)
95 |
96 | return insetSpec
97 | }
98 |
99 | // MARK: - IBActions
100 |
101 | func loginButtonPressed() {
102 | self.delegate?.authRequiredNode(didPressLoginButton: self.loginButton)
103 | }
104 |
105 | func signupButtonPressed() {
106 | self.delegate?.authRequiredNode(didPressSignupButton: self.signupButton)
107 | }
108 |
109 | }
110 |
--------------------------------------------------------------------------------
/HackerNews/ItemProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Model.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/10/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import PromiseKit
11 | import PINCache
12 |
13 | class ItemProvider {
14 |
15 | // MARK: - Read items
16 |
17 | static var readItems: [Int] = {
18 | return PINCache.shared.object(forKey: "readItems") as? [Int] ?? []
19 | }()
20 |
21 | static func read(itemWithId id: Int) {
22 | // if we already read the item
23 | if let index = self.readItems.index(of: id) {
24 | // remove it
25 | self.readItems.remove(at: index)
26 | }
27 | // add the item to the front
28 | self.readItems.insert(id, at: 0)
29 |
30 | // save the list in cache
31 | PINCache.shared.setObject(self.readItems as NSCoding, forKey: "readItems", block: nil)
32 | }
33 |
34 | static func didRead(itemWithId id: Int) -> Bool {
35 | return self.readItems.contains(id)
36 | }
37 |
38 | // MARK: - Stories
39 |
40 | static func stories(ofType type: StoryType) -> [Int] {
41 | return PINCache.shared.object(forKey: type.value) as? [Int] ?? []
42 | }
43 |
44 | static func set(stories: [Int], forType type: StoryType) {
45 | PINCache.shared.setObject(stories as NSCoding, forKey: type.value)
46 | }
47 |
48 | // MARK: - Items
49 |
50 | static func add(item: Item) -> Promise {
51 | return Promise(resolvers: { (fulfill, reject) in
52 | PINCache.shared.setObject(item, forKey: "\(item.id)") { (cache, key, object) in
53 | fulfill()
54 | }
55 | })
56 | }
57 |
58 | static func add(items: [Item]) -> Promise {
59 | return Promise(resolvers: { (fulfill, reject) in
60 | var calls: [Promise] = []
61 |
62 | for i in 0.. Item? {
73 | return PINCache.shared.object(forKey: "\(id)") as? Item
74 | }
75 |
76 | // MARK: - Users
77 |
78 | static func add(user: User) -> Promise {
79 | return Promise(resolvers: { (fulfill, reject) in
80 | PINCache.shared.setObject(user, forKey: "user:\(user.id)") { (cache, key, object) in
81 | fulfill()
82 | }
83 | })
84 | }
85 |
86 | static func user(withId id: String) -> User? {
87 | return PINCache.shared.object(forKey: "user:\(id)") as? User
88 | }
89 |
90 | // MARK: - Current user
91 |
92 | static func set(currentUser user: User) {
93 | PINCache.shared.setObject(user, forKey: "currentUser")
94 | }
95 |
96 | static func currentUser() -> User {
97 | return PINCache.shared.object(forKey: "currentUser") as? User ?? User()
98 | }
99 |
100 | // MARK: - Voted items
101 |
102 | static func vote(item: Item) {
103 | PINCache.shared.setObject(true as NSCoding, forKey: "v:\(item.id)")
104 | }
105 |
106 | static func didVote(item: Item) -> Bool {
107 | return PINCache.shared.containsObject(forKey: "v:\(item.id)")
108 | }
109 |
110 | static func removeVote(item: Item) {
111 | PINCache.shared.removeObject(forKey: "v:\(item.id)")
112 | }
113 |
114 | // MARK: - Helpers
115 |
116 | static func clear(toDate date: Date) {
117 | PINCache.shared.trim(to: date)
118 | self.readItems.removeAll()
119 | NotificationCenter.default.post(Constants.kClearCacheNotification)
120 | }
121 |
122 | }
123 |
--------------------------------------------------------------------------------
/HackerNews/UserAboutViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserAboutViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/6/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import AsyncDisplayKit
11 | import XLPagerTabStrip
12 |
13 | class UserAboutViewController: BaseViewController {
14 |
15 | // MARK: - IBOutlets
16 |
17 | var tableView = HNTableNode()
18 |
19 | // MARK: - Properties
20 |
21 | var refreshControl: UIRefreshControl = UIRefreshControl()
22 |
23 | var userId: String = ""
24 | var user: User = User()
25 |
26 | // MARK: - Lifecycle
27 |
28 | init(withUser user: String) {
29 | self.userId = user
30 |
31 | super.init(node: self.tableView)
32 | }
33 |
34 | required init?(coder aDecoder: NSCoder) {
35 | super.init(coder: aDecoder)
36 | }
37 |
38 | override func viewDidLoad() {
39 | super.viewDidLoad()
40 |
41 | self.setup()
42 | }
43 |
44 | // MARK: - Setup Helpers
45 |
46 | func setup() {
47 | self.user = ItemProvider.user(withId: self.userId) ?? self.user
48 |
49 | self.setupTableView()
50 | self.refresh()
51 | }
52 |
53 | func setupTableView() {
54 | self.tableView.dataSource = self
55 | self.tableView.delegate = self
56 |
57 | self.refreshControl.addTarget(self, action: #selector(self.refresh(refreshControl:)), for: .valueChanged)
58 | self.tableView.view.addSubview(self.refreshControl)
59 | }
60 |
61 | // MARK: - Helpers
62 |
63 | func refresh(refreshControl: UIRefreshControl? = nil) {
64 | DispatchQueue.default.async {
65 | HNAPI.user(id: self.userId)
66 | .then { result -> Void in
67 | // save the item
68 | self.user = result
69 | let _ = ItemProvider.add(user: self.user)
70 |
71 | self.tableView.reloadData()
72 |
73 | }.always {
74 | self.refreshControl.endRefreshing()
75 | }.catch { error in
76 | print(error) // TODO: - Handle this
77 | }
78 | }
79 | }
80 |
81 | }
82 |
83 | // MARK: - IndicatorInfoProvider
84 |
85 | extension UserAboutViewController: IndicatorInfoProvider {
86 |
87 | func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
88 | return IndicatorInfo(title: "About")
89 | }
90 |
91 | }
92 |
93 | // MARK: - ASTableDataSource, ASTableDelegate
94 |
95 | extension UserAboutViewController: ASTableDataSource, ASTableDelegate {
96 |
97 | func numberOfSections(in tableView: UITableView) -> Int {
98 | return 1
99 | }
100 |
101 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
102 | return self.user.about.isEmpty ? 1 : 2
103 | }
104 |
105 | func tableView(_ tableView: ASTableView, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
106 | return {
107 | switch indexPath.row {
108 | case 0:
109 | return UserAboutNode(withUser: self.user)
110 | default:
111 | let node = UserAboutBioNode(withUser: self.user)
112 | node.delegate = self
113 | return node
114 | }
115 | }
116 | }
117 |
118 | func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
119 | return true
120 | }
121 |
122 | }
123 |
124 | // MARK: - UserAboutBioNodeDelegate
125 |
126 | extension UserAboutViewController: UserAboutBioNodeDelegate {
127 |
128 | func userAboutBioNode(_ userAboutBioNode: UserAboutBioNode, didPressLink link: String, withSender sender: ASDisplayNode) {
129 | self.navController.push(viewController: WebViewController(link: link))
130 | }
131 |
132 | }
133 |
--------------------------------------------------------------------------------
/HackerNews/SearchStoriesViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchPostsViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/6/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import PromiseKit
11 | import AsyncDisplayKit
12 | import XLPagerTabStrip
13 |
14 | class SearchStoriesViewController: StoriesViewController, HNSearchProtocol {
15 |
16 | // MARK: - Properties
17 |
18 | var query: String = ""
19 | var sortType: SearchSortType = .relevance
20 | var timeRangeType: SearchTimeRangeType = .all
21 |
22 | var state: AlgoliaResponse?
23 |
24 | // MARK: - Lifecycle
25 |
26 | deinit {
27 | NotificationCenter.default.removeObserver(self)
28 | }
29 |
30 | // MARK: - Setup Helpers
31 |
32 | override func setup() {
33 | super.setup()
34 |
35 | self.setupNotificationListener()
36 | }
37 |
38 | func setupNotificationListener() {
39 | NotificationCenter.default.addObserver(self, selector: #selector(self.onRefreshNotificationReceived(notification:)), name: Constants.kSearchNotification.name, object: nil)
40 | }
41 |
42 | // MARK: - Helpers
43 |
44 | func onRefreshNotificationReceived(notification: Notification) {
45 | // sort type
46 | if let sortType = notification.userInfo?["sort"] as? SearchSortType {
47 | self.sortType = sortType
48 | }
49 |
50 | // time range type
51 | if let timeRangeType = notification.userInfo?["timeRange"] as? SearchTimeRangeType {
52 | self.timeRangeType = timeRangeType
53 | }
54 |
55 | // query
56 | if let query = notification.userInfo?["query"] as? String {
57 | self.query = query
58 |
59 | self.refresh(refreshControl: nil)
60 | }
61 | }
62 |
63 | override func refresh(refreshControl: UIRefreshControl? = nil) {
64 | DispatchQueue.default.async {
65 | self.state = nil
66 |
67 | // remove all existing items
68 | self.removeAllRows(completion: { (finished) in
69 | // load new items
70 | self.fetchMoreItems(completion: { (finished) in
71 | self.refreshControl.endRefreshing()
72 | self.isFetching = false
73 | })
74 | })
75 | }
76 | }
77 |
78 | override func fetchMoreItems(completion: ((Bool) -> ())? = nil) {
79 | guard !self.isFetching && !self.query.isEmpty else {
80 | completion?(true)
81 | return;
82 | }
83 |
84 | var page = 0
85 |
86 | if let state = self.state {
87 | if state.page + 1 >= state.pageCount {
88 | completion?(true)
89 | return;
90 | }
91 |
92 | page = state.page + 1
93 | }
94 |
95 | self.isFetching = true
96 |
97 | HNAPI.searchStories(query: self.query, timeRange: self.timeRangeType, sortBy: self.sortType, page: page)
98 | .then { result -> Promise in
99 | self.state = result
100 |
101 | return ItemProvider.add(items: result.hits)
102 | .then { Void -> Void in
103 | self.append(items: result.hits, completion: completion)
104 | }
105 | }.catch { error in
106 | print(error) // TODO: - Handle this
107 | completion?(false)
108 | }
109 | }
110 |
111 | }
112 |
113 | // MARK: - IndicatorInfoProvider
114 |
115 | extension SearchStoriesViewController: IndicatorInfoProvider {
116 |
117 | func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
118 | return IndicatorInfo(title: "Stories")
119 | }
120 |
121 | }
122 |
123 | // MARK: - ASTableView
124 |
125 | extension SearchStoriesViewController {
126 |
127 | override func shouldBatchFetch(for tableView: ASTableView) -> Bool {
128 | return !self.query.isEmpty
129 | }
130 |
131 | }
132 |
--------------------------------------------------------------------------------
/HackerNews/TabBarController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabBarController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import AsyncDisplayKit
11 |
12 | class TabBarController: ASTabBarController {
13 |
14 | // MARK: - Lifecycle
15 |
16 | override func viewDidLoad() {
17 | super.viewDidLoad()
18 |
19 | self.setup()
20 | }
21 |
22 | // MARK: - Setup Helpers
23 |
24 | func setup() {
25 | self.setupTheme()
26 | self.setupViewControllers()
27 | self.setupTabBar()
28 | self.removeTabBarItemsText()
29 | self.setupDelegate()
30 | }
31 |
32 | func setupViewControllers() {
33 | self.viewControllers = [
34 | NavigationController(rootViewController: HomeViewController()),
35 | NavigationController(rootViewController: SearchViewController()),
36 | NavigationController(rootViewController: ProfileViewController()),
37 | NavigationController(rootViewController: SettingsViewController())
38 | ]
39 | }
40 |
41 | func setupTabBar() {
42 | if let viewControllers = self.viewControllers {
43 | if viewControllers.count > 0 {
44 | let icon = IonIcon.image(iconType: .ion_social_hackernews, color: Styles.tabBar.icon)
45 |
46 | viewControllers[0].tabBarItem = UITabBarItem(
47 | title: nil,
48 | image: icon,
49 | selectedImage: icon
50 | )
51 | }
52 | if viewControllers.count > 1 {
53 | let icon = IonIcon.image(iconType: .ion_search, color: Styles.tabBar.icon)
54 |
55 | viewControllers[1].tabBarItem = UITabBarItem(
56 | title: nil,
57 | image: icon,
58 | selectedImage: icon
59 | )
60 | }
61 | if viewControllers.count > 2 {
62 | let icon = IonIcon.image(iconType: .ion_person, color: Styles.tabBar.icon)
63 |
64 | viewControllers[2].tabBarItem = UITabBarItem(
65 | title: nil,
66 | image: icon,
67 | selectedImage: icon
68 | )
69 | }
70 | if viewControllers.count > 3 {
71 | let icon = IonIcon.image(iconType: .ion_ios_cog, color: Styles.tabBar.icon)
72 |
73 | viewControllers[3].tabBarItem = UITabBarItem(
74 | title: nil,
75 | image: icon,
76 | selectedImage: icon
77 | )
78 | }
79 | }
80 | }
81 |
82 | func setupDelegate() {
83 | self.delegate = self
84 | }
85 |
86 | // MARK: - Helpers
87 |
88 | func removeTabBarItemsText() {
89 | if let items = tabBar.items {
90 | for item in items {
91 | item.title = ""
92 | item.imageInsets = UIEdgeInsetsMake(6, 0, -6, 0);
93 | }
94 | }
95 | }
96 |
97 | }
98 |
99 | // MARK: - UITabBarDelegate
100 |
101 | extension TabBarController: UITabBarControllerDelegate {
102 |
103 | func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
104 | if let viewControllers = self.viewControllers {
105 | // home
106 | if tabBarController.selectedIndex == 0 {
107 | // make sure first view controller is a navigation controller
108 | ((viewControllers[safe: 0] as? NavigationController)?
109 | // make sure the first view controller in the navigation controller is home
110 | .viewControllers[safe: 0] as? HomeViewController)?
111 | // scroll to top
112 | .tableView.view.setContentOffset(CGPoint.zero, animated: true)
113 | }
114 | }
115 | return true
116 | }
117 |
118 | }
119 |
--------------------------------------------------------------------------------
/HackerNews/HNPostStoryViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HNPostStoryViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 6/5/17.
6 | // Copyright © 2017 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class HNPostStoryViewController: BaseViewController {
12 |
13 | // MARK: - IBOutlets
14 |
15 | var postStoryNode: HNPostStoryNode!
16 |
17 | // MARK: - Lifecycle
18 |
19 | init() {
20 | self.postStoryNode = HNPostStoryNode()
21 | super.init(node: self.postStoryNode)
22 | }
23 |
24 | required init?(coder aDecoder: NSCoder) {
25 | super.init(coder: aDecoder)
26 | }
27 |
28 | override func viewDidLoad() {
29 | super.viewDidLoad()
30 |
31 | // Do any additional setup after loading the view.
32 | self.setup()
33 | }
34 |
35 | // MARK: - Setup Helpers
36 |
37 | func setup() {
38 | self.setupTheme()
39 | self.setupNode()
40 | self.setupNavigationItem()
41 | }
42 |
43 | func setupNode() {
44 | self.postStoryNode.delegate = self
45 | self.postStoryNode.frame = self.view.bounds
46 | self.postStoryNode.autoresizingMask = [.flexibleWidth, .flexibleHeight]
47 | }
48 |
49 | func setupNavigationItem() {
50 | self.navigationItem.set(leftButtonIcon: .ion_close, target: self, action: #selector(self.close))
51 | }
52 |
53 | // MARK: - Helpers
54 |
55 | func validateData(title: String, url: String, text: String) -> Bool {
56 |
57 | // title must not be empty
58 | if title.characters.count < 1 {
59 | AlertBuilder()
60 | .set(message: "kErrorEmptyTitle".localized)
61 | .add(actionWithTitle: "Ok")
62 | .present(sender: self)
63 |
64 | return false
65 | }
66 |
67 | // we can't have both an url and a post text
68 | if url.characters.count > 0 && text.characters.count > 0 {
69 | AlertBuilder()
70 | .set(message: "kErrorUrlAndText".localized)
71 | .add(actionWithTitle: "Ok")
72 | .present(sender: self)
73 |
74 | return false
75 | }
76 |
77 | // we must have an url or a title
78 | if url.characters.count == 0 && text.characters.count == 0 {
79 | AlertBuilder()
80 | .set(message: "kErrorEmptyUrlAndText".localized)
81 | .add(actionWithTitle: "Ok")
82 | .present(sender: self)
83 |
84 | return false
85 | }
86 |
87 | // validate url
88 | if !url.isValidUrl() {
89 | AlertBuilder()
90 | .set(message: "kErrorInvalidUrl".localized)
91 | .add(actionWithTitle: "Ok")
92 | .present(sender: self)
93 |
94 | return false
95 | }
96 |
97 | return true
98 | }
99 |
100 | func close() {
101 | self.dismiss(animated: true, completion: nil)
102 | }
103 |
104 | }
105 |
106 | // MARK: - HNPostStoryDelegate
107 |
108 | extension HNPostStoryViewController: HNPostStoryDelegate {
109 |
110 | func postStory(didPressSubmitButton button: ASDisplayNode, title: String, url: String, text: String) {
111 |
112 | // make sure the data is valid
113 | if self.validateData(title: title, url: url, text: text) {
114 |
115 | // show the loader
116 | self.showLoader()
117 |
118 | // post the story
119 | HNAPI.postStory(title: title, url: url, text: text)
120 | .then { Void -> Void in
121 |
122 | // TODO: - Redirect to story? Do we get this in the response or headers?
123 | self.close()
124 |
125 | }.always {
126 | self.hideLoader()
127 | }.catch { error in
128 | AlertBuilder().set(title: "Error")
129 | .set(message: error.localizedDescription)
130 | .add(actionWithTitle: "OK")
131 | .present(sender: self)
132 | }
133 |
134 | }
135 | }
136 |
137 | func postStory(didPressCloseButton button: ASDisplayNode) {
138 | self.close()
139 | }
140 |
141 | }
142 |
--------------------------------------------------------------------------------
/HackerNews/HistoryViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HistoryViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/5/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import AsyncDisplayKit
11 | import PromiseKit
12 |
13 | class HistoryViewController: StoriesViewController {
14 |
15 | // MARK: - Lifecycle
16 |
17 | override func viewDidLoad() {
18 | super.viewDidLoad()
19 |
20 | self.setup()
21 | }
22 |
23 | override func viewWillAppear(_ animated: Bool) {
24 | super.viewWillAppear(animated)
25 |
26 | self.setupTabBar()
27 | self.navigationController(followScrollView: self.tableView.view, delay: 50)
28 | }
29 |
30 | override func viewWillDisappear(_ animated: Bool) {
31 | super.viewWillDisappear(animated)
32 |
33 | self.showNavbar(animated: false)
34 | }
35 |
36 | override func viewDidDisappear(_ animated: Bool) {
37 | super.viewDidDisappear(animated)
38 |
39 | self.stopFollowingScrollView()
40 | }
41 |
42 | deinit {
43 | NotificationCenter.default.removeObserver(self)
44 | }
45 |
46 | // MARK: - Setup Helpers
47 |
48 | override func setup() {
49 | super.setup()
50 |
51 | self.storyList = ItemProvider.readItems
52 |
53 | self.title = "History"
54 | self.setupNavigationItem()
55 | self.setupNotificationListener()
56 | }
57 |
58 | func setupNavigationItem() {
59 | self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
60 | }
61 |
62 | func setupTabBar() {
63 | self.navController.tabBarItem.title = nil
64 | }
65 |
66 | func setupNotificationListener() {
67 | NotificationCenter.default.addObserver(self, selector: #selector(self.onClearCacheNotificationReceived), name: Constants.kClearCacheNotification.name, object: nil)
68 | }
69 |
70 | // MARK: - Helpers
71 |
72 | func onClearCacheNotificationReceived() {
73 | self.storyList.removeAll()
74 | self.removeAllRows()
75 | }
76 |
77 | override func refresh(refreshControl: UIRefreshControl? = nil) {
78 | DispatchQueue.default.async {
79 |
80 | self.storyList = ItemProvider.readItems
81 |
82 | // remove all existing items
83 | self.removeAllRows(completion: { (finished) in
84 | // load new items
85 | self.fetchMoreItems(completion: { (finished) in
86 | self.isFetching = false
87 | self.refreshControl.endRefreshing()
88 | })
89 | })
90 | }
91 | }
92 |
93 | override func fetchMoreItems(completion: ((Bool) -> ())? = nil) {
94 | // make sure we have items in the list
95 | guard self.storyList.count > self.stories.count && !self.isFetching else {
96 | completion?(true)
97 | return;
98 | }
99 |
100 | self.isFetching = true
101 |
102 | // make sure we won't go out of bounds
103 | let maxItem = min(self.stories.count + Constants.kHomePreloadItemCount, self.storyList.count)
104 |
105 | var callStack: [Promise
- ] = []
106 |
107 | for i in self.stories.count.. Void in
123 | ItemProvider.add(items: result).then { () -> Void in
124 | // add all the items to the table
125 | self.append(items: result, completion: completion)
126 | }.catch { error in
127 | print(error)
128 | completion?(false)
129 | }
130 | }.catch { error in
131 | print(error) // TODO: - Handle this
132 | completion?(false)
133 | }
134 | }
135 |
136 | }
137 |
--------------------------------------------------------------------------------
/HackerNews/StoryNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoryNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/22/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AsyncDisplayKit
11 |
12 | /**
13 | * Story cell.
14 | */
15 | class StoryNode: ASCellNode {
16 |
17 | // MARK: - Properties
18 |
19 | /**
20 | * The poster of the story.
21 | */
22 | var posterNode = ASButtonNode()
23 |
24 | /**
25 | * The website of the story.
26 | */
27 | var siteNode = ASTextNode()
28 |
29 | /**
30 | * The title of the story.
31 | */
32 | var titleNode = ASTextNode()
33 |
34 | /**
35 | * The story bar node. Contains the story controls like share, comments, upvotes.
36 | */
37 | var storyBarNode: StoryBarNode!
38 |
39 | /**
40 | * Story node delegate.
41 | */
42 | weak var delegate: StoryNodeDelegate?
43 |
44 | // MARK: - Inits
45 |
46 | init(withItem item: Item, storyNodeDelegate delegate: StoryNodeDelegate? = nil, storyBarDelegate: StoryBarDelegate? = nil) {
47 | super.init()
48 |
49 | // save properties
50 | self.delegate = delegate
51 |
52 | self.selectionStyle = .none
53 |
54 | self.automaticallyManagesSubnodes = true
55 |
56 | // Poster button
57 | self.posterNode.setAttributedTitle(
58 | NSAttributedString(
59 | string: "\(item.by) • \(NSDate.init(timeIntervalSince1970: item.time).timeAgo() ?? "")",
60 | attributes: Styles.story.poster),
61 | for: UIControlState.normal
62 | )
63 | self.posterNode.isUserInteractionEnabled = true
64 | self.posterNode.addTarget(self, action: #selector(self.onPosterButtonPressed(_:)), forControlEvents: .touchUpInside)
65 |
66 | // Site button
67 | if !item.url.isEmpty {
68 | self.siteNode.attributedText = NSAttributedString(
69 | string: " • \(item.url.domainName())",
70 | attributes: Styles.story.website)
71 | self.siteNode.truncationMode = .byTruncatingTail
72 | self.siteNode.maximumNumberOfLines = 1
73 | self.siteNode.style.flexShrink = 1
74 | }
75 |
76 | // Title text
77 | self.titleNode.attributedText = NSAttributedString(
78 | string: item.title,
79 | attributes: ItemProvider.didRead(itemWithId: item.id) ? Styles.story.seenTitle : Styles.story.title)
80 |
81 | // Story Bar
82 | self.storyBarNode = StoryBarNode(withItem: item)
83 | self.storyBarNode.delegate = storyBarDelegate
84 |
85 | // setup the theme
86 | self.setupTheme()
87 | }
88 |
89 | // MARK: - Layout
90 |
91 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
92 |
93 | // story bar
94 | self.storyBarNode.style.width = .init(unit: .fraction, value: 1)
95 | self.storyBarNode.style.height = .init(unit: .points, value: HNDimensions.itemBar.height)
96 |
97 | // poster + site
98 | let headerStack = ASStackLayoutSpec(
99 | direction: .horizontal,
100 | spacing: 0,
101 | justifyContent: .start,
102 | alignItems: .center,
103 | children: [self.posterNode, self.siteNode])
104 |
105 | // header stack + title
106 | let contentStack = ASStackLayoutSpec(
107 | direction: .vertical,
108 | spacing: HNDimensions.padding / 4,
109 | justifyContent: .center,
110 | alignItems: .start,
111 | children: [headerStack, self.titleNode])
112 |
113 | let insetContentStack = ASInsetLayoutSpec(
114 | insets: UIEdgeInsetsMake(HNDimensions.padding / 2,
115 | HNDimensions.padding,
116 | 0,
117 | HNDimensions.padding
118 | ),
119 | child: contentStack)
120 |
121 | let mainStack = ASStackLayoutSpec(
122 | direction: .vertical,
123 | spacing: 0,
124 | justifyContent: .center,
125 | alignItems: .start,
126 | children: [insetContentStack, self.storyBarNode])
127 |
128 | return mainStack
129 | }
130 |
131 | // MARK: - IBActions
132 |
133 | func onPosterButtonPressed(_ sender: ASDisplayNode) {
134 | self.delegate?.storyNode?(self, didPressOnPosterButton: sender)
135 | }
136 |
137 | }
138 |
--------------------------------------------------------------------------------
/HackerNews/UserViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 11/5/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import XLPagerTabStrip
11 | import AsyncDisplayKit
12 |
13 | class UserViewController: HNPagerViewController {
14 |
15 | // MARK: - Properties
16 |
17 | var user: String = ""
18 |
19 | // MARK: - Lifecycle
20 |
21 | init(withUser user: String) {
22 | self.user = user
23 |
24 | super.init(nibName: nil, bundle: nil)
25 | }
26 |
27 | required init?(coder aDecoder: NSCoder) {
28 | super.init(coder: aDecoder)
29 | }
30 |
31 | override func viewDidLoad() {
32 | super.viewDidLoad()
33 |
34 | self.setup()
35 | }
36 |
37 | // MARK: - Setup Helpers
38 |
39 | func setup() {
40 | self.title = user
41 |
42 | self.setupNavigationItem()
43 | self.setupTheme()
44 | }
45 |
46 | func setupNavigationItem() {
47 | self.navigationItem.set(rightButtonIcon: .ion_more, target: self, action: #selector(self.onNavigationRightButtonPressed))
48 | self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
49 | }
50 |
51 | // MARK: - IBActions
52 |
53 | func onNavigationRightButtonPressed() {
54 | AppDelegate.tabBarController.present(OptionsViewController(delegate: self), animated: true, completion: nil)
55 | }
56 |
57 | // MARK: - PagerTabStripDataSource
58 |
59 | override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] {
60 | return [
61 | UserAboutViewController(withUser: self.user),
62 | UserPostsViewController(withUser: self.user),
63 | UserCommentsViewController(withUser: self.user)
64 | ]
65 | }
66 |
67 | }
68 |
69 | // MARK: - OptionSelectViewDelegate
70 |
71 | /**
72 | * There are 2 sections.
73 | * Cancel
74 | * Options
75 | */
76 | enum UserSectionType: Int {
77 | case options = 0
78 | case cancel = 1
79 |
80 | static var count: Int {
81 | return 2
82 | }
83 | }
84 |
85 | extension UserViewController: OptionSelectViewDelegate {
86 |
87 | func numberOfSections(in optionSelectView: OptionsViewController) -> Int {
88 | return UserSectionType.count
89 | }
90 |
91 | func optionSelectView(optionSelectView: OptionsViewController, numberOfItemsInSection section: Int) -> Int {
92 |
93 | guard let sectionType = UserSectionType(rawValue: section) else {
94 | assertionFailure("User section \(section) not found.")
95 | return -1
96 | }
97 |
98 | // check section
99 | switch sectionType {
100 |
101 | // cancel
102 | case .cancel:
103 | return 1
104 |
105 | // options
106 | case .options:
107 | return UserOptionsNavigationRight.values.count
108 | }
109 |
110 | }
111 |
112 | func optionSelectView(optionSelectView: OptionsViewController, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
113 | return {
114 |
115 | guard let sectionType = UserSectionType(rawValue: indexPath.section) else {
116 | assertionFailure("User section \(indexPath.section) not found.")
117 | return ASCellNode()
118 | }
119 |
120 | // check section
121 | switch sectionType {
122 |
123 | // cancel
124 | case .cancel:
125 | return HNOptionActionNode(optionType: GeneralType.cancel)
126 |
127 | // options
128 | case .options:
129 | return OptionSelectNode(
130 | optionType: UserOptionsNavigationRight.values[indexPath.row],
131 | selected: false
132 | )
133 |
134 | }
135 | }
136 | }
137 |
138 | func optionSelectView(optionSelectView: OptionsViewController, didSelectItemAtIndexPath indexPath: IndexPath) {
139 |
140 | guard let sectionType = UserSectionType(rawValue: indexPath.section) else {
141 | assertionFailure("User section \(indexPath.section) not found.")
142 | return;
143 | }
144 |
145 | // check section
146 | switch sectionType {
147 |
148 | // cancel
149 | case .cancel:
150 | return;
151 |
152 | // options
153 | case .options:
154 |
155 | let option = UserOptionsNavigationRight.values[indexPath.row]
156 |
157 | switch option {
158 | case .openInBrowser:
159 | Utils.openURL(urlString: User.userWebURL(user: self.user))
160 |
161 | }
162 | }
163 | }
164 |
165 | }
166 |
--------------------------------------------------------------------------------
/HackerNews/AuthViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginViewController.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/29/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import AsyncDisplayKit
11 |
12 | class AuthViewController: BaseViewController {
13 |
14 | // MARK: - IBOutlets
15 |
16 | var authNode: AuthNode!
17 |
18 | // MARK: - Properties
19 |
20 | var state: AuthState = .login {
21 | didSet {
22 | self.authNode.set(state: self.state)
23 | self.authNode.usernameTextField.becomeFirstResponder()
24 |
25 | self.title = self.state == .login ? "Login" : "Register"
26 | }
27 | }
28 |
29 | // MARK: - Lifecycle
30 |
31 | init(state: AuthState) {
32 | self.authNode = AuthNode(state: .login)
33 |
34 | self.state = state
35 |
36 | super.init(node: authNode)
37 |
38 | }
39 |
40 | required init?(coder aDecoder: NSCoder) {
41 | super.init(coder: aDecoder)
42 | }
43 |
44 | override func viewDidLoad() {
45 | super.viewDidLoad()
46 |
47 | // Do any additional setup after loading the view.
48 | self.setup()
49 | }
50 |
51 | // MARK: - Setup Helpers
52 |
53 | func setup() {
54 | self.setupTheme()
55 | self.setupAuthNode()
56 | self.setupNavigationItem()
57 |
58 | let state = self.state
59 | self.state = state
60 | }
61 |
62 | func setupAuthNode() {
63 | self.authNode.delegate = self
64 | self.authNode.frame = self.view.bounds
65 | self.authNode.autoresizingMask = [.flexibleWidth, .flexibleHeight]
66 | }
67 |
68 | func setupNavigationItem() {
69 | self.navigationItem.set(leftButtonIcon: .ion_close, target: self, action: #selector(self.close))
70 | }
71 |
72 | // MARK: - Helpers
73 |
74 | func validateLogin(username: String, password: String) -> Bool {
75 | // username must be between 2 and 15 characters
76 | if username.characters.count < 2 {
77 | return false
78 | }
79 | if username.characters.count > 15 {
80 | return false
81 | }
82 | // username can only contain text, digits, underscore and dashes
83 |
84 | // password must be > 8 characters
85 | if password.characters.count < 8 {
86 | return false
87 | }
88 | return true
89 | }
90 |
91 | func close() {
92 | self.dismiss(animated: true, completion: nil)
93 | }
94 |
95 | }
96 |
97 | // MARK: - AuthNodeDelegate
98 |
99 | extension AuthViewController: AuthNodeDelegate {
100 |
101 | func authNode(didPressSwitchToSignupButton button: ASDisplayNode) {
102 | self.state = .register
103 | }
104 |
105 | func authNode(didPressSwitchToLoginButton button: ASDisplayNode) {
106 | self.state = .login
107 | }
108 |
109 | func authNode(didPressSwitchToForgotPasswordButton button: ASDisplayNode) {
110 | Utils.openURL(urlString: Constants.kForgotPasswordURL)
111 | }
112 |
113 | func authNode(didPressSubmitButton button: ASDisplayNode, username: String, password: String) {
114 |
115 | if self.validateLogin(username: username, password: password) {
116 | self.showLoader()
117 |
118 | switch self.state {
119 | case .login:
120 |
121 | HNAPI.login(username: username, password: password)
122 | .then { Void -> Void in
123 |
124 | User.sharedInstance = User(username: username)
125 | ItemProvider.set(currentUser: User.sharedInstance)
126 |
127 | NotificationCenter.default.post(Constants.kLoginNotification)
128 |
129 | self.close()
130 |
131 | }.always {
132 | self.hideLoader()
133 | }.catch { error in
134 | AlertBuilder().set(title: "Error")
135 | .set(message: error.localizedDescription)
136 | .add(actionWithTitle: "OK")
137 | .present(sender: self)
138 | }
139 |
140 | case .register:
141 |
142 | HNAPI.register(username: username, password: password)
143 | .then { result -> Void in
144 |
145 | if result.matches(for: "captcha").count > 0 {
146 | self.state = .login
147 |
148 | AlertBuilder()
149 | .set(message: "kRegisterCapcha".localized)
150 | .add(actionWithTitle: "OK", handler: { _ in
151 | Utils.openURL(urlString: Constants.kLoginURL)
152 | })
153 | .present(sender: self)
154 |
155 | return;
156 | }
157 |
158 | User.sharedInstance = User(username: username)
159 | self.close()
160 |
161 | }.always {
162 | self.hideLoader()
163 | }.catch { error in
164 | AlertBuilder().set(title: "Error")
165 | .set(message: error.localizedDescription)
166 | .add(actionWithTitle: "OK")
167 | .present(sender: self)
168 | }
169 |
170 | }
171 |
172 | } else {
173 | AlertBuilder().set(title: "Error")
174 | .set(message: "kInvalidLogin".localized)
175 | .add(actionWithTitle: "OK")
176 | .present(sender: self)
177 | }
178 | }
179 |
180 | func authNode(didPressCloseButton button: ASDisplayNode) {
181 | self.dismiss(animated: true, completion: nil)
182 | }
183 |
184 | }
185 |
--------------------------------------------------------------------------------
/HackerNews/SettingsNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsDropdownNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/30/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class SettingsNode: ASCellNode {
12 |
13 | // MARK: - Properties
14 |
15 | var iconNode = ASTextNode()
16 | var titleNode = ASTextNode()
17 | var selectedNode = ASTextNode()
18 | var rightIconNode = ASTextNode()
19 |
20 | var nodeType: SettingsNodeType = .action
21 |
22 | // MARK: - Inits
23 |
24 | init(dropdownNodeWithType type: OptionType, selected: OptionType) {
25 | super.init()
26 |
27 | self.nodeType = .dropdown
28 |
29 | self.commonInit(optionType: type)
30 |
31 | // selected title
32 | self.selectedNode.attributedText = NSAttributedString(
33 | string: selected.title,
34 | attributes: Styles.settings.selectedTitle)
35 |
36 | // right icon
37 | self.rightIconNode.attributedText = NSAttributedString(
38 | string: IonIconType.ion_chevron_down.rawValue,
39 | attributes: Styles.settings.rightIcon)
40 | }
41 |
42 | init(actionNodeWithType type: OptionType) {
43 | super.init()
44 |
45 | self.nodeType = .action
46 |
47 | self.commonInit(optionType: type)
48 | }
49 |
50 | init(openNodeWithType type: OptionType) {
51 | super.init()
52 |
53 | self.nodeType = .open
54 |
55 | self.commonInit(optionType: type)
56 |
57 | // right icon
58 | self.rightIconNode.attributedText = NSAttributedString(
59 | string: IonIconType.ion_chevron_right.rawValue,
60 | attributes: Styles.settings.rightIcon)
61 | }
62 |
63 | func commonInit(optionType type: OptionType) {
64 | self.automaticallyManagesSubnodes = true
65 |
66 | // icon
67 | self.iconNode.attributedText = NSAttributedString(
68 | string: type.icon.rawValue,
69 | attributes: Styles.settings.icon)
70 |
71 | // title
72 | self.titleNode.attributedText = NSAttributedString(
73 | string: type.title,
74 | attributes: Styles.settings.title)
75 |
76 | self.setupTheme()
77 | }
78 |
79 | // MARK: - Layout
80 |
81 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
82 | switch self.nodeType {
83 | case .dropdown:
84 | let leftStack = ASStackLayoutSpec(
85 | direction: .horizontal,
86 | spacing: HNDimensions.padding / 2,
87 | justifyContent: .start,
88 | alignItems: .center,
89 | children: [self.iconNode, self.titleNode])
90 |
91 | let rightStack = ASStackLayoutSpec(
92 | direction: .horizontal,
93 | spacing: HNDimensions.padding / 2,
94 | justifyContent: .end,
95 | alignItems: .center,
96 | children: [self.selectedNode, self.rightIconNode])
97 |
98 | let spacer = ASLayoutSpec()
99 | spacer.style.flexGrow = 1
100 | spacer.style.flexShrink = 1
101 |
102 | let mainStack = ASStackLayoutSpec(
103 | direction: .horizontal,
104 | spacing: 0,
105 | justifyContent: .start,
106 | alignItems: .center, children: [leftStack, spacer, rightStack])
107 |
108 | let insetTitle = ASInsetLayoutSpec(
109 | insets: UIEdgeInsetsMake(
110 | HNDimensions.padding / 1.5,
111 | HNDimensions.padding,
112 | HNDimensions.padding / 1.5,
113 | HNDimensions.padding),
114 | child: mainStack)
115 |
116 | return insetTitle
117 |
118 | case .action:
119 | let leftStack = ASStackLayoutSpec(
120 | direction: .horizontal,
121 | spacing: HNDimensions.padding / 2,
122 | justifyContent: .start,
123 | alignItems: .center,
124 | children: [self.iconNode, self.titleNode])
125 |
126 | let insetTitle = ASInsetLayoutSpec(
127 | insets: UIEdgeInsetsMake(
128 | HNDimensions.padding / 1.5,
129 | HNDimensions.padding,
130 | HNDimensions.padding / 1.5,
131 | HNDimensions.padding),
132 | child: leftStack)
133 |
134 | return insetTitle
135 |
136 | case .open:
137 | let leftStack = ASStackLayoutSpec(
138 | direction: .horizontal,
139 | spacing: HNDimensions.padding / 2,
140 | justifyContent: .start,
141 | alignItems: .center,
142 | children: [self.iconNode, self.titleNode])
143 |
144 | let rightStack = ASStackLayoutSpec(
145 | direction: .horizontal,
146 | spacing: HNDimensions.padding / 2,
147 | justifyContent: .end,
148 | alignItems: .center,
149 | children: [self.rightIconNode])
150 |
151 | let spacer = ASLayoutSpec()
152 | spacer.style.flexGrow = 1
153 | spacer.style.flexShrink = 1
154 |
155 | let mainStack = ASStackLayoutSpec(
156 | direction: .horizontal,
157 | spacing: 0,
158 | justifyContent: .start,
159 | alignItems: .center, children: [leftStack, spacer, rightStack])
160 |
161 | let insetTitle = ASInsetLayoutSpec(
162 | insets: UIEdgeInsetsMake(
163 | HNDimensions.padding / 1.5,
164 | HNDimensions.padding,
165 | HNDimensions.padding / 1.5,
166 | HNDimensions.padding),
167 | child: mainStack)
168 |
169 | return insetTitle
170 | }
171 | }
172 |
173 | }
174 |
--------------------------------------------------------------------------------
/HackerNews/CommentOptionsNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentOptionsNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/23/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class CommentOptionsNode: ASCellNode {
12 |
13 | // MARK: - Properties
14 |
15 | var moreNode = ASTextNode()
16 | var replyIconNode = ASTextNode()
17 | var replyTextNode = ASTextNode()
18 | var voteNode = ASTextNode()
19 | var topDividerNode = ASDisplayNode()
20 | var bottomDividerNode = ASDisplayNode()
21 |
22 | var item = Item()
23 |
24 | weak var delegate: CommentOptionsNodeDelegate?
25 |
26 | // MARK: - Inits
27 |
28 | init(withItem item: Item, commentOptionsDelegate: CommentOptionsNodeDelegate? = nil) {
29 | super.init()
30 |
31 | self.item = item
32 | self.delegate = commentOptionsDelegate
33 |
34 | self.automaticallyManagesSubnodes = true
35 |
36 | // more icon
37 | self.moreNode.attributedText = NSAttributedString(
38 | string: IonIconType.ion_more.rawValue,
39 | attributes: Styles.commentOptions.icon
40 | )
41 |
42 | // reply icon
43 | self.replyIconNode.attributedText = NSAttributedString(
44 | string: IonIconType.ion_reply.rawValue,
45 | attributes: Styles.commentOptions.icon
46 | )
47 |
48 | // reply text
49 | self.replyTextNode.attributedText = NSAttributedString(
50 | string: "Reply",
51 | attributes: Styles.commentOptions.text
52 | )
53 |
54 | // vote icon
55 | self.voteNode.attributedText = NSAttributedString(
56 | string: IonIconType.ion_arrow_up_a.rawValue,
57 | attributes: ItemProvider.didVote(item: item) ? Styles.commentOptions.highlightedIcon : Styles.commentOptions.icon
58 | )
59 |
60 | // top divider
61 | self.addSubnode(self.topDividerNode)
62 |
63 | // bottom divider
64 | self.addSubnode(self.bottomDividerNode)
65 |
66 | self.setupTheme()
67 | }
68 |
69 | // MARK: - Helpers
70 |
71 | override func touchesEnded(_ touches: Set, with event: UIEvent?) {
72 | if let location = touches.first?.location(in: self.view) {
73 | if location.x < self.frame.width / 3 {
74 | self.onMoreButtonPressed(self.moreNode)
75 | } else if location.x < self.frame.width - self.frame.width / 3 {
76 | self.onReplyButtonPressed(self.replyTextNode)
77 | } else {
78 | self.onVoteButtonPressed(self.voteNode)
79 | }
80 | }
81 | }
82 |
83 | func set(upvoted: Bool) {
84 | self.voteNode.attributedText = NSAttributedString(
85 | string: IonIconType.ion_arrow_up_a.rawValue,
86 | attributes: upvoted ? Styles.commentOptions.highlightedIcon : Styles.commentOptions.icon
87 | )
88 | }
89 |
90 | // MARK: - Layout
91 |
92 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
93 |
94 | // more stack
95 | let moreStack = ASStackLayoutSpec(
96 | direction: .horizontal,
97 | spacing: HNDimensions.padding / 2,
98 | justifyContent: .center,
99 | alignItems: .center,
100 | children: [self.moreNode])
101 | moreStack.style.width = .init(unit: .fraction, value: 1/2)
102 |
103 | // TODO: - uncomment when replying to post is supported, also change 2 to 3 above
104 | // reply stack
105 | // let replyStack = ASStackLayoutSpec(
106 | // direction: .horizontal,
107 | // spacing: HNDimensions.padding / 2,
108 | // justifyContent: .center,
109 | // alignItems: .center,
110 | // children: [self.replyIconNode, self.replyTextNode])
111 | // replyStack.style.width = .init(unit: .fraction, value: 1/3)
112 |
113 | // vote stack
114 | let voteStack = ASStackLayoutSpec(
115 | direction: .horizontal,
116 | spacing: HNDimensions.padding / 2,
117 | justifyContent: .center,
118 | alignItems: .center,
119 | children: [self.voteNode])
120 | voteStack.style.width = .init(unit: .fraction, value: 1/2)
121 |
122 | // main stack
123 | let mainStack = ASStackLayoutSpec(
124 | direction: .horizontal,
125 | spacing: 0,
126 | justifyContent: .spaceAround,
127 | alignItems: .center,
128 | children: [moreStack /*, replyStack */, voteStack])
129 |
130 | // top/bottom insets
131 | let insetStack = ASInsetLayoutSpec(
132 | insets: UIEdgeInsetsMake(
133 | HNDimensions.padding, 0,
134 | HNDimensions.padding, 0),
135 | child: mainStack)
136 |
137 | return insetStack
138 | }
139 |
140 | override func layout() {
141 | super.layout()
142 |
143 | let width = self.calculatedSize.width
144 | let dividerWidth = width - HNDimensions.padding
145 |
146 | // arrange the dividers
147 | self.topDividerNode.frame = CGRect(
148 | x: HNDimensions.padding / 2,
149 | y: 0,
150 | width: dividerWidth,
151 | height: 1)
152 | self.bottomDividerNode.frame = CGRect(
153 | x: HNDimensions.padding / 2,
154 | y: self.frame.maxY - 1,
155 | width: dividerWidth,
156 | height: 1)
157 | }
158 |
159 | // MARK: - IBActions
160 |
161 | func onMoreButtonPressed(_ sender: ASDisplayNode) {
162 | self.delegate?.commentOptions?(self, didPressMoreButtonWithSender: sender)
163 | }
164 |
165 | func onReplyButtonPressed(_ sender: ASDisplayNode) {
166 | self.delegate?.commentOptions?(self, didPressReplyButtonWithSender: sender)
167 | }
168 |
169 | func onVoteButtonPressed(_ sender: ASDisplayNode) {
170 | self.delegate?.commentOptions?(self, didPressVoteButtonWithSender: sender)
171 | }
172 |
173 | }
174 |
--------------------------------------------------------------------------------
/HackerNews/StoryDetailNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoryDetailNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/23/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AsyncDisplayKit
11 | import NSDate_TimeAgo
12 |
13 | class StoryDetailNode: ASCellNode {
14 |
15 | // MARK: - Properties
16 |
17 | var posterNode: ASButtonNode!
18 | var siteNode: ASButtonNode!
19 | var titleNode: ASTextNode!
20 | var textNode: ASTextNode!
21 | var dividerNode: ASDisplayNode!
22 |
23 | weak var delegate: StoryDetailNodeDelegate?
24 |
25 | // MARK: - Inits
26 |
27 | init(withItem item: Item, delegate: StoryDetailNodeDelegate? = nil) {
28 | super.init()
29 |
30 | self.delegate = delegate
31 |
32 | // Poster button
33 | self.posterNode = ASButtonNode()
34 | if !item.by.isEmpty && item.time > 0 {
35 | self.posterNode.setAttributedTitle(
36 | NSAttributedString(
37 | string: "\(item.by) • \(NSDate.init(timeIntervalSince1970: item.time).timeAgo() ?? "")",
38 | attributes: Styles.story.poster),
39 | for: .normal
40 | )
41 | }
42 | self.posterNode.isUserInteractionEnabled = true
43 | self.posterNode.addTarget(self, action: #selector(self.onPosterButtonPressed(_:)), forControlEvents: .touchUpInside)
44 | self.addSubnode(self.posterNode)
45 |
46 | // Site button
47 | self.siteNode = ASButtonNode()
48 | if !item.url.isEmpty {
49 | self.siteNode.setAttributedTitle(
50 | NSAttributedString(
51 | string: " • \(item.url.domainName())",
52 | attributes: Styles.story.website),
53 | for: .normal
54 | )
55 | }
56 | self.siteNode.isUserInteractionEnabled = true
57 | self.siteNode.addTarget(self, action: #selector(self.onSiteButtonPressed(_:)), forControlEvents: .touchUpInside)
58 | self.addSubnode(self.siteNode)
59 |
60 | // Title text
61 | self.titleNode = ASTextNode()
62 | self.titleNode.attributedText = NSAttributedString(
63 | string: item.title,
64 | attributes: Styles.storyDetails.title)
65 | self.titleNode.isUserInteractionEnabled = true
66 | self.titleNode.addTarget(self, action: #selector(self.onTitlePressed(_:)), forControlEvents: .touchUpInside)
67 | self.addSubnode(self.titleNode)
68 |
69 | // Text
70 | if !item.text.isEmpty {
71 | self.textNode = ASTextNode()
72 | self.textNode.attributedText = NSMutableAttributedString(
73 | string: item.text,
74 | attributes: Styles.storyDetails.text).detectLinks()
75 | self.textNode.isUserInteractionEnabled = true
76 | self.textNode.linkAttributeNames = [Constants.kTextLinkAttributeName]
77 | self.textNode.passthroughNonlinkTouches = true
78 | self.textNode.delegate = self
79 | self.addSubnode(self.textNode)
80 | }
81 |
82 | // Divider
83 | self.dividerNode = ASDisplayNode()
84 | self.addSubnode(self.dividerNode)
85 |
86 | self.setupTheme()
87 | }
88 |
89 | // MARK: - Layout
90 |
91 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
92 |
93 | // poster + site
94 | let headerStack = ASStackLayoutSpec(
95 | direction: .horizontal,
96 | spacing: 0,
97 | justifyContent: .start,
98 | alignItems: .center,
99 | children: [self.posterNode, self.siteNode])
100 |
101 | var contentChildren: [ASLayoutElement] = [headerStack, self.titleNode]
102 | if self.textNode != nil {
103 | contentChildren.append(self.textNode)
104 | }
105 |
106 | // header stack + title + text
107 | let contentStack = ASStackLayoutSpec(
108 | direction: .vertical,
109 | spacing: HNDimensions.padding,
110 | justifyContent: .center,
111 | alignItems: .start,
112 | children: contentChildren)
113 |
114 | let insetContentStack = ASInsetLayoutSpec(
115 | insets: UIEdgeInsetsMake(HNDimensions.padding / 2, HNDimensions.padding,
116 | 0, HNDimensions.padding),
117 | child: contentStack)
118 |
119 | return insetContentStack
120 | }
121 |
122 | override func layout() {
123 | super.layout()
124 |
125 | // arrange the divider
126 | let distanceBetweenPosterAndTitle = self.titleNode.frame.minY - self.posterNode.frame.maxY
127 | self.dividerNode.frame = CGRect(
128 | x: HNDimensions.padding,
129 | y: self.posterNode.frame.maxY + distanceBetweenPosterAndTitle / 2,
130 | width: self.calculatedSize.width - 2 * HNDimensions.padding,
131 | height: 1)
132 | }
133 |
134 | // MARK: - IBActions
135 |
136 | func onSiteButtonPressed(_ sender: ASDisplayNode) {
137 | self.delegate?.storyDetail?(self, didPressSiteButtonWithSender: sender)
138 | }
139 |
140 | func onPosterButtonPressed(_ sender: ASDisplayNode) {
141 | self.delegate?.storyDetail?(self, didPressPosterButtonWithSender: sender)
142 | }
143 |
144 | func onTitlePressed(_ sender: ASDisplayNode) {
145 | self.delegate?.storyDetail?(self, didPressTitleWithSender: sender)
146 | }
147 |
148 | }
149 |
150 | // MARK: - ASTextNodeDelegate
151 |
152 | extension StoryDetailNode: ASTextNodeDelegate {
153 |
154 | func textNode(_ textNode: ASTextNode, shouldHighlightLinkAttribute attribute: String, value: Any, at point: CGPoint) -> Bool {
155 | return true
156 | }
157 |
158 | func textNode(_ textNode: ASTextNode, tappedLinkAttribute attribute: String, value: Any, at point: CGPoint, textRange: NSRange) {
159 | if let value = value as? URL {
160 | self.delegate?.storyDetail?(self, didPressLink: value.absoluteString, withSender: textNode)
161 | }
162 | }
163 |
164 | }
165 |
--------------------------------------------------------------------------------
/HackerNews/StoryBarNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ItemNode.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/22/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import AsyncDisplayKit
10 |
11 | class StoryBarNode: ASDisplayNode {
12 |
13 | // MARK: - Properties
14 |
15 | var shareIconNode = ASTextNode()
16 | var shareTextNode = ASTextNode()
17 | var commentsIconNode = ASTextNode()
18 | var commentsTextNode = ASTextNode()
19 | var votesIconNode = ASTextNode()
20 | var votesTextNode = ASTextNode()
21 | var leftDividerNode = ASDisplayNode()
22 | var rightDividerNode = ASDisplayNode()
23 |
24 | weak var delegate: StoryBarDelegate?
25 |
26 | var itemId: Int = -1
27 |
28 | // MARK: - Inits
29 |
30 | init(withItem item: Item) {
31 | super.init()
32 |
33 | self.itemId = item.id
34 |
35 | self.automaticallyManagesSubnodes = true
36 |
37 | // share icon
38 | self.shareIconNode.attributedText = NSAttributedString(
39 | string: IonIconType.ion_ios_upload.rawValue,
40 | attributes: Styles.storyBar.icon
41 | )
42 |
43 | // share text
44 | self.shareTextNode.attributedText = NSAttributedString(
45 | string: "Share",
46 | attributes: Styles.storyBar.button
47 | )
48 |
49 | // comments icon
50 | self.commentsIconNode.attributedText = NSAttributedString(
51 | string: IonIconType.ion_chatbox.rawValue,
52 | attributes: Styles.storyBar.icon
53 | )
54 |
55 | // comments text
56 | self.commentsTextNode.attributedText = NSAttributedString(
57 | string: "\(item.descendants)",
58 | attributes: Styles.storyBar.button
59 | )
60 |
61 | // votes icon
62 | self.votesIconNode.attributedText = NSAttributedString(
63 | string: IonIconType.ion_arrow_up_a.rawValue,
64 | attributes: ItemProvider.didVote(item: item) ? Styles.storyBar.highlightedIcon : Styles.storyBar.icon
65 | )
66 |
67 | // votes text
68 | self.votesTextNode.attributedText = NSAttributedString(
69 | string: "\(item.score)",
70 | attributes: Styles.storyBar.button
71 | )
72 |
73 | // left divider
74 | self.leftDividerNode = ASDisplayNode()
75 | self.addSubnode(self.leftDividerNode)
76 |
77 | // right divider
78 | self.rightDividerNode = ASDisplayNode()
79 | self.addSubnode(self.rightDividerNode)
80 |
81 | // setup the theme
82 | self.setupTheme()
83 | }
84 |
85 | // MARK: - Helpers
86 |
87 | override func touchesEnded(_ touches: Set, with event: UIEvent?) {
88 | if let location = touches.first?.location(in: self.view) {
89 | if location.x < self.frame.width / 3 {
90 | self.onShareButtonPressed(self.shareTextNode)
91 | } else if location.x < self.frame.width - self.frame.width / 3 {
92 | self.onCommentsButtonPressed(self.commentsTextNode)
93 | } else {
94 | self.onVoteButtonPressed(self.votesTextNode)
95 | }
96 | }
97 | }
98 |
99 | func set(upvoted: Bool) {
100 | self.votesIconNode.attributedText = NSAttributedString(
101 | string: IonIconType.ion_arrow_up_a.rawValue,
102 | attributes: upvoted ? Styles.storyBar.highlightedIcon : Styles.storyBar.icon
103 | )
104 | }
105 |
106 | // MARK: - Layout
107 |
108 | override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
109 |
110 | self.style.width = .init(unit: .fraction, value: 1)
111 | self.style.height = .init(unit: .points, value: HNDimensions.itemBar.height)
112 |
113 | let shareStack = ASStackLayoutSpec(
114 | direction: .horizontal,
115 | spacing: HNDimensions.padding / 2,
116 | justifyContent: .center,
117 | alignItems: .center,
118 | children: [self.shareIconNode, self.shareTextNode])
119 | shareStack.style.width = .init(unit: .fraction, value: 1/3)
120 |
121 | let commentStack = ASStackLayoutSpec(
122 | direction: .horizontal,
123 | spacing: HNDimensions.padding / 2,
124 | justifyContent: .center,
125 | alignItems: .center,
126 | children: [self.commentsIconNode, self.commentsTextNode])
127 | commentStack.style.width = .init(unit: .fraction, value: 1/3)
128 |
129 | let voteStack = ASStackLayoutSpec(
130 | direction: .horizontal,
131 | spacing: HNDimensions.padding / 2,
132 | justifyContent: .center,
133 | alignItems: .center,
134 | children: [self.votesIconNode, self.votesTextNode])
135 | voteStack.style.width = .init(unit: .fraction, value: 1/3)
136 |
137 | let mainStack = ASStackLayoutSpec(
138 | direction: .horizontal,
139 | spacing: 0,
140 | justifyContent: .spaceAround,
141 | alignItems: .center,
142 | children: [shareStack, commentStack, voteStack])
143 |
144 | return mainStack
145 | }
146 |
147 | override func layout() {
148 | super.layout()
149 |
150 | let width = self.calculatedSize.width
151 | let height = self.calculatedSize.height
152 |
153 | // arrange the dividers
154 | self.leftDividerNode.frame = CGRect(x: width / 3, y: height / 2 - HNDimensions.itemBar.dividerHeight / 2, width: 1, height: HNDimensions.itemBar.dividerHeight)
155 | self.rightDividerNode.frame = CGRect(x: 2 * width / 3, y: height / 2 - HNDimensions.itemBar.dividerHeight / 2, width: 1, height: HNDimensions.itemBar.dividerHeight)
156 | }
157 |
158 | // MARK: - IBActions
159 |
160 | func onShareButtonPressed(_ sender: ASDisplayNode) {
161 | self.delegate?.storyBar?(self, didPressShareButtonWithSender: sender)
162 | }
163 |
164 | func onCommentsButtonPressed(_ sender: ASDisplayNode) {
165 | self.delegate?.storyBar?(self, didPressCommentsButtonWithSender: sender)
166 | }
167 |
168 | func onVoteButtonPressed(_ sender: ASDisplayNode) {
169 | self.delegate?.storyBar?(self, didPressVoteButtonWithSender: sender)
170 | }
171 |
172 | }
173 |
--------------------------------------------------------------------------------
/HackerNews/3DTouchHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // 3DTouchHelper.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/16/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension AppDelegate {
12 |
13 | // MARK: - Properties
14 |
15 | static func applicationShortcutUserInfoIconKey() -> String {
16 | return "applicationShortcutUserInfoIconKey"
17 | }
18 |
19 | // MARK: - Types
20 |
21 | enum ShortcutIdentifier: String {
22 | case post
23 | case inbox
24 | case search
25 |
26 | // MARK: - Initializers
27 |
28 | init?(fullType: String) {
29 | guard let last = fullType.components(separatedBy: ".").last else { return nil }
30 |
31 | self.init(rawValue: last)
32 | }
33 |
34 | // MARK: - Properties
35 |
36 | var type: String {
37 | return Bundle.main.bundleIdentifier! + ".\(self.rawValue)"
38 | }
39 | }
40 |
41 | // MARK: - Helpers
42 |
43 | @available(iOS 9.0, *)
44 | func handleShortCutItem(shortcutItem: UIApplicationShortcutItem) -> Bool {
45 | var handled = false
46 |
47 | // Verify that the provided `shortcutItem`'s `type` is one handled by the application.
48 | guard ShortcutIdentifier(fullType: shortcutItem.type) != nil else { return false }
49 |
50 | guard let shortCutType = shortcutItem.type as String? else { return false }
51 |
52 | switch (shortCutType) {
53 | case ShortcutIdentifier.post.type:
54 | handled = true
55 | break
56 | case ShortcutIdentifier.inbox.type:
57 | handled = true
58 | break
59 | case ShortcutIdentifier.search.type:
60 | handled = true
61 | break
62 | default:
63 | break
64 | }
65 |
66 | // Construct an alert using the details of the shortcut used to open the application.
67 | let alertController = UIAlertController(title: "Shortcut Handled", message: "\"\(shortcutItem.localizedTitle)\"", preferredStyle: .alert)
68 | let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
69 | alertController.addAction(okAction)
70 |
71 | // Display an alert indicating the shortcut selected from the home screen.
72 | window!.rootViewController?.present(alertController, animated: true, completion: nil)
73 |
74 | return handled
75 | }
76 |
77 | func app(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
78 | if #available(iOS 9.0, *) {
79 | // Override point for customization after application launch.
80 | var shouldPerformAdditionalDelegateHandling = true
81 |
82 | // If a shortcut was launched, display its information and take the appropriate action
83 | if let shortcutItem = launchOptions?[UIApplicationLaunchOptionsKey.shortcutItem] as? UIApplicationShortcutItem {
84 |
85 | launchedShortcutItem = shortcutItem
86 |
87 | // This will block "performActionForShortcutItem:completionHandler" from being called.
88 | shouldPerformAdditionalDelegateHandling = false
89 | }
90 |
91 | // Install initial versions of our two extra dynamic shortcuts.
92 | if let shortcutItems = application.shortcutItems, shortcutItems.isEmpty {
93 | // Construct the items.
94 | let shortcut1 = UIMutableApplicationShortcutItem(type: ShortcutIdentifier.post.type, localizedTitle: "Post", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .add), userInfo: [
95 | AppDelegate.applicationShortcutUserInfoIconKey(): UIApplicationShortcutIconType.add.rawValue
96 | ]
97 | )
98 |
99 | var shortcut2: UIMutableApplicationShortcutItem!
100 |
101 | if #available(iOS 9.1, *) {
102 | shortcut2 = UIMutableApplicationShortcutItem(type: ShortcutIdentifier.inbox.type, localizedTitle: "Inbox", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .mail), userInfo: [
103 | AppDelegate.applicationShortcutUserInfoIconKey(): UIApplicationShortcutIconType.mail.rawValue
104 | ]
105 | )
106 | } else {
107 | shortcut2 = UIMutableApplicationShortcutItem(type: ShortcutIdentifier.inbox.type, localizedTitle: "Inbox", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .compose), userInfo: [
108 | AppDelegate.applicationShortcutUserInfoIconKey(): UIApplicationShortcutIconType.compose.rawValue
109 | ]
110 | )
111 | }
112 |
113 | let shortcut3 = UIMutableApplicationShortcutItem(type: ShortcutIdentifier.inbox.type, localizedTitle: "Search", localizedSubtitle: nil, icon: UIApplicationShortcutIcon(type: .search), userInfo: [
114 | AppDelegate.applicationShortcutUserInfoIconKey(): UIApplicationShortcutIconType.search.rawValue
115 | ]
116 | )
117 |
118 | // Update the application providing the initial 'dynamic' shortcut items.
119 | application.shortcutItems = [shortcut1, shortcut2, shortcut3]
120 | }
121 |
122 | return shouldPerformAdditionalDelegateHandling
123 | }
124 | return true
125 | }
126 |
127 | // MARK: - Application Life Cycle
128 |
129 | func applicationDidBecomeActive(_ application: UIApplication) {
130 | if #available(iOS 9.0, *) {
131 | guard let shortcut = launchedShortcutItem as? UIApplicationShortcutItem else { return }
132 | let _ = handleShortCutItem(shortcutItem: shortcut)
133 |
134 | launchedShortcutItem = nil
135 | }
136 | }
137 |
138 | @objc(application:performActionForShortcutItem:completionHandler:) @available(iOS 9.0, *)
139 | func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
140 | let handledShortCutItem = handleShortCutItem(shortcutItem: shortcutItem)
141 |
142 | completionHandler(handledShortCutItem)
143 | }
144 |
145 | }
146 |
--------------------------------------------------------------------------------
/HackerNews/RestAPI.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RestAPI.swift
3 | // HackerNews
4 | //
5 | // Created by Mihail Cristian Dumitru on 10/21/16.
6 | // Copyright © 2016 Null. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import PromiseKit
11 | import Alamofire
12 |
13 | class RestAPI {
14 |
15 | // MARK: - Properties
16 |
17 | /**
18 | * API urls
19 | */
20 | open class urls {
21 | open static let baseUrl = "https://hn.algolia.com/api/v1"
22 |
23 | open static let items = "\(baseUrl)/items/"
24 | open static let users = "\(baseUrl)/users/"
25 | open static let searchByRelevance = "\(baseUrl)/search"
26 | open static let searchByDate = "\(baseUrl)/search_by_date"
27 | }
28 |
29 | /**
30 | * Dispatch queue
31 | */
32 | static let RestAPIQueue = DispatchQueue(label: "RestAPIQueue")
33 |
34 | // MARK: - Helpers
35 |
36 | static func add(timeRange: SearchTimeRangeType, toParams params: [String: Any]) -> [String: Any] {
37 | // check if we need to add a time range
38 | if let startTime = timeRange.startTime {
39 |
40 | var mutableParams = params
41 | mutableParams["numericFilters"] = "created_at_i>=\(startTime.timeIntervalSince1970)"
42 |
43 | return mutableParams
44 | }
45 |
46 | return params
47 | }
48 |
49 | // MARK: - APIs
50 |
51 | static func item(id: Int) -> Promise
- {
52 | return Promise(resolvers: { (fulfill, reject) in
53 | RestAPIQueue.async {
54 | Alamofire.request("\(urls.items)\(id)",
55 | method: .get,
56 | encoding: JSONEncoding.default)
57 | .validate()
58 | .responseJSON()
59 | .then { response, json -> Void in
60 | RestAPIQueue.async {
61 | let item = Item(withJSON: json)
62 | fulfill(item)
63 | }
64 | }.catch { error in
65 | reject(error)
66 | }
67 | }
68 | })
69 | }
70 |
71 | static func userPosts(id: String, page: Int = 0) -> Promise {
72 | return Promise(resolvers: { (fulfill, reject) in
73 | RestAPIQueue.async {
74 | let params: [String: Any] = [
75 | "tags" : "story,author_\(id)",
76 | "page": page
77 | ]
78 |
79 | Alamofire.request(urls.searchByDate,
80 | method: .get,
81 | parameters: params,
82 | encoding: URLEncoding.default)
83 | .validate()
84 | .responseJSON()
85 | .then { response, json -> Void in
86 | RestAPIQueue.async {
87 | let item = AlgoliaResponse(withJSON: json)
88 | fulfill(item)
89 | }
90 | }.catch { error in
91 | reject(error)
92 | }
93 | }
94 | })
95 | }
96 |
97 | static func userComments(id: String, page: Int = 0) -> Promise {
98 | return Promise(resolvers: { (fulfill, reject) in
99 | RestAPIQueue.async {
100 | let params: [String: Any] = [
101 | "tags" : "comment,author_\(id)",
102 | "page": page
103 | ]
104 |
105 | Alamofire.request(urls.searchByDate,
106 | method: .get,
107 | parameters: params,
108 | encoding: URLEncoding.default)
109 | .validate()
110 | .responseJSON()
111 | .then { response, json -> Void in
112 | RestAPIQueue.async {
113 | let item = AlgoliaResponse(withJSON: json)
114 | fulfill(item)
115 | }
116 | }.catch { error in
117 | reject(error)
118 | }
119 | }
120 | })
121 | }
122 |
123 | static func searchStories(query: String, timeRange: SearchTimeRangeType = .all, sortBy: SearchSortType = .relevance, page: Int = 0) -> Promise {
124 | return Promise(resolvers: { (fulfill, reject) in
125 | RestAPIQueue.async {
126 | var params: [String: Any] = [
127 | "query": query,
128 | "tags" : "story",
129 | "page": page
130 | ]
131 |
132 | params = add(timeRange: timeRange, toParams: params)
133 |
134 | Alamofire.request("\(urls.baseUrl)\(sortBy.url)",
135 | method: .get,
136 | parameters: params,
137 | encoding: URLEncoding.default)
138 | .validate()
139 | .responseJSON()
140 | .then { response, json -> Void in
141 | RestAPIQueue.async {
142 | let item = AlgoliaResponse(withJSON: json)
143 | fulfill(item)
144 | }
145 | }.catch { error in
146 | reject(error)
147 | }
148 | }
149 | })
150 | }
151 |
152 | static func searchComments(query: String, timeRange: SearchTimeRangeType = .all, sortBy: SearchSortType = .relevance, page: Int = 0) -> Promise {
153 | return Promise(resolvers: { (fulfill, reject) in
154 | RestAPIQueue.async {
155 | var params: [String: Any] = [
156 | "query": query,
157 | "tags" : "comment",
158 | "page": page,
159 | ]
160 |
161 | params = add(timeRange: timeRange, toParams: params)
162 |
163 | Alamofire.request("\(urls.baseUrl)\(sortBy.url)",
164 | method: .get,
165 | parameters: params,
166 | encoding: URLEncoding.default)
167 | .validate()
168 | .responseJSON()
169 | .then { response, json -> Void in
170 | RestAPIQueue.async {
171 | let item = AlgoliaResponse(withJSON: json)
172 | fulfill(item)
173 | }
174 | }.catch { error in
175 | reject(error)
176 | }
177 | }
178 | })
179 | }
180 |
181 | }
182 |
--------------------------------------------------------------------------------