├── .gitattributes
├── Images
├── techcrunch.png
├── theverge.png
├── venturebeat.png
├── businessinsider.png
├── screenshot-swiftui.png
└── snapshot-product-hunt-button.png
├── Sources
└── ProductHunt
│ ├── Resources
│ └── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── logo.imageset
│ │ ├── logo-product-hunt.pdf
│ │ └── Contents.json
│ │ ├── border.colorset
│ │ └── Contents.json
│ │ ├── background.colorset
│ │ └── Contents.json
│ │ └── foreground.colorset
│ │ └── Contents.json
│ ├── Extensions
│ ├── String.swift
│ ├── UIFont.swift
│ ├── Font.swift
│ ├── UIColor.swift
│ ├── UserDefaults.swift
│ └── Color.swift
│ ├── PHResponse.swift
│ ├── PHPost.swift
│ ├── SwiftUI
│ ├── PHVotesCount.swift
│ └── ProductHuntButton.swift
│ ├── PHManager.swift
│ └── PHButton.swift
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ └── xcschemes
│ └── ProductHunt.xcscheme
├── Tests
├── LinuxMain.swift
└── ProductHuntTests
│ ├── XCTestManifests.swift
│ └── ProductHuntTests.swift
├── Bundle.swift
├── .github
└── workflows
│ └── swift.yml
├── Package.swift
├── LICENSE
├── ProductHunt.podspec
├── .gitignore
└── README.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/Images/techcrunch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appcraftconsulting/producthunt/HEAD/Images/techcrunch.png
--------------------------------------------------------------------------------
/Images/theverge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appcraftconsulting/producthunt/HEAD/Images/theverge.png
--------------------------------------------------------------------------------
/Images/venturebeat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appcraftconsulting/producthunt/HEAD/Images/venturebeat.png
--------------------------------------------------------------------------------
/Images/businessinsider.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appcraftconsulting/producthunt/HEAD/Images/businessinsider.png
--------------------------------------------------------------------------------
/Images/screenshot-swiftui.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appcraftconsulting/producthunt/HEAD/Images/screenshot-swiftui.png
--------------------------------------------------------------------------------
/Images/snapshot-product-hunt-button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appcraftconsulting/producthunt/HEAD/Images/snapshot-product-hunt-button.png
--------------------------------------------------------------------------------
/Sources/ProductHunt/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import ProductHuntTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | #if canImport(UIKit)
7 | tests += ProductHuntTests.allTests()
8 | #endif
9 | XCTMain(tests)
10 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Resources/Assets.xcassets/logo.imageset/logo-product-hunt.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appcraftconsulting/producthunt/HEAD/Sources/ProductHunt/Resources/Assets.xcassets/logo.imageset/logo-product-hunt.pdf
--------------------------------------------------------------------------------
/Tests/ProductHuntTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(ProductHuntTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Resources/Assets.xcassets/logo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "logo-product-hunt.pdf",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Bundle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Bundle.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 01/07/2020.
6 | // Copyright © 2020 App Craft Studio. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | internal extension Bundle {
12 | static let module = Bundle(for: PHManager.self)
13 | }
14 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Extensions/String.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 06/11/2020.
6 | //
7 |
8 | import Foundation
9 |
10 | extension String {
11 | static let buttonFeaturedOn = "FEATURED ON"
12 | static let buttonProductHunt = "Product Hunt"
13 | static let buttonUpvote = "▲"
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Extensions/UIFont.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIFont.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 17/09/2020.
6 | //
7 |
8 | #if canImport(UIKit)
9 | import UIKit
10 |
11 | extension UIFont {
12 | static func defaultFont(ofSize fontSize: CGFloat) -> UIFont? {
13 | UIFont(name: "HelveticaNeue-Bold", size: fontSize)
14 | }
15 | }
16 | #endif
17 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Extensions/Font.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Font.swift
3 | // ProductHunt
4 | //
5 | // Created by Julien Lacroix on 05/11/2020.
6 | //
7 |
8 | #if canImport(UIKit)
9 | import SwiftUI
10 |
11 | // MARK: - Extension
12 |
13 | @available(iOS 14, *)
14 | extension Font {
15 | static func defaultFont(size: CGFloat) -> Font {
16 | .custom("HelveticaNeue-Bold", size: size)
17 | }
18 | }
19 | #endif
20 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Extensions/UIColor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIColor.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 17/09/2020.
6 | //
7 |
8 | #if canImport(UIKit)
9 | import UIKit
10 |
11 | enum ColorAsset: String {
12 | case foreground, background, border
13 | }
14 |
15 | extension UIColor {
16 | convenience init?(asset: ColorAsset) {
17 | self.init(named: asset.rawValue, in: .module, compatibleWith: nil)
18 | }
19 | }
20 | #endif
21 |
--------------------------------------------------------------------------------
/.github/workflows/swift.yml:
--------------------------------------------------------------------------------
1 |
2 | name: Swift
3 |
4 | on:
5 | push:
6 | branches: [ master ]
7 | pull_request:
8 | branches: [ master ]
9 |
10 | jobs:
11 | build:
12 | runs-on: macos-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v2
16 | - name: Select Xcode 12
17 | run: sudo xcode-select -s /Applications/Xcode_12.app && xcodebuild -version
18 | - name: Linting podspec
19 | run: pod lib lint
20 | - name: Build
21 | run: swift build -v
22 | - name: Run tests
23 | run: swift test -v
24 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Extensions/UserDefaults.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserDefaults.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 17/09/2020.
6 | //
7 |
8 | import Foundation
9 |
10 | extension UserDefaults {
11 | private var key: String {
12 | "productHuntVotesCount"
13 | }
14 |
15 | @objc internal dynamic var productHuntVotesCount: Int {
16 | integer(forKey: key)
17 | }
18 |
19 | internal func setVotesCount(_ votesCount: Int) {
20 | setValue(votesCount, forKey: key)
21 | synchronize()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Extensions/Color.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Color.swift
3 | // ProductHunt
4 | //
5 | // Created by Julien Lacroix on 05/11/2020.
6 | //
7 |
8 | #if canImport(UIKit)
9 | import SwiftUI
10 |
11 | // MARK: - Extension
12 |
13 | @available(iOS 14, *)
14 | extension Color {
15 | static var background: Color {
16 | .init("background", bundle: .module)
17 | }
18 |
19 | static var foreground: Color {
20 | .init("foreground", bundle: .module)
21 | }
22 |
23 | static var border: Color {
24 | .init("border", bundle: .module)
25 | }
26 | }
27 | #endif
28 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/PHResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PHResponse.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 17/09/2020.
6 | //
7 |
8 | import Foundation
9 |
10 | struct PHResponse: Decodable {
11 | struct Data: Decodable {
12 | struct Post: Decodable {
13 | let votesCount: Int
14 | }
15 |
16 | let post: Post
17 | }
18 |
19 | struct Error: Decodable {
20 | let description: String
21 |
22 | enum CodingKeys: String, CodingKey {
23 | case description = "error_description"
24 | }
25 | }
26 |
27 | let data: Data?
28 | let errors: [Error]?
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/PHPost.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PHPost.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 17/09/2020.
6 | //
7 |
8 | import Foundation
9 |
10 | public enum PHPost {
11 | case id(Int)
12 | case slug(String)
13 |
14 | internal var query: String {
15 | switch self {
16 | case let .id(id):
17 | return "{post(id: \(id)){votesCount}}"
18 | case let .slug(slug):
19 | return "{post(slug: \"\(slug)\"){votesCount}}"
20 | }
21 | }
22 |
23 | internal var url: URL? {
24 | switch self {
25 | case let .id(id):
26 | return URL(string: "https://www.producthunt.com/posts/\(id)")
27 | case let .slug(slug):
28 | return URL(string: "https://www.producthunt.com/posts/\(slug)")
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Resources/Assets.xcassets/border.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x2A",
9 | "green" : "0x53",
10 | "red" : "0xEA"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x3F",
27 | "green" : "0x27",
28 | "red" : "0x24"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Resources/Assets.xcassets/background.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "1.000",
9 | "green" : "1.000",
10 | "red" : "1.000"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x3F",
27 | "green" : "0x27",
28 | "red" : "0x24"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/Resources/Assets.xcassets/foreground.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x2A",
9 | "green" : "0x53",
10 | "red" : "0xEA"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0xFF",
27 | "green" : "0xFF",
28 | "red" : "0xFF"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/SwiftUI/PHVotesCount.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PHVotesCount.swift
3 | // ProductHunt
4 | //
5 | // Created by Julien Lacroix on 05/11/2020.
6 | //
7 |
8 | #if canImport(UIKit)
9 | import Foundation
10 |
11 | // MARK: - Class
12 |
13 | @available(iOS 14, *)
14 | class PHVotesCount: ObservableObject {
15 |
16 | // MARK: - Properties
17 |
18 | @Published var value: Int = 0
19 | private var observer: NSKeyValueObservation?
20 |
21 | // MARK: - Lifecycle
22 |
23 | init(post: PHPost, token: String) {
24 | PHManager.shared.configure(forPost: post, token: token)
25 |
26 | observer = UserDefaults.standard.observe(\.productHuntVotesCount, options: [.initial, .new]) { [weak self] defaults, change in
27 | DispatchQueue.main.async {
28 | self?.value = change.newValue ?? 0
29 | }
30 | }
31 | }
32 |
33 | deinit {
34 | observer?.invalidate()
35 | }
36 | }
37 | #endif
38 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "ProductHunt",
8 | platforms: [
9 | .iOS(.v11)
10 | ],
11 | products: [
12 | // Products define the executables and libraries a package produces, and make them visible to other packages.
13 | .library(
14 | name: "ProductHunt",
15 | targets: ["ProductHunt"]),
16 | ],
17 | targets: [
18 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
19 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
20 | .target(
21 | name: "ProductHunt",
22 | dependencies: [],
23 | resources: [
24 | .process("Resources/Assets.xcassets")
25 | ]),
26 | .testTarget(
27 | name: "ProductHuntTests",
28 | dependencies: ["ProductHunt"]),
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 François Boulais
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, 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,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/ProductHunt.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |spec|
2 | spec.name = 'ProductHunt'
3 | spec.version = '1.0.3'
4 | spec.license = { :type => "MIT", :file => "LICENSE" }
5 |
6 | spec.homepage = 'https://www.producthunt.com'
7 | spec.author = { 'François Boulais' => 'francois@appcraftstudio.com' }
8 | spec.social_media_url = 'https://twitter.com/frboulais'
9 |
10 | spec.summary = 'Product Hunt badge for iOS'
11 | spec.source = { :git => 'https://github.com/appcraftstudio/producthunt.git', :tag => "#{spec.version}" }
12 |
13 | spec.ios.deployment_target = '11.0'
14 | spec.platform = :ios, '11.0'
15 | spec.swift_version = '5.0'
16 |
17 | spec.source_files = 'Sources/**/*.swift', 'Bundle.swift'
18 | spec.resources = 'Sources/**/Resources/*'
19 |
20 | spec.ios.framework = 'UIKit', 'WebKit', 'SwiftUI'
21 |
22 | spec.test_spec 'ProductHuntTests' do |test_spec|
23 | test_spec.source_files = 'Tests/ProductHuntTests/*.{swift}'
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/Tests/ProductHuntTests/ProductHuntTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ProductHunt
3 |
4 | #if canImport(UIKit)
5 | final class ProductHuntTests: XCTestCase {
6 | private let fileManager: FileManager = .default
7 |
8 | func testSnapshotButton() {
9 | guard let url = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else {
10 | XCTFail()
11 | return
12 | }
13 |
14 | var isDirectory: ObjCBool = false
15 | if !fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) || !isDirectory.boolValue {
16 | XCTAssertNoThrow(try fileManager.createDirectory(at: url, withIntermediateDirectories: false))
17 | }
18 |
19 | let button = PHButton(frame: .init(x: 0, y: 0, width: 260, height: 60))
20 | button.setVotesCount(872)
21 | button.layoutIfNeeded()
22 |
23 | if let data = button.snapshot().pngData() {
24 | let url = url.appendingPathComponent("snapshot-product-hunt-button").appendingPathExtension("png")
25 | print(url.path)
26 | XCTAssertNoThrow(try data.write(to: url))
27 | } else {
28 | XCTFail()
29 | }
30 | }
31 |
32 | static var allTests = [
33 | ("snapshot button", testSnapshotButton),
34 | ]
35 | }
36 |
37 | fileprivate extension PHButton {
38 | func snapshot() -> UIImage {
39 | UIGraphicsImageRenderer(size: bounds.size).image { context in
40 | layer.render(in: context.cgContext)
41 | }
42 | }
43 | }
44 | #endif
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
92 | # FileVault
93 | *.DS_Store
--------------------------------------------------------------------------------
/Sources/ProductHunt/SwiftUI/ProductHuntButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProductHuntButton.swift
3 | // ProductHunt
4 | //
5 | // Created by Julien Lacroix on 04/11/2020.
6 | //
7 |
8 | #if canImport(UIKit)
9 | import SwiftUI
10 | import SafariServices
11 |
12 | // MARK: - View
13 |
14 | @available(iOS 14, *)
15 | public struct ProductHuntButton: View {
16 |
17 | // MARK: - Enum
18 |
19 | public enum Mode {
20 | case preview(votesCount: Int)
21 | case `default`
22 | }
23 |
24 | // MARK: - Properties
25 |
26 | @State private var isShowingSafariView: Bool = false
27 |
28 | private let mode: Mode
29 | private let post: PHPost
30 |
31 | @ObservedObject private var votesCount: PHVotesCount
32 | private var votes: Int {
33 | switch mode {
34 | case .default:
35 | return votesCount.value
36 | case .preview(let votesCount):
37 | return votesCount
38 | }
39 | }
40 |
41 | // MARK: - Lifecycle
42 |
43 | public init(post: PHPost, token: String, mode: Mode = .default) {
44 | self.post = post
45 | self.votesCount = .init(post: post, token: token)
46 | self.mode = mode
47 | }
48 |
49 | // MARK: - Body
50 |
51 | public var body: some View {
52 | Button(action: { isShowingSafariView = true }, label: {
53 | ZStack {
54 | Color.background
55 | RoundedRectangle(cornerRadius: 12.0).stroke(Color.border, lineWidth: 2.0)
56 | HStack {
57 | Image("logo", bundle: .module)
58 | VStack(alignment: .leading, spacing: -2.0) {
59 | Text(String.buttonFeaturedOn)
60 | .foregroundColor(.foreground)
61 | .font(.defaultFont(size: 8.0))
62 | Text(String.buttonProductHunt)
63 | .foregroundColor(.foreground)
64 | .font(.defaultFont(size: 22.0))
65 | }
66 | Spacer()
67 | Text([.buttonUpvote, .init(votes)].joined(separator: "\n"))
68 | .foregroundColor(.foreground)
69 | .font(.defaultFont(size: 14.0))
70 | .multilineTextAlignment(.center)
71 | }
72 | .padding(EdgeInsets(top: 5.0, leading: 20.0, bottom: 5.0, trailing: 20.0))
73 | }
74 | .clipShape(RoundedRectangle(cornerRadius: 12.0))
75 | })
76 | .sheet(isPresented: $isShowingSafariView, content: presentSafariViewIfNecessary)
77 | }
78 |
79 | // MARK: - Private functions
80 |
81 | @ViewBuilder private func presentSafariViewIfNecessary() -> some View {
82 | if let url = post.url {
83 | SafariView(url: url)
84 | }
85 | }
86 | }
87 |
88 |
89 |
90 | // MARK: - SafariView
91 |
92 | @available(iOS 14, *)
93 | struct SafariView: UIViewControllerRepresentable {
94 | let url: URL
95 |
96 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController {
97 | .init(url: url)
98 | }
99 |
100 | func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) { }
101 | }
102 | #endif
103 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/PHManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PHManager.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 17/09/2020.
6 | //
7 |
8 | #if canImport(UIKit)
9 | import SafariServices
10 | import UIKit
11 |
12 | public class PHManager: NSObject, SFSafariViewControllerDelegate {
13 | public static let shared = PHManager()
14 |
15 | /// The view controller used to present post page.
16 | public var presentingViewController: UIViewController?
17 |
18 | private var session = URLSession.shared
19 | private var token: String?
20 | private var post: PHPost?
21 |
22 | private override init() {
23 | super.init()
24 |
25 | Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] timer in
26 | self?.fetchVotesCount()
27 | }
28 | }
29 |
30 | // MARK: - Public functions
31 |
32 | /**
33 | Configure the manager for product synchronization
34 | - parameters:
35 | - post: The post that will be linked to the `PHButton` (either defined with slug or id)
36 | - token: Your Product Hunt developer token (https://www.producthunt.com/v2/oauth/applications)
37 | */
38 | public func configure(forPost post: PHPost, token: String) {
39 | self.post = post
40 | self.token = token
41 |
42 | fetchVotesCount()
43 | }
44 |
45 | // MARK: - Internal functions
46 |
47 | @objc internal func showPostPage() {
48 | guard let presentingViewController = presentingViewController else {
49 | fatalError("presentingViewController must be set.")
50 | }
51 |
52 | if let url = post?.url {
53 | let viewController = SFSafariViewController(url: url)
54 | viewController.preferredControlTintColor = UIColor(asset: .foreground)
55 | viewController.modalPresentationStyle = .formSheet
56 | viewController.delegate = self
57 | presentingViewController.present(viewController, animated: true)
58 | }
59 | }
60 |
61 | // MARK: - SFSafariViewControllerDelegate
62 |
63 | public func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
64 | fetchVotesCount()
65 | }
66 |
67 | // MARK: - Private functions
68 |
69 | @objc private func fetchVotesCount() {
70 | guard let post = post, let token = token else {
71 | print("PHManager instance has not been configured.")
72 | return
73 | }
74 |
75 | guard let url = URL(string: "https://api.producthunt.com/v2/api/graphql") else {
76 | return
77 | }
78 |
79 | let parameters : [String : Any] = ["query" : post.query]
80 | let data = try? JSONSerialization.data(withJSONObject: parameters, options: .fragmentsAllowed)
81 |
82 | var request = URLRequest(url: url)
83 | request.httpMethod = "POST"
84 | request.addValue("application/json", forHTTPHeaderField: "Content-Type")
85 | request.addValue("application/json", forHTTPHeaderField: "Accept")
86 | request.addValue("Bearer " + token, forHTTPHeaderField: "Authorization")
87 | request.httpBody = data
88 |
89 | DispatchQueue(label: "NetworkThread").async {
90 | let task = self.session.dataTask(with: request) { data, response, error in
91 | if let data = data, let response = try? JSONDecoder().decode(PHResponse.self, from: data) {
92 | if let votesCount = response.data?.post.votesCount {
93 | DispatchQueue.main.async {
94 | print("New votes count received: \(votesCount)")
95 | UserDefaults.standard.setVotesCount(votesCount)
96 | }
97 | } else if let description = response.errors?.first?.description {
98 | print(description)
99 | } else if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) {
100 | print(jsonObject)
101 | }
102 | } else if let error = error {
103 | print(error.localizedDescription)
104 | }
105 | }
106 |
107 | task.resume()
108 | }
109 | }
110 | }
111 | #endif
112 |
--------------------------------------------------------------------------------
/Sources/ProductHunt/PHButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PHButton.swift
3 | // ProductHunt
4 | //
5 | // Created by François Boulais on 14/09/2020.
6 | //
7 |
8 | #if canImport(UIKit)
9 | import UIKit
10 |
11 | @IBDesignable
12 | public class PHButton: UIButton {
13 | private let upvotesLabel = UILabel()
14 | private var observer: NSKeyValueObservation?
15 |
16 | required init?(coder: NSCoder) {
17 | super.init(coder: coder)
18 | }
19 |
20 | override init(frame: CGRect) {
21 | super.init(frame: frame)
22 |
23 | setup()
24 | addObservers()
25 | }
26 |
27 | public override func awakeFromNib() {
28 | super.awakeFromNib()
29 |
30 | setup()
31 | addObservers()
32 | }
33 |
34 | deinit {
35 | observer?.invalidate()
36 | }
37 |
38 | public override func prepareForInterfaceBuilder() {
39 | super.prepareForInterfaceBuilder()
40 |
41 | setup()
42 | }
43 |
44 | public override func layoutSubviews() {
45 | super.layoutSubviews()
46 |
47 | layer.borderColor = UIColor(asset: .border)?.cgColor
48 | }
49 |
50 | // MARK: - Internal functions
51 |
52 | internal func setVotesCount(_ votesCount: Int?) {
53 | let paragraphStyle = NSMutableParagraphStyle()
54 | paragraphStyle.paragraphSpacing = -2
55 | paragraphStyle.alignment = .center
56 |
57 | var attributes = [NSAttributedString.Key : Any]()
58 | attributes[.font] = UIFont.defaultFont(ofSize: 14)
59 | attributes[.foregroundColor] = UIColor(asset: .foreground)
60 | attributes[.paragraphStyle] = paragraphStyle
61 |
62 | let string = [.buttonUpvote, .init(votesCount ?? 0)].joined(separator: "\n")
63 | upvotesLabel.attributedText = .init(string: string, attributes: attributes)
64 | }
65 |
66 | // MARK: - Private functions
67 |
68 | private func setup() {
69 | layer.cornerRadius = 12
70 | backgroundColor = UIColor(asset: .background)
71 | let image = UIImage(named: "logo", in: .module, compatibleWith: nil)
72 | setImage(image, for: .normal)
73 |
74 | layer.borderWidth = 1
75 |
76 | adjustsImageWhenHighlighted = false
77 |
78 | let paragraphStyle = NSMutableParagraphStyle()
79 | paragraphStyle.paragraphSpacing = -2
80 | paragraphStyle.alignment = .left
81 |
82 | var attributes = [NSAttributedString.Key : Any]()
83 | attributes[.font] = UIFont.defaultFont(ofSize: 4)
84 | attributes[.foregroundColor] = UIColor(asset: .foreground)
85 | attributes[.paragraphStyle] = paragraphStyle
86 | let attributedTitle = NSMutableAttributedString(string: " ", attributes: attributes)
87 |
88 | attributes[.font] = UIFont.defaultFont(ofSize: 8)
89 | attributedTitle.append(.init(string: String.buttonFeaturedOn.appending("\n"), attributes: attributes))
90 |
91 | attributes[.font] = UIFont.defaultFont(ofSize: 22)
92 | attributedTitle.append(.init(string: .buttonProductHunt, attributes: attributes))
93 |
94 | setAttributedTitle(attributedTitle, for: .normal)
95 |
96 | contentEdgeInsets = .init(top: 10, left: 14, bottom: 8, right: 62)
97 | titleEdgeInsets = .init(top: 0, left: 4, bottom: 0, right: -4)
98 | imageEdgeInsets = .init(top: 0, left: -4, bottom: 0, right: 4)
99 |
100 | upvotesLabel.translatesAutoresizingMaskIntoConstraints = false
101 | upvotesLabel.widthAnchor.constraint(equalToConstant: 40).isActive = true
102 | upvotesLabel.minimumScaleFactor = 0.5
103 | upvotesLabel.numberOfLines = 2
104 |
105 | addSubview(upvotesLabel)
106 |
107 | upvotesLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
108 | if let titleLabel = titleLabel {
109 | titleLabel.numberOfLines = 0
110 | upvotesLabel.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: 8).isActive = true
111 | }
112 | }
113 |
114 | private func addObservers() {
115 | addTarget(PHManager.shared, action: #selector(PHManager.shared.showPostPage), for: .touchUpInside)
116 |
117 | observer = UserDefaults.standard.observe(\.productHuntVotesCount, options: [.initial, .new]) { [weak self] defaults, change in
118 | self?.setVotesCount(change.newValue)
119 | }
120 | }
121 | }
122 | #endif
123 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/ProductHunt.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
43 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
61 |
64 |
65 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
79 |
85 |
86 |
87 |
88 |
89 |
99 |
100 |
106 |
107 |
113 |
114 |
115 |
116 |
118 |
119 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Product Hunt badge for iOS.
4 |
5 | ### [Product Hunt](https://www.producthunt.com) surfaces the best new products, every day. It's a place for product-loving enthusiasts to share and geek out about the latest mobile apps, websites, hardware projects, and tech creations.
6 |
7 | >[...] Product Hunt has become a must-read site in Silicon Valley.
8 |
9 |
10 | >[...] Product Hunt is an online community that caters to the tech product fanatics.
11 |
12 |
13 | >[Product Hunt] ballooned in popularity since its humble beginnings and has since become a destination site where folks could submit and vote on their favorite tech products.
14 |
15 |
16 | >[...] Product Hunt has evolved from a small email list into a must-read for those in the tech and startup space to discover the next great product.
17 |
18 |
19 | ## Features
20 |
21 | - [X] Dark mode support
22 | - [X] Data persistence
23 | - [X] Auto refresh every 5 minutes
24 |
25 | ## Requirements
26 |
27 | - Swift 5.0
28 | - Xcode 11.x
29 |
30 | ## Implement Product Hunt - UIKit
31 |
32 | 1. Import the ProductHunt framework in your `UIApplicationDelegate`:
33 | ```swift
34 | import ProductHunt
35 | ```
36 | 2. Configure the `PHManager` shared instance in your app's `application:didFinishLaunchingWithOptions:` method with:
37 | - The post that will be linked to the `PHButton` (either defined by slug or id)
38 | - Your Product Hunt developer token (https://www.producthunt.com/v2/oauth/applications)
39 |
40 | ```swift
41 | PHManager.shared.configure(forPost: .slug("timizer"), token: "")
42 | ```
43 | 3. In the view controller, override the `viewDidLoad` method to set the presenting view controller of the `PHManager` object.
44 | ```swift
45 | PHManager.shared.presentingViewController = self
46 | ```
47 | 4. Add a `PHButton` to your storyboard, XIB file, or instantiate it programmatically. To add the button to your storyboard or XIB file, add a View and set its custom class to `PHButton`.
48 |
49 |
50 |
51 |
52 |
53 |
54 | ## Implement Product Hunt - SwiftUI
55 |
56 | 1. Import the ProductHunt framework in your view struct:
57 | ```swift
58 | import ProductHunt
59 | ```
60 |
61 | 2. Add the `ProductHuntButton` to your view with:
62 | - The post that will be linked to the button (either defined by slug or id)
63 | - Your Product Hunt developer token (https://www.producthunt.com/v2/oauth/applications)
64 |
65 | ```swift
66 | ProductHuntButton(post: .slug(""), token: "")
67 | .frame(width: 260.0, height: 60.0, alignment: .center)
68 | .padding(10.0)
69 | ```
70 |
71 |
72 |
73 |
74 |
75 |
76 | ## Installation
77 |
78 | ### [CocoaPods](https://guides.cocoapods.org/using/using-cocoapods.html)
79 |
80 | You want to add pod `'ProductHunt', '~> 1.0'` similar to the following to your Podfile:
81 | ```rb
82 | target 'MyApp' do
83 | pod 'ProductHunt', '~> 1.0'
84 | end
85 | ```
86 | Then run a `pod install` inside your terminal, or from CocoaPods.app.
87 |
88 | ### [Swift Package Manager](https://swift.org/package-manager/)
89 |
90 | 1. Using Xcode 11 or above go to *File* > *Swift Packages* > *Add Package Dependency*
91 | 2. Paste the project URL: https://github.com/appcraftstudio/producthunt.git
92 | 3. Click on next and select the project target
93 |
94 |
95 | ## Contributors ✨
96 |
97 | | Name | GitHub | Twitter |
98 | | :------------------ | :------------------------------------------- | :---------------------------------------------------- |
99 | | **François Boulais** | [**frboulais**](https://github.com/frboulais) | [**@frboulais**](https://twitter.com/frboulais) |
100 | | **Julien Lacroix** | [**JulienLacr0ix**](https://github.com/JulienLacr0ix) | [**@JulienLacr0ix**](https://twitter.com/JulienLacr0ix) |
101 |
102 |
103 | ---
104 |
105 |
106 |
107 |
108 |
109 | Copyright © 2020 App Craft Studio. All rights reserved.
110 |
--------------------------------------------------------------------------------