├── .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 | ![Swift](https://github.com/appcraftstudio/producthunt/workflows/Swift/badge.svg) 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 | Product Hunt badge for iOS - Swift framework to add Product Hunt badge in your iOS apps! | Product Hunt Embed 106 | 107 | Buy Me A Coffee 108 | 109 | Copyright © 2020 App Craft Studio. All rights reserved. 110 | --------------------------------------------------------------------------------