├── Gemfile ├── LocalizedStringsScript ├── Screenshots ├── Detail.png ├── Home.png ├── MacOS.png ├── iPadOS.png ├── Detail_Dark.png ├── Home_Dark.png ├── MacOS_Dark.png └── iPadOS_Dark.png ├── GithubJobs ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── errorIcon.imageset │ │ ├── Contents.json │ │ └── errorIcon.svg │ └── AppIcon.appiconset │ │ └── Contents.json ├── Helpers │ ├── Base │ │ └── UI │ │ │ ├── BaseView │ │ │ ├── BaseView.swift │ │ │ ├── LoadableView.swift │ │ │ └── ErrorPlaceholderView.swift │ │ │ ├── HostingController │ │ │ ├── HostingConfiguration.swift │ │ │ └── HostingController.swift │ │ │ ├── SplitViewController.swift │ │ │ └── ViewController.swift │ ├── Utils │ │ ├── FeatureFlags │ │ │ ├── FeatureFlagIdentifier.swift │ │ │ ├── DisplayFAQsFeatureFlag.swift │ │ │ ├── CustomChevronFeatureFlag.swift │ │ │ ├── FeatureFlagsManager.swift │ │ │ └── FeatureFlagProtocol.swift │ │ ├── Font │ │ │ └── View+UIFont.swift │ │ ├── Theming │ │ │ ├── Theme.swift │ │ │ ├── ThemeManagerProtocol.swift │ │ │ └── ThemeManager.swift │ │ └── LocalizedStrings.swift │ ├── Extensions │ │ ├── UIImageView+Kingfisher.swift │ │ ├── URLRequest+Headers.swift │ │ ├── CharacterSet+Filtering.swift │ │ ├── RangeReplaceableCollection+Extensions.swift │ │ ├── Dictionary+Format.swift │ │ ├── UITableView+Register.swift │ │ ├── String+HTML.swift │ │ ├── UICollectionView+Register.swift │ │ ├── UIViewController+Extensions.swift │ │ └── UIView+Layout.swift │ ├── ViewComponents │ │ ├── ErrorView │ │ │ ├── ErrorViewModel+LocalizedError.swift │ │ │ ├── ErrorViewModel.swift │ │ │ └── ErrorView.swift │ │ ├── RefreshControl.swift │ │ ├── ExpandCollapseControl │ │ │ ├── ExpandCollapseControlStyleConfiguration.swift │ │ │ ├── ExpandCollapseControlContent.swift │ │ │ └── ExpandCollapseControlDisclosureStyle.swift │ │ ├── DataSources │ │ │ └── TableViewDataSourcePrefetching.swift │ │ ├── LoadingFooterView.swift │ │ ├── CustomFooterView.swift │ │ └── Animations │ │ │ └── Animator.swift │ └── Protocols │ │ ├── Dequeuable.swift │ │ └── Themeable.swift ├── Scenes │ ├── Settings │ │ ├── FAQs │ │ │ ├── FAQsViewState.swift │ │ │ ├── FAQsInteractor.swift │ │ │ ├── FAQsCoordinator.swift │ │ │ ├── FAQsContent.swift │ │ │ ├── FAQsItemContent.swift │ │ │ ├── FAQsItemViewModel.swift │ │ │ └── FAQsViewModel.swift │ │ ├── FetureFlags │ │ │ ├── FeatureFlagsViewState.swift │ │ │ ├── FeatureFlagToggleView.swift │ │ │ ├── FeatureFlagsCoordinator.swift │ │ │ ├── FeatureFlagsInteractor.swift │ │ │ ├── FeatureFlagsContent.swift │ │ │ └── FeatureFlagsViewModel.swift │ │ ├── ThemeSelection │ │ │ ├── ThemeSelectionSection.swift │ │ │ ├── ThemeSelectionItemModel.swift │ │ │ ├── ThemeSelectionCoordinator.swift │ │ │ ├── ThemeSelectionProtocols.swift │ │ │ ├── ThemeSelectionInteractor.swift │ │ │ ├── ViewComponents │ │ │ │ └── ThemeSelectionSectionHeaderView.swift │ │ │ ├── ThemeSelectionViewModel.swift │ │ │ └── ThemeSelectionViewController.swift │ │ ├── SettingsSection.swift │ │ ├── SettingsInteractor.swift │ │ ├── SettingsItemModel.swift │ │ ├── SettingsCoordinator.swift │ │ ├── SettingsViewModel.swift │ │ ├── SettingsProtocols.swift │ │ └── SettingsViewController.swift │ ├── Main │ │ ├── EmptyDetailViewController.swift │ │ └── MainSplitViewController.swift │ ├── Jobs │ │ ├── ViewComponents │ │ │ ├── JobCellViewModel.swift │ │ │ └── JobTableViewCell.swift │ │ ├── JobsProtocols.swift │ │ ├── JobsInteractor.swift │ │ ├── JobsCoordinator.swift │ │ ├── JobsViewState.swift │ │ ├── JobsViewModel.swift │ │ └── JobsViewController.swift │ └── JobDetail │ │ ├── ViewComponents │ │ ├── Header │ │ │ ├── JobDetailHeaderViewModel.swift │ │ │ ├── BackgroundCurvedView.swift │ │ │ └── JobDetailHeaderView.swift │ │ └── Section │ │ │ └── JobDetailSectionView.swift │ │ ├── JobDetailProtocols.swift │ │ ├── JobDetailViewState.swift │ │ ├── JobDetailCoordinator.swift │ │ └── JobDetailViewModel.swift ├── Networking │ ├── Services │ │ ├── FAQs │ │ │ ├── FAQsClientProtocol.swift │ │ │ ├── FAQsProvider.swift │ │ │ └── FAQsClient.swift │ │ └── Jobs │ │ │ ├── JobsClientProtocol.swift │ │ │ ├── JobsProvider.swift │ │ │ └── JobsClient.swift │ ├── FeedResults │ │ ├── FAQs │ │ │ ├── FAQResult.swift │ │ │ └── FAQsResult.swift │ │ └── Jobs │ │ │ ├── JobsResult.swift │ │ │ └── JobResult.swift │ ├── APIError.swift │ ├── APIClient.swift │ └── Endpoint.swift ├── GithubJobs.entitlements ├── Entities │ ├── FAQ.swift │ └── Job.swift ├── en.lproj │ └── Localizable.strings ├── es-419.lproj │ └── Localizable.strings ├── SceneDelegate.swift ├── AppDelegate.swift ├── Base.lproj │ └── LaunchScreen.storyboard └── Info.plist ├── GithubJobs.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── GithubJobs.xcscheme ├── fastlane ├── Appfile ├── Fastfile └── README.md ├── GithubJobsUITests ├── GithubJobsUITests.swift └── Info.plist ├── GithubJobsTests ├── Mocks │ ├── MockFAQsInteractor.swift │ ├── MockThemeManagerProtocol.swift │ ├── Job+MockInitializer.swift │ ├── MockFeatureFlagsInteractor.swift │ ├── MockSettingsInteractor.swift │ ├── MockJobsInteractor.swift │ └── MockFeatureFlagsManagerProtocol.swift ├── Info.plist ├── Settings │ ├── SettingsInteractorTests.swift │ ├── FAQs │ │ ├── FAQsItemViewModelTests.swift │ │ └── FAQsViewModelTests.swift │ └── SettingsViewModelTests.swift ├── JobDetailViewModelTests.swift └── JobsViewModelTests.swift ├── .github └── workflows │ └── testing.yml ├── .swiftlint.yml ├── README.md ├── .gitignore └── Scripts └── LocalizedStringsGenerator.swift /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /LocalizedStringsScript: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/LocalizedStringsScript -------------------------------------------------------------------------------- /Screenshots/Detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/Screenshots/Detail.png -------------------------------------------------------------------------------- /Screenshots/Home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/Screenshots/Home.png -------------------------------------------------------------------------------- /Screenshots/MacOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/Screenshots/MacOS.png -------------------------------------------------------------------------------- /Screenshots/iPadOS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/Screenshots/iPadOS.png -------------------------------------------------------------------------------- /Screenshots/Detail_Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/Screenshots/Detail_Dark.png -------------------------------------------------------------------------------- /Screenshots/Home_Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/Screenshots/Home_Dark.png -------------------------------------------------------------------------------- /Screenshots/MacOS_Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/Screenshots/MacOS_Dark.png -------------------------------------------------------------------------------- /Screenshots/iPadOS_Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeluxeAlonso/GithubJobs/HEAD/Screenshots/iPadOS_Dark.png -------------------------------------------------------------------------------- /GithubJobs/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Base/UI/BaseView/BaseView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 26/12/24. 6 | // 7 | 8 | typealias BaseView = LoadableView & ErrorPlaceholderView 9 | -------------------------------------------------------------------------------- /GithubJobs.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /GithubJobs/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FAQs/FAQsViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsViewState.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 8/12/24. 6 | // 7 | 8 | enum FAQsViewState { 9 | case loading 10 | case populated 11 | case error 12 | } 13 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | # app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app 2 | # apple_id("[[APPLE_ID]]") # Your Apple email address 3 | 4 | 5 | # For more information about the Appfile, see: 6 | # https://docs.fastlane.tools/advanced/#appfile 7 | -------------------------------------------------------------------------------- /GithubJobs/Assets.xcassets/errorIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "errorIcon.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FetureFlags/FeatureFlagsViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagsViewState.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 15/11/24. 6 | // 7 | 8 | enum FeatureFlagsViewState { 9 | case loading 10 | case populated 11 | case error 12 | } 13 | -------------------------------------------------------------------------------- /GithubJobs/Networking/Services/FAQs/FAQsClientProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsClientProtocol.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 2/12/24. 6 | // 7 | 8 | protocol FAQsClientProtocol: Sendable { 9 | 10 | func getFAQs() async -> Result 11 | 12 | } 13 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/FeatureFlags/FeatureFlagIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagIdentifier.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 5/02/25. 6 | // 7 | 8 | enum FeatureFlagIdentifier: String { 9 | case displayFAQs = "DisplayFAQs" 10 | case customChevron = "UseCustomChevron" 11 | } 12 | -------------------------------------------------------------------------------- /GithubJobs.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Base/UI/HostingController/HostingConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HostingConfiguration.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 29/12/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | final class HostingConfiguration { 12 | 13 | @Published var title: String? 14 | 15 | } 16 | -------------------------------------------------------------------------------- /GithubJobs/Networking/FeedResults/FAQs/FAQResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQResult.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 8/03/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct FAQResult: Decodable { 11 | 12 | let id: String 13 | let title: String 14 | let descriptions: [String] 15 | 16 | } 17 | -------------------------------------------------------------------------------- /GithubJobs.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/Font/View+UIFont.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+UIFont.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 30/04/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | 12 | /// Applies a UIFont to a SwiftUI view 13 | func uiFont(_ font: UIFont) -> some View { 14 | self.font(Font(font)) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /GithubJobs/GithubJobs.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Main/EmptyDetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyDetailViewController.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 8/05/21. 6 | // 7 | 8 | final class EmptyDetailViewController: ViewController { 9 | 10 | override func viewDidLoad() { 11 | super.viewDidLoad() 12 | 13 | view.backgroundColor = .systemBackground 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/UIImageView+Kingfisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImageView+Kingfisher.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | import UIKit 9 | import Kingfisher 10 | 11 | extension UIImageView { 12 | 13 | func setImage(with url: URL?) { 14 | kf.indicatorType = .activity 15 | kf.setImage(with: url) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/ThemeSelection/ThemeSelectionSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeSelectionSection.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 6/07/22. 6 | // 7 | 8 | enum ThemeSelectionSection { 9 | 10 | case main 11 | 12 | var title: String? { 13 | switch self { 14 | case .main: 15 | return "Theme" 16 | } 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/ErrorView/ErrorViewModel+LocalizedError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorViewModel+LocalizedError.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 15/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension ErrorViewModel { 11 | 12 | init(localizedError: Error) { 13 | self.title = LocalizedStrings.errorTitle() 14 | self.subtitles = [localizedError.localizedDescription] 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | default_platform(:ios) 2 | 3 | ENV["FASTLANE_XCODEBUILD_SETTINGS_RETRIES"] = "4" 4 | 5 | platform :ios do 6 | 7 | lane :tests do 8 | run_tests( 9 | devices: ["iPhone 8"], 10 | scheme: "GithubJobs" 11 | ) 12 | end 13 | 14 | lane :lint do 15 | swiftlint( 16 | mode: :lint, 17 | output_file: "swiftlint.result.json", 18 | config_file: ".swiftlint.yml", 19 | raise_if_swiftlint_error: true 20 | ) 21 | end 22 | 23 | end -------------------------------------------------------------------------------- /GithubJobs/Networking/FeedResults/FAQs/FAQsResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsResult.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 2/12/24. 6 | // 7 | 8 | struct FAQsResult: Decodable { 9 | 10 | let faqs: [FAQResult] 11 | 12 | } 13 | 14 | extension FAQsResult { 15 | 16 | init(from decoder: Decoder) throws { 17 | let container = try decoder.singleValueContainer() 18 | self.faqs = try container.decode([FAQResult].self) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /GithubJobs/Networking/FeedResults/Jobs/JobsResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsResult.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | struct JobsResult: Decodable { 9 | 10 | let jobs: [JobResult] 11 | 12 | } 13 | 14 | extension JobsResult { 15 | 16 | init(from decoder: Decoder) throws { 17 | let container = try decoder.singleValueContainer() 18 | self.jobs = try container.decode([JobResult].self) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /GithubJobs/Entities/FAQ.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQ.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 2/12/24. 6 | // 7 | 8 | struct FAQ: Equatable { 9 | 10 | let id: String 11 | let title: String 12 | let descriptions: [String] 13 | 14 | } 15 | 16 | extension FAQ { 17 | 18 | init(_ faqResult: FAQResult) { 19 | self.id = faqResult.id 20 | self.title = faqResult.title 21 | self.descriptions = faqResult.descriptions 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /GithubJobsUITests/GithubJobsUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GithubJobsUITests.swift 3 | // GithubJobsUITests 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import XCTest 9 | 10 | class GithubJobsUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | continueAfterFailure = false 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | // Put teardown code here. This method is called after the invocation of each test method in the class. 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /GithubJobs/Networking/Services/Jobs/JobsClientProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsClientProtocol.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Combine 9 | 10 | protocol JobsClientProtocol: Sendable { 11 | 12 | func getJobs(page: Int) -> AnyPublisher 13 | func getJobs(page: Int) async throws -> JobsResult 14 | func getJobs(description: String) -> AnyPublisher 15 | func getJobs(description: String) async throws -> JobsResult 16 | 17 | } 18 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Protocols/Dequeuable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dequeuable.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol Dequeuable { 11 | 12 | static var dequeueIdentifier: String { get } 13 | 14 | } 15 | 16 | extension Dequeuable where Self: UIView { 17 | 18 | static var dequeueIdentifier: String { 19 | String(describing: self) 20 | } 21 | 22 | } 23 | 24 | extension UITableViewCell: Dequeuable { } 25 | 26 | extension UICollectionViewCell: Dequeuable { } 27 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/SettingsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsSection.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 25/07/22. 6 | // 7 | 8 | enum SettingsSection: Hashable { 9 | 10 | case main(items: [SettingsItemModel] = []) 11 | case debug(items: [SettingsItemModel] = []) 12 | 13 | var items: [SettingsItemModel] { 14 | switch self { 15 | case .main(let items): 16 | return items 17 | case .debug(let items): 18 | return items 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/URLRequest+Headers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequest+Headers.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URLRequest { 11 | 12 | mutating func setJSONContentType() { 13 | setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") 14 | } 15 | 16 | mutating func setHeader(for httpHeaderField: String, with value: String) { 17 | setValue(value, forHTTPHeaderField: httpHeaderField) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Jobs/ViewComponents/JobCellViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobCellViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | protocol JobCellViewModelProtocol { 9 | 10 | var title: String { get } 11 | var company: String { get } 12 | 13 | } 14 | 15 | final class JobCellViewModel: JobCellViewModelProtocol { 16 | 17 | let title: String 18 | let company: String 19 | 20 | init(_ job: Job) { 21 | self.title = job.title 22 | self.company = job.company 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /GithubJobsTests/Mocks/MockFAQsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockFAQsInteractor.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 20/01/25. 6 | // 7 | 8 | @testable import GithubJobs 9 | 10 | final class MockFAQsInteractor: @unchecked Sendable, FAQsInteractorProtocol { 11 | 12 | var getAllFAQsResult: Result<[FAQ], APIError> = .success([]) 13 | private(set) var getAllFAQsCallCount = 0 14 | func getAllFAQs() async -> Result<[FAQ], APIError> { 15 | getAllFAQsCallCount += 1 16 | return getAllFAQsResult 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/FeatureFlags/DisplayFAQsFeatureFlag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DisplayFAQsFeatureFlag.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 5/02/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | final class DisplayFAQsFeatureFlag: MutableFeatureFlagProtocol { 11 | 12 | let identifier: String = FeatureFlagIdentifier.displayFAQs.rawValue 13 | let title: String = "Displays FAQs screen" 14 | 15 | @AppStorage("GithubJobs_DisplayFAQs") 16 | private(set) var value: Bool = false 17 | 18 | func setValue(_ value: Bool) { 19 | self.value = value 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/CharacterSet+Filtering.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CharacterSet+Filtering.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CharacterSet { 11 | 12 | static let urlQueryValueAllowed: CharacterSet = { 13 | let generalDelimitersToEncode = ":#[]@" 14 | let subDelimitersToEncode = "!$&'()*+,;=" 15 | 16 | var allowed = CharacterSet.urlQueryAllowed 17 | allowed.remove(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") 18 | return allowed 19 | }() 20 | 21 | } 22 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/RangeReplaceableCollection+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeReplaceableCollection+Extensions.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 18/02/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RangeReplaceableCollection { 11 | 12 | mutating func removeLast(while predicate: (Element) throws -> Bool) rethrows { 13 | guard let index = try indices.reversed().first(where: { try !predicate(self[$0]) }) else { 14 | removeAll() 15 | return 16 | } 17 | removeSubrange(self.index(after: index)...) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/FeatureFlags/CustomChevronFeatureFlag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomChevronFeatureFlag.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 5/02/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | final class CustomChevronFeatureFlag: MutableFeatureFlagProtocol { 11 | 12 | let identifier: String = FeatureFlagIdentifier.customChevron.rawValue 13 | let title: String = "User custom chevron view" 14 | 15 | @AppStorage("GithubJobs_UseCustomChevron") 16 | private(set) var value: Bool = false 17 | 18 | func setValue(_ value: Bool) { 19 | self.value = value 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /GithubJobs/Networking/FeedResults/Jobs/JobResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobResult.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 8/03/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct JobResult: Decodable, Equatable { 11 | 12 | let id: String 13 | let title: String 14 | let description: String 15 | let company: String 16 | let companyLogoPath: String? 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case id 20 | case title 21 | case description = "how_to_apply" 22 | case company 23 | case companyLogoPath = "company_logo" 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/Dictionary+Format.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary+Format.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | extension Dictionary { 9 | 10 | func percentEscaped() -> String { 11 | map { (key, value) in 12 | let escapedKey = "\(key)".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "" 13 | let escapedValue = "\(value)".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "" 14 | return escapedKey + "=" + escapedValue 15 | } 16 | .joined(separator: "&") 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /GithubJobsTests/Mocks/MockThemeManagerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockThemeManagerProtocol.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 28/11/24. 6 | // 7 | 8 | @testable import GithubJobs 9 | import Combine 10 | import UIKit 11 | 12 | final actor MockThemeManagerProtocol: ThemeManagerProtocol { 13 | 14 | var theme: GithubJobs.Theme = .system 15 | 16 | var themeSubject: CurrentValueSubject = .init(.system) 17 | 18 | private(set) var updateThemeCallCount = 0 19 | func updateTheme(_ theme: Theme) { 20 | self.theme = theme 21 | updateThemeCallCount += 1 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Base/UI/BaseView/LoadableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadableView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 9/01/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | protocol LoadableView: View { 12 | 13 | associatedtype LoadingContent: View 14 | 15 | var loading: LoadingContent { get } 16 | 17 | } 18 | 19 | extension LoadableView { 20 | var loading: some View { 21 | VStack { 22 | ProgressView() 23 | .progressViewStyle(CircularProgressViewStyle()) 24 | .controlSize(.large) 25 | .padding(.top, 24.0) 26 | Spacer() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Base/UI/BaseView/ErrorPlaceholderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorPlaceholderView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/01/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | protocol ErrorPlaceholderView: View { 12 | associatedtype ErrorContent: View 13 | 14 | var error: ErrorContent { get } 15 | var errorViewModel: ErrorViewModel? { get } 16 | } 17 | 18 | extension ErrorPlaceholderView { 19 | var error: some View { 20 | VStack { 21 | Spacer() 22 | errorViewModel.flatMap { 23 | ErrorView(viewModel: $0) 24 | } 25 | Spacer() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/ThemeSelection/ThemeSelectionItemModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeSelectionItemModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 20/07/22. 6 | // 7 | 8 | protocol ThemeSelectionItemModelProtocol { 9 | 10 | var theme: Theme { get } 11 | var isSelected: Bool { get } 12 | 13 | } 14 | 15 | struct ThemeSelectionItemModel: ThemeSelectionItemModelProtocol, Hashable { 16 | 17 | let theme: Theme 18 | let isSelected: Bool 19 | 20 | var title: String { 21 | theme.description 22 | } 23 | 24 | init(_ theme: Theme, isSelected: Bool) { 25 | self.theme = theme 26 | self.isSelected = isSelected 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /GithubJobsTests/Mocks/Job+MockInitializer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Job+MockInitializer.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | @testable import GithubJobs 9 | 10 | extension Job { 11 | 12 | static func with(id: String = "1", 13 | title: String = "Job 1", 14 | description: String = "Description", 15 | company: String = "Company", 16 | companyLogoPath: String? = "/logo.jpg") -> Job { 17 | Job(id: id, title: title, 18 | description: description, 19 | company: company, 20 | companyLogoPath: companyLogoPath) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/JobDetail/ViewComponents/Header/JobDetailHeaderViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobDetailHeaderViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | protocol JobDetailHeaderViewModelProtocol { 9 | 10 | var jobDescription: String { get } 11 | var companyLogoURLString: String? { get } 12 | 13 | } 14 | 15 | struct JobDetailHeaderViewModel: JobDetailHeaderViewModelProtocol { 16 | 17 | private(set) var jobDescription: String 18 | private(set) var companyLogoURLString: String? 19 | 20 | init(_ job: Job) { 21 | self.jobDescription = job.description.htmlToString 22 | self.companyLogoURLString = job.companyLogoPath 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/ErrorView/ErrorViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 15/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ErrorViewModelProtocol { 11 | 12 | var title: String? { get } 13 | var subtitles: [String] { get } 14 | var imageSize: CGSize { get } 15 | 16 | } 17 | 18 | struct ErrorViewModel: ErrorViewModelProtocol { 19 | 20 | let title: String? 21 | let subtitles: [String] 22 | 23 | var imageSize: CGSize { 24 | CGSize(width: 64.0, height: 64.0) 25 | } 26 | 27 | init(title: String? = nil, subtitles: [String] = []) { 28 | self.title = title 29 | self.subtitles = subtitles 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FetureFlags/FeatureFlagToggleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagToggleView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 5/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FeatureFlagToggleView: View { 11 | @ObservedObject var viewModel: ViewModel 12 | 13 | var body: some View { 14 | Toggle(viewModel.title, isOn: $viewModel.value) 15 | .padding(.vertical, viewModel.verticalPadding) 16 | } 17 | } 18 | 19 | struct FeatureFlagToggleViewPreviews: PreviewProvider { 20 | static var previews: some View { 21 | FeatureFlagToggleView(viewModel: FeatureFlagToggleViewModel(FeatureFlag(CustomChevronFeatureFlag()))) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/JobDetail/JobDetailProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobDetailProtocols.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Combine 9 | import Coordinator 10 | 11 | @MainActor 12 | protocol JobDetailViewModelProtocol { 13 | 14 | var viewStatePublisher: Published.Publisher { get } 15 | 16 | var jobTitle: String? { get } 17 | var jobsCells: [JobCellViewModel] { get } 18 | 19 | func getRelatedJobs() 20 | func job(at index: Int) -> Job 21 | 22 | func makeJobDetailHeaderViewModel() -> JobDetailHeaderViewModelProtocol 23 | 24 | } 25 | 26 | @MainActor 27 | protocol JobDetailCoordinatorProtocol: Coordinator { 28 | 29 | func showJobDetail(_ job: Job) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/Theming/Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Theme.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 5/07/22. 6 | // 7 | 8 | import UIKit 9 | 10 | enum Theme: Int, CaseIterable, CustomStringConvertible, Sendable { 11 | case light, dark, system 12 | 13 | func asUserInterfaceStyle() -> UIUserInterfaceStyle { 14 | switch self { 15 | case .light: return .light 16 | case .dark: return .dark 17 | case .system: return .unspecified 18 | } 19 | } 20 | 21 | var description: String { 22 | switch self { 23 | case .system: 24 | return "System" 25 | case .light: 26 | return "Light" 27 | case .dark: 28 | return "Dark" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /GithubJobs/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | GithubJobs 4 | 5 | Created by Alonso on 11/8/20. 6 | 7 | */ 8 | 9 | "jobsTitle" = "Jobs"; 10 | "emptyJobsTitle" = "There are no jobs to show"; 11 | 12 | "relatedJobsTitle" = "Related jobs"; 13 | "emptyRelatedJobsTitle" = "There are no related jobs to show"; 14 | 15 | "themeSelectionTitle" = "Theme selection"; 16 | "themeSelectionHeaderTitle" = "Themes"; 17 | "themeSelectionBarButtonItemTitle" = "Themes"; 18 | 19 | "settingsTitle" = "Settings"; 20 | "settingsThemeSelectionRowTitle" = "Themes"; 21 | "settingsFeatureFlagRowTitle" = "Feature Flags"; 22 | "settingsFAQsRowTitle" = "FAQs"; 23 | 24 | "refreshControlTitle" = "Pull to refresh"; 25 | 26 | "errorTitle" = "Error"; 27 | 28 | "faqsTitle" = "FAQs"; 29 | "featureFlagsTitle" = "Flags"; 30 | -------------------------------------------------------------------------------- /GithubJobsTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /GithubJobsUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/ThemeSelection/ThemeSelectionCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeSelectionCoordinator.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 4/07/22. 6 | // 7 | 8 | import Coordinator 9 | import UIKit 10 | 11 | final class ThemeSelectionCoordinator: BaseCoordinator, ThemeSelectionCoordinatorProtocol { 12 | 13 | override func build() -> UIViewController { 14 | let themeManager = ThemeManager.shared 15 | let interactor = ThemeSelectionInteractor(themeManager: themeManager) 16 | let viewModel = ThemeSelectionViewModel(interactor: interactor) 17 | return ThemeSelectionViewController(themeManager: themeManager, 18 | viewModel: viewModel, 19 | coordinator: self) 20 | 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /GithubJobsTests/Mocks/MockFeatureFlagsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockFeatureFlagsInteractor.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 30/01/25. 6 | // 7 | 8 | @testable import GithubJobs 9 | 10 | final class MockFeatureFlagsInteractor: @unchecked Sendable, FeatureFlagsInteractorProtocol { 11 | 12 | var getAllFeatureFlagsResult: Result<[FeatureFlagProtocol], APIError> = .success([]) 13 | private(set) var getAllFeatureFlagsCallCount = 0 14 | func getAllFeatureFlags() async -> Result<[FeatureFlagProtocol], APIError> { 15 | getAllFeatureFlagsCallCount += 1 16 | return getAllFeatureFlagsResult 17 | } 18 | 19 | private(set) var updateFeatureFlagCallCount = 0 20 | func updateFeatureFlag(identifier: String, value: Bool) async { 21 | updateFeatureFlagCallCount += 1 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/Theming/ThemeManagerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeManagerProtocol.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 24/04/21. 6 | // 7 | 8 | import Combine 9 | 10 | /// Protocol for managing themes within the application. 11 | /// Provides access to the current theme and a way to observe theme changes. 12 | protocol ThemeManagerProtocol: Actor { 13 | 14 | /// The current theme applied to the application. 15 | var theme: Theme { get } 16 | 17 | /// A publisher that emits the current theme and any future theme changes. 18 | /// Use this to observe theme changes across the application. 19 | var themeSubject: CurrentValueSubject { get } 20 | 21 | /// Updates the current application theme. 22 | /// - Parameter theme: The new theme to apply. 23 | func updateTheme(_ theme: Theme) 24 | } 25 | -------------------------------------------------------------------------------- /GithubJobs/es-419.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | GithubJobs 4 | 5 | Created by Alonso on 11/8/20. 6 | 7 | */ 8 | 9 | "jobsTitle" = "Empleos"; 10 | "emptyJobsTitle" = "No hay empleos que mostrar"; 11 | 12 | "relatedJobsTitle" = "Empleos relacionados"; 13 | "emptyRelatedJobsTitle" = "No hay empleos relacionados que mostrar"; 14 | 15 | "themeSelectionTitle" = "Seleccion de temas"; 16 | "themeSelectionHeaderTitle" = "Temas"; 17 | "themeSelectionBarButtonItemTitle" = "Temas"; 18 | 19 | "settingsTitle" = "Configuración"; 20 | "settingsThemeSelectionRowTitle" = "Temas"; 21 | "settingsFeatureFlagRowTitle" = "Feature Flags"; 22 | "settingsFAQsRowTitle" = "Preguntas frecuentes"; 23 | 24 | "refreshControlTitle" = "Refrescar"; 25 | 26 | "errorTitle" = "Error"; 27 | 28 | "faqsTitle" = "Preguntas frecuentes"; 29 | "featureFlagsTitle" = "Flags"; 30 | -------------------------------------------------------------------------------- /GithubJobs/Entities/Job.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Job.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | struct Job: Decodable, Equatable { 9 | 10 | let id: String 11 | let title: String 12 | let description: String 13 | let company: String 14 | let companyLogoPath: String? 15 | 16 | enum CodingKeys: String, CodingKey { 17 | case id 18 | case title 19 | case description = "how_to_apply" 20 | case company 21 | case companyLogoPath = "company_logo" 22 | } 23 | 24 | } 25 | 26 | extension Job { 27 | 28 | init(_ jobResult: JobResult) { 29 | self.id = jobResult.id 30 | self.title = jobResult.title 31 | self.description = jobResult.description 32 | self.company = jobResult.company 33 | self.companyLogoPath = jobResult.companyLogoPath 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/SettingsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsInteractor.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 23/11/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class SettingsInteractor: SettingsInteractorProtocol { 11 | 12 | private let themeManager: ThemeManagerProtocol 13 | private let featureFlagsManager: FeatureFlagsManagerProtocol 14 | 15 | init(themeManager: ThemeManagerProtocol, 16 | featureFlagsManager: FeatureFlagsManagerProtocol) { 17 | self.themeManager = themeManager 18 | self.featureFlagsManager = featureFlagsManager 19 | } 20 | 21 | func getFeatureFlagValue(for identifier: FeatureFlagIdentifier) async -> Bool { 22 | await featureFlagsManager.value(for: identifier) 23 | } 24 | 25 | func getCurrentTheme() async -> Theme { 26 | await themeManager.theme 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /GithubJobsTests/Mocks/MockSettingsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockSettingsInteractor.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 26/11/24. 6 | // 7 | 8 | import UIKit 9 | @testable import GithubJobs 10 | 11 | final class MockSettingsInteractor: @unchecked Sendable, SettingsInteractorProtocol { 12 | 13 | var getFeatureFlagValueResult: Bool = false 14 | private(set) var getFeatureFlagValueCallCount = 0 15 | func getFeatureFlagValue(for identifier: FeatureFlagIdentifier) async -> Bool { 16 | getFeatureFlagValueCallCount += 1 17 | return getFeatureFlagValueResult 18 | } 19 | 20 | var getCurrentThemeResult: Theme = .system 21 | private(set) var getCurrentThemeCallCount = 0 22 | func getCurrentTheme() async -> GithubJobs.Theme { 23 | getCurrentThemeCallCount += 1 24 | return getCurrentThemeResult 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/RefreshControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RefreshControl.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 14/08/22. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | final class RefreshControl: UIRefreshControl { 12 | 13 | private(set) var valueChanged = PassthroughSubject() 14 | 15 | init(title: String, backgroundColor: UIColor = .systemBackground) { 16 | super.init() 17 | self.attributedTitle = NSAttributedString(string: title) 18 | self.backgroundColor = backgroundColor 19 | self.addTarget(self, action: #selector(refreshControlAction), for: .valueChanged) 20 | } 21 | 22 | required init?(coder: NSCoder) { 23 | fatalError("init(coder:) has not been implemented") 24 | } 25 | 26 | @objc private func refreshControlAction() { 27 | valueChanged.send() 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | branches: [ development ] 6 | pull_request: 7 | branches: [ development ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: macos-15 12 | steps: 13 | - uses: maxim-lobanov/setup-xcode@v1 14 | with: 15 | xcode-version: '16.3' 16 | - uses: actions/checkout@v3 17 | - name: Install Bundle 18 | run: bundle install 19 | - name: Install SwiftLint 20 | run: brew install swiftlint 21 | - name: Run swiftlint 22 | run: bundle exec fastlane lint 23 | 24 | test: 25 | needs: lint 26 | runs-on: macos-15 27 | steps: 28 | - uses: maxim-lobanov/setup-xcode@v1 29 | with: 30 | xcode-version: '16.3' 31 | - uses: actions/checkout@v3 32 | - name: Install Bundle 33 | run: bundle install 34 | - name: Run unit tests 35 | run: bundle exec fastlane tests 36 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FAQs/FAQsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsInteractor.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 30/11/24. 6 | // 7 | 8 | protocol FAQsInteractorProtocol: Sendable { 9 | 10 | func getAllFAQs() async -> Result<[FAQ], APIError> 11 | 12 | } 13 | 14 | final class FAQsInteractor: FAQsInteractorProtocol { 15 | 16 | private let faqsClient: FAQsClientProtocol 17 | 18 | // MARK: - Initializers 19 | 20 | init(faqsClient: FAQsClientProtocol) { 21 | self.faqsClient = faqsClient 22 | } 23 | 24 | // MARK: - FAQsInteractorProtocol 25 | 26 | func getAllFAQs() async -> Result<[FAQ], APIError> { 27 | switch await faqsClient.getFAQs() { 28 | case .success(let result): 29 | return .success(result.faqs.map(FAQ.init)) 30 | case .failure(let error): 31 | return .failure(error) 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios tests 19 | 20 | ```sh 21 | [bundle exec] fastlane ios tests 22 | ``` 23 | 24 | 25 | 26 | ### ios lint 27 | 28 | ```sh 29 | [bundle exec] fastlane ios lint 30 | ``` 31 | 32 | 33 | 34 | ---- 35 | 36 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 37 | 38 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 39 | 40 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 41 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/JobDetail/ViewComponents/Header/BackgroundCurvedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundCurvedView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | import UIKit 9 | 10 | final class BackgroundCurvedView: UIView { 11 | 12 | override func draw(_ rect: CGRect) { 13 | let layerHeight = layer.frame.height 14 | let layerWidth = layer.frame.width 15 | 16 | let path = UIBezierPath() 17 | path.move(to: .zero) 18 | path.addLine(to: CGPoint(x: layerWidth, y: 0)) 19 | path.addLine(to: CGPoint(x: layerWidth, y: layerHeight * 0.85)) 20 | path.addQuadCurve(to: CGPoint(x: 0, y: layerHeight * 0.85), 21 | controlPoint: CGPoint(x: layerWidth * 0.5, y: layerHeight + 12.0)) 22 | path.close() 23 | 24 | let shapeLayer = CAShapeLayer() 25 | shapeLayer.path = path.cgPath 26 | layer.mask = shapeLayer 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/ThemeSelection/ThemeSelectionProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeSelectionProtocols.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 4/07/22. 6 | // 7 | 8 | import Combine 9 | 10 | @MainActor 11 | protocol ThemeSelectionViewModelProtocol { 12 | 13 | var themes: CurrentValueSubject<[ThemeSelectionItemModel], Never> { get } 14 | 15 | func screenTitle() -> String? 16 | func headerTitle(for section: Int) -> String? 17 | 18 | func loadThemes() 19 | func isSelected(at index: Int) -> Bool 20 | func selectTheme(at index: Int) 21 | 22 | } 23 | 24 | @MainActor 25 | protocol ThemeSelectionCoordinatorProtocol: AnyObject { 26 | 27 | func dismiss() 28 | 29 | } 30 | 31 | protocol ThemeSelectionInteractorProtocol: Sendable { 32 | 33 | func getAllThemes() async throws -> [ThemeSelectionItemModel] 34 | 35 | @discardableResult 36 | func updateTheme(_ theme: Theme) async throws -> [ThemeSelectionItemModel] 37 | 38 | } 39 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | file_length: 300 2 | function_body_length: 50 3 | function_parameter_count: 5 4 | line_length: 150 5 | disabled_rules: # rule identifiers to exclude from running 6 | - force_cast 7 | - trailing_whitespace 8 | - todo 9 | - weak_delegate 10 | - class_delegate_protocol 11 | excluded: # paths to ignore during linting. Takes precedence over `included`. 12 | - Carthage 13 | - Pods 14 | - 'Scripts/**' 15 | identifier_name: 16 | excluded: # excluded via string array 17 | - id 18 | nesting: 19 | type_level: 3 20 | opt_in_rules: 21 | - explicit_init 22 | - fatal_error_message 23 | - force_unwrapping 24 | - implicit_return 25 | - operator_usage_whitespace 26 | - optional_enum_case_matching 27 | - overridden_super_call 28 | - override_in_extension 29 | - reduce_into 30 | - redundant_nil_coalescing 31 | - untyped_error_in_catch 32 | - unused_control_flow_label 33 | analyzer_rules: 34 | - unused_declaration 35 | - unused_import 36 | -------------------------------------------------------------------------------- /GithubJobsTests/Mocks/MockJobsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockJobsInteractor.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 3/01/22. 6 | // 7 | 8 | @testable import GithubJobs 9 | import Combine 10 | 11 | final class MockJobsInteractor: @unchecked Sendable, JobsInteractorProtocol { 12 | 13 | var getJobResult: AnyPublisher<[Job], APIError>! 14 | var jobs: [Job] = [] 15 | var error: Error? 16 | 17 | func getJobs(page: Int) -> AnyPublisher<[Job], APIError> { 18 | getJobResult 19 | } 20 | 21 | func getJobs(page: Int) async throws -> [Job] { 22 | if let error { 23 | throw error 24 | } 25 | return jobs 26 | } 27 | 28 | func getJobs(description: String) -> AnyPublisher<[Job], APIError> { 29 | getJobResult 30 | } 31 | 32 | func getJobs(description: String) async throws -> [Job] { 33 | if let error { 34 | throw error 35 | } 36 | return jobs 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/ThemeSelection/ThemeSelectionInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeSelectionInteractor.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 13/04/25. 6 | // 7 | 8 | final class ThemeSelectionInteractor: ThemeSelectionInteractorProtocol { 9 | 10 | private let themeManager: ThemeManagerProtocol 11 | 12 | init(themeManager: ThemeManagerProtocol) { 13 | self.themeManager = themeManager 14 | } 15 | 16 | func getAllThemes() async throws -> [ThemeSelectionItemModel] { 17 | let selectedTheme = await themeManager.theme 18 | return Theme.allCases.map { theme in 19 | let isSelected = selectedTheme == theme 20 | return ThemeSelectionItemModel(theme, isSelected: isSelected) 21 | } 22 | } 23 | 24 | @discardableResult 25 | func updateTheme(_ theme: Theme) async throws -> [ThemeSelectionItemModel] { 26 | await themeManager.updateTheme(theme) 27 | return try await getAllThemes() 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Jobs/JobsProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsProtocols.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Combine 9 | import Coordinator 10 | 11 | @MainActor 12 | protocol JobsViewModelProtocol { 13 | 14 | var viewStatePublisher: Published.Publisher { get } 15 | var needsPrefetch: Bool { get } 16 | 17 | var jobsCells: [JobCellViewModel] { get } 18 | 19 | func getJobs() 20 | func refreshJobs() 21 | 22 | func job(at index: Int) -> Job 23 | 24 | } 25 | 26 | protocol JobsInteractorProtocol: Sendable { 27 | 28 | func getJobs(page: Int) -> AnyPublisher<[Job], APIError> 29 | func getJobs(page: Int) async throws -> [Job] 30 | 31 | func getJobs(description: String) -> AnyPublisher<[Job], APIError> 32 | func getJobs(description: String) async throws -> [Job] 33 | 34 | } 35 | 36 | @MainActor 37 | protocol JobsCoordinatorProtocol: Coordinator { 38 | 39 | func showJobDetail(_ job: Job) 40 | func showSettings() 41 | 42 | } 43 | -------------------------------------------------------------------------------- /GithubJobs/Networking/Services/FAQs/FAQsProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsProvider.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 2/12/24. 6 | // 7 | 8 | enum FAQsProvider { 9 | case getAll 10 | } 11 | 12 | extension FAQsProvider: Endpoint { 13 | 14 | var base: String { "https://private-45833-githubjobsapi.apiary-mock.com" } 15 | 16 | var path: String { 17 | switch self { 18 | case .getAll: 19 | return "/faqs" 20 | } 21 | } 22 | 23 | var params: [String: Any]? { 24 | switch self { 25 | case .getAll: 26 | return nil 27 | } 28 | } 29 | 30 | var parameterEncoding: ParameterEnconding { 31 | switch self { 32 | case .getAll: 33 | return .defaultEncoding 34 | } 35 | } 36 | 37 | var method: HTTPMethod { 38 | switch self { 39 | case .getAll: 40 | return .get 41 | } 42 | } 43 | 44 | var headers: [String: String]? { 45 | nil 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Base/UI/HostingController/HostingController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HostingController.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 29/12/24. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | final class HostingController: UIHostingController { 12 | 13 | private let configuration: HostingConfiguration 14 | private var cancellables: Set = [] 15 | 16 | init(rootView: Content, configuration: HostingConfiguration) { 17 | self.configuration = configuration 18 | super.init(rootView: rootView) 19 | 20 | configuration 21 | .$title 22 | .sink { [weak self] title in 23 | guard let self else { return } 24 | self.title = title 25 | self.navigationItem.title = title 26 | }.store(in: &cancellables) 27 | } 28 | 29 | @MainActor 30 | required dynamic init?(coder aDecoder: NSCoder) { 31 | fatalError("init(coder:) has not been implemented") 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/JobDetail/JobDetailViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobDetailViewState.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | enum JobDetailViewState: Equatable { 9 | 10 | case initial 11 | case empty 12 | case populated([Job]) 13 | case error(message: String) 14 | 15 | static func == (lhs: JobDetailViewState, rhs: JobDetailViewState) -> Bool { 16 | switch (lhs, rhs) { 17 | case (.initial, .initial): 18 | return true 19 | case (let .populated(lhsEntities), let .populated(rhsEntities)): 20 | return lhsEntities == rhsEntities 21 | case (.empty, .empty): 22 | return true 23 | case (.error, .error): 24 | return true 25 | default: 26 | return false 27 | } 28 | } 29 | 30 | var currentJobs: [Job] { 31 | switch self { 32 | case .populated(let jobs): 33 | return jobs 34 | case .empty, .error, .initial: 35 | return [] 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/UITableView+Register.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView+Register.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UITableView { 11 | 12 | // MARK: - Cell Register 13 | 14 | func register(cellType: T.Type, bundle: Bundle? = nil) { 15 | let identifier = cellType.dequeueIdentifier 16 | register(cellType, forCellReuseIdentifier: identifier) 17 | } 18 | 19 | // MARK: - Nib Register 20 | 21 | func registerNib(cellType: T.Type, bundle: Bundle? = nil) { 22 | let identifier = cellType.dequeueIdentifier 23 | let nib = UINib(nibName: identifier, bundle: bundle) 24 | register(nib, forCellReuseIdentifier: identifier) 25 | } 26 | 27 | // MARK: - Dequeuing 28 | 29 | func dequeueReusableCell(with type: T.Type, for indexPath: IndexPath) -> T { 30 | dequeueReusableCell(withIdentifier: type.dequeueIdentifier, for: indexPath) as! T 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FAQs/FAQsCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsCoordinator.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 14/11/24. 6 | // 7 | 8 | import Coordinator 9 | import UIKit 10 | 11 | final class FAQsCoordinator: BaseCoordinator { 12 | 13 | override func build() -> UIViewController { 14 | let hostingConfiguration = HostingConfiguration() 15 | 16 | let viewModel = makeViewModel(hostingConfiguration: hostingConfiguration) 17 | let view = FAQsContent(viewModel: viewModel) 18 | return HostingController(rootView: view, configuration: hostingConfiguration) 19 | } 20 | 21 | // MARK: - Builders 22 | 23 | private func makeInteractor() -> FAQsInteractorProtocol { 24 | let faqsClient = FAQsClient() 25 | return FAQsInteractor(faqsClient: faqsClient) 26 | } 27 | 28 | private func makeViewModel(hostingConfiguration: HostingConfiguration) -> some FAQsViewModelProtocol { 29 | FAQsViewModel(interactor: makeInteractor(), 30 | hostingConfiguration: hostingConfiguration) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FetureFlags/FeatureFlagsCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorSelectionCoordinator.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 27/10/24. 6 | // 7 | 8 | import Coordinator 9 | import UIKit 10 | 11 | final class FeatureFlagsCoordinator: BaseCoordinator { 12 | 13 | override func build() -> UIViewController { 14 | let hostingConfiguration = HostingConfiguration() 15 | 16 | let viewModel = makeViewModel(hostingConfiguration: hostingConfiguration) 17 | let view = FeatureFlagsContent(viewModel: viewModel) 18 | return HostingController(rootView: view, configuration: hostingConfiguration) 19 | } 20 | 21 | private func makeInteractor() -> FeatureFlagsInteractorProtocol { 22 | FeatureFlagsInteractor(featureFlagsManager: FeatureFlagsManager.shared) 23 | } 24 | 25 | private func makeViewModel(hostingConfiguration: HostingConfiguration) -> some FeatureFlagsViewModelProtocol { 26 | FeatureFlagsViewModel(interactor: makeInteractor(), 27 | hostingConfiguration: hostingConfiguration) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /GithubJobs/Networking/Services/FAQs/FAQsClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsClient.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 1/12/24. 6 | // 7 | 8 | import Foundation 9 | 10 | final class FAQsClient: FAQsClientProtocol, APIClient { 11 | 12 | let session: URLSession 13 | 14 | // MARK: - Initializers 15 | 16 | init(configuration: URLSessionConfiguration) { 17 | configuration.requestCachePolicy = .reloadIgnoringLocalCacheData 18 | self.session = URLSession(configuration: configuration) 19 | } 20 | 21 | convenience init() { 22 | self.init(configuration: .default) 23 | } 24 | 25 | // MARK: - FAQsClientProtocol 26 | 27 | func getFAQs() async -> Result { 28 | let request = FAQsProvider.getAll.request 29 | do { 30 | let result = try await fetch(with: request, decodingType: FAQsResult.self) 31 | return .success(result) 32 | } catch { 33 | guard let apiError = error as? APIError else { return .failure(.invalidData) } 34 | return .failure(apiError) 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /GithubJobsTests/Mocks/MockFeatureFlagsManagerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockFeatureFlagsManagerProtocol.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 28/11/24. 6 | // 7 | 8 | @testable import GithubJobs 9 | 10 | final actor MockFeatureFlagsManagerProtocol: FeatureFlagsManagerProtocol { 11 | 12 | var allFlags: [FeatureFlagProtocol] = [] 13 | 14 | private(set) var getAllFlagsCallCount = 0 15 | func getAllFlags() -> [any FeatureFlagProtocol] { 16 | getAllFlagsCallCount += 1 17 | return allFlags 18 | } 19 | 20 | private(set) var updateFlagCallCount = 0 21 | func updateFlag(identifier: String, value: Bool) { 22 | updateFlagCallCount += 1 23 | } 24 | 25 | private(set) var valueForIdentifierResult = false 26 | func setValueForIdentifierResult(_ value: Bool) { 27 | valueForIdentifierResult = value 28 | } 29 | 30 | private(set) var valueForIdentifierCallCount = 0 31 | func value(for identifier: FeatureFlagIdentifier) -> Bool { 32 | valueForIdentifierCallCount += 1 33 | return valueForIdentifierResult 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FetureFlags/FeatureFlagsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagsInteractor.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 15/11/24. 6 | // 7 | 8 | protocol FeatureFlagsInteractorProtocol: Sendable { 9 | 10 | func getAllFeatureFlags() async -> Result<[FeatureFlagProtocol], APIError> 11 | 12 | func updateFeatureFlag(identifier: String, value: Bool) async 13 | 14 | } 15 | 16 | final class FeatureFlagsInteractor: FeatureFlagsInteractorProtocol { 17 | 18 | private let featureFlagsManager: FeatureFlagsManagerProtocol 19 | 20 | init(featureFlagsManager: FeatureFlagsManagerProtocol) { 21 | self.featureFlagsManager = featureFlagsManager 22 | } 23 | 24 | func getAllFeatureFlags() async -> Result<[FeatureFlagProtocol], APIError> { 25 | let flags = await featureFlagsManager.getAllFlags() 26 | return flags.isEmpty ? .failure(APIError.invalidData) : .success(flags) 27 | } 28 | 29 | func updateFeatureFlag(identifier: String, value: Bool) async { 30 | await featureFlagsManager.updateFlag(identifier: identifier, value: value) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /GithubJobs/Networking/Services/Jobs/JobsProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsProvider.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | enum JobsProvider { 9 | case getAll(page: Int, description: String) 10 | } 11 | 12 | extension JobsProvider: Endpoint { 13 | 14 | var base: String { "https://private-45833-githubjobsapi.apiary-mock.com" } 15 | 16 | var path: String { 17 | switch self { 18 | case .getAll: 19 | return "/positions" 20 | } 21 | } 22 | 23 | var params: [String: Any]? { 24 | switch self { 25 | case .getAll(let page, let description): 26 | return ["page": page, "description": description] 27 | } 28 | } 29 | 30 | var parameterEncoding: ParameterEnconding { 31 | switch self { 32 | case .getAll: 33 | return .defaultEncoding 34 | } 35 | } 36 | 37 | var method: HTTPMethod { 38 | switch self { 39 | case .getAll: 40 | return .get 41 | } 42 | } 43 | 44 | var headers: [String: String]? { 45 | nil 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /GithubJobs/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Coordinator 9 | import UIKit 10 | 11 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 12 | 13 | var window: UIWindow? 14 | var mainCoordinator: Coordinator! 15 | 16 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 17 | guard let windowsScene = (scene as? UIWindowScene) else { return } 18 | self.window = UIWindow(windowScene: windowsScene) 19 | 20 | mainCoordinator = JobsCoordinator(navigationController: UINavigationController()) 21 | mainCoordinator.start(coordinatorMode: .push) 22 | 23 | let splitVC = MainSplitViewController(themeManager: ThemeManager.shared, preferredDisplayMode: .oneBesideSecondary) 24 | splitVC.viewControllers = [mainCoordinator.navigationController, EmptyDetailViewController(themeManager: ThemeManager.shared)] 25 | 26 | self.window?.rootViewController = splitVC 27 | self.window?.makeKeyAndVisible() 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Jobs/ViewComponents/JobTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobTableViewCell.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | 10 | final class JobTableViewCell: UITableViewCell { 11 | 12 | var viewModel: JobCellViewModel? { 13 | didSet { 14 | setupBindables() 15 | } 16 | } 17 | 18 | // MARK: - Initializer 19 | 20 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 21 | super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) 22 | setupUI() 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | // MARK: - Private 30 | 31 | private func setupUI() { 32 | accessoryType = .disclosureIndicator 33 | textLabel?.numberOfLines = 2 34 | 35 | detailTextLabel?.numberOfLines = 2 36 | detailTextLabel?.textColor = .systemGray 37 | } 38 | 39 | private func setupBindables() { 40 | textLabel?.text = viewModel?.title 41 | detailTextLabel?.text = viewModel?.company 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Main/MainSplitViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainSplitViewController.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 8/05/21. 6 | // 7 | 8 | import UIKit 9 | 10 | final class MainSplitViewController: SplitViewController, UISplitViewControllerDelegate { 11 | 12 | // MARK: - Initializers 13 | 14 | init(themeManager: ThemeManagerProtocol, preferredDisplayMode: UISplitViewController.DisplayMode) { 15 | super.init(themeManager: themeManager) 16 | self.preferredDisplayMode = preferredDisplayMode 17 | } 18 | 19 | required init?(coder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | // MARK: - Lifecycle 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | delegate = self 28 | } 29 | 30 | // MARK: - UISplitViewControllerDelegate 31 | 32 | func splitViewController(_ splitViewController: UISplitViewController, 33 | collapseSecondary secondaryViewController: UIViewController, 34 | onto primaryViewController: UIViewController) -> Bool { 35 | true 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/String+HTML.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+HTML.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | 12 | var htmlToAttributedString: NSAttributedString? { 13 | guard let data = data(using: .utf8) else { return nil } 14 | let attributedStringOptions: [NSAttributedString.DocumentReadingOptionKey: Any] = [ 15 | .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue 16 | ] 17 | return try? NSAttributedString(data: data, 18 | options: attributedStringOptions, 19 | documentAttributes: nil) 20 | } 21 | 22 | var htmlToString: String { 23 | htmlToAttributedString?.string.trailingNewLinesTrimmed ?? "" 24 | } 25 | 26 | var trailingNewLinesTrimmed: String { 27 | var newString = self 28 | 29 | while newString.last?.isNewline == true { 30 | newString = String(newString.dropLast()) 31 | } 32 | 33 | return newString 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/ThemeSelection/ViewComponents/ThemeSelectionSectionHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeSelectionSectionHeaderView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 4/07/22. 6 | // 7 | 8 | import UIKit 9 | 10 | final class ThemeSelectionSectionHeaderView: UICollectionReusableView { 11 | 12 | private lazy var titleLabel: UILabel = { 13 | let label = UILabel() 14 | label.textColor = .systemGray 15 | label.font = FontHelper.Dynamic.footnote 16 | 17 | label.translatesAutoresizingMaskIntoConstraints = false 18 | 19 | return label 20 | }() 21 | 22 | var title: String? { 23 | didSet { 24 | titleLabel.text = title 25 | } 26 | } 27 | 28 | // MARK: - Initializers 29 | 30 | override init(frame: CGRect) { 31 | super.init(frame: frame) 32 | setupUI() 33 | } 34 | 35 | required init?(coder: NSCoder) { 36 | super.init(coder: coder) 37 | setupUI() 38 | } 39 | 40 | // MARK: - Private 41 | 42 | private func setupUI() { 43 | addSubview(titleLabel) 44 | titleLabel.fillSuperview(padding: .init(top: 16, left: 16, bottom: 8, right: 16)) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/SettingsItemModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsItemModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 25/07/22. 6 | // 7 | 8 | struct SettingsItemModel: Hashable, Sendable { 9 | 10 | let title: String 11 | let value: String? 12 | let actionHandler: @Sendable () -> Void 13 | 14 | init(title: String, 15 | value: String? = nil, 16 | actionHandler: @escaping @Sendable () -> Void) { 17 | self.title = title 18 | self.value = value 19 | self.actionHandler = actionHandler 20 | } 21 | 22 | static func == (lhs: SettingsItemModel, rhs: SettingsItemModel) -> Bool { 23 | lhs.title == rhs.title && lhs.value == rhs.value 24 | } 25 | 26 | func hash(into hasher: inout Hasher) { 27 | hasher.combine(title) 28 | hasher.combine(value) 29 | } 30 | 31 | } 32 | 33 | extension SettingsItemModel { 34 | 35 | init?(featureFlagValue: Bool, 36 | title: String, 37 | value: String?, 38 | actionHandler: @escaping @Sendable () -> Void) { 39 | guard featureFlagValue else { return nil } 40 | self.title = title 41 | self.value = value 42 | self.actionHandler = actionHandler 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FAQs/FAQsContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsContent.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 10/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FAQsContent: BaseView { 11 | @ObservedObject var viewModel: ViewModel 12 | 13 | // MARK: - View 14 | 15 | var body: some View { 16 | content 17 | .task { 18 | await viewModel.load() 19 | } 20 | } 21 | 22 | // MARK: - Private 23 | 24 | @ViewBuilder 25 | private var content: some View { 26 | switch viewModel.viewState { 27 | case .loading: loading 28 | case .populated: populated 29 | case .error: error 30 | } 31 | } 32 | 33 | private var populated: some View { 34 | ScrollView { 35 | VStack(spacing: viewModel.verticalSpacing) { 36 | ForEach(viewModel.items, id: \.title) { 37 | FAQsItemContent(viewModel: $0) 38 | } 39 | Spacer() 40 | } 41 | .padding(.top, viewModel.topPadding) 42 | } 43 | } 44 | 45 | // MARK: - ErrorPlaceHolderView 46 | 47 | var errorViewModel: ErrorViewModel? { viewModel.errorViewModel } 48 | } 49 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/ExpandCollapseControl/ExpandCollapseControlStyleConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExpandCollapseControlStyleConfiguration.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 10/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Configuration options for customizing the appearance and behavior of expandable/collapsible controls. 11 | /// 12 | /// Use this struct to configure various aspects of expand/collapse controls, including 13 | /// icon appearance, sizing, and spacing between elements. 14 | struct ExpandCollapseControlStyleConfiguration { 15 | 16 | /// The name of the icon to display when the control is in expanded state. 17 | let expandedIconName: String 18 | 19 | /// The name of the icon to display when the control is in collapsed state. 20 | let collapsedIconName: String 21 | 22 | /// The size dimensions for both expanded and collapsed icons. 23 | let iconSize: CGSize 24 | 25 | /// The padding between the icon and the content that follows it. 26 | let iconTrailingPadding: CGFloat 27 | 28 | /// Vertical spacing between the collapsed and expanded content. 29 | let verticalSpacing: CGFloat 30 | 31 | /// Horizontal spacing between elements in the control. 32 | let horizontalSpacing: CGFloat 33 | } 34 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Jobs/JobsInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsInteractor.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 2/01/22. 6 | // 7 | 8 | import Combine 9 | 10 | final class JobsInteractor: JobsInteractorProtocol { 11 | 12 | private let jobsClient: JobsClientProtocol 13 | 14 | init(jobsClient: JobsClientProtocol) { 15 | self.jobsClient = jobsClient 16 | } 17 | 18 | func getJobs(page: Int) -> AnyPublisher<[Job], APIError> { 19 | jobsClient 20 | .getJobs(page: page) 21 | .map { $0.jobs.map(Job.init) } 22 | .eraseToAnyPublisher() 23 | } 24 | 25 | func getJobs(page: Int) async throws -> [Job] { 26 | let jobsResult = try await jobsClient.getJobs(page: page) 27 | return jobsResult.jobs.map(Job.init) 28 | } 29 | 30 | func getJobs(description: String) -> AnyPublisher<[Job], APIError> { 31 | jobsClient 32 | .getJobs(description: description) 33 | .map { $0.jobs.map(Job.init) } 34 | .eraseToAnyPublisher() 35 | } 36 | 37 | func getJobs(description: String) async throws -> [Job] { 38 | let jobsResult = try await jobsClient.getJobs(description: description) 39 | return jobsResult.jobs.map(Job.init) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Protocols/Themeable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Theme.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 9/08/22. 6 | // 7 | 8 | import UIKit 9 | 10 | @MainActor 11 | protocol Themeable { 12 | 13 | func updateTheme(_ theme: Theme, animated: Bool) 14 | 15 | } 16 | 17 | extension Themeable where Self: UIViewController { 18 | 19 | func updateTheme(_ theme: Theme, animated: Bool) { 20 | let userInterfaceStyle = theme.asUserInterfaceStyle() 21 | if animated { 22 | UIView.transition(with: view, duration: 0.2, options: .transitionCrossDissolve, animations: { 23 | self.overrideUserInterfaceStyle = userInterfaceStyle 24 | }, completion: nil) 25 | 26 | guard let navigationControllerView = navigationController?.view else { return } 27 | UIView.transition(with: navigationControllerView, duration: 0.2, options: .transitionCrossDissolve, animations: { 28 | self.navigationController?.overrideUserInterfaceStyle = userInterfaceStyle 29 | }, completion: nil) 30 | } else { 31 | overrideUserInterfaceStyle = userInterfaceStyle 32 | navigationController?.overrideUserInterfaceStyle = userInterfaceStyle 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/LocalizedStrings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Auto-generated code. Do not modify this file manually. 3 | // 4 | 5 | import Foundation 6 | 7 | protocol Localizable { 8 | var tableName: String { get } 9 | } 10 | 11 | extension Localizable where Self: RawRepresentable, Self.RawValue == String { 12 | var tableName: String { 13 | "Localizable" 14 | } 15 | 16 | func callAsFunction() -> String { 17 | rawValue.localized(tableName: tableName) 18 | } 19 | } 20 | 21 | private extension String { 22 | func localized(bundle: Bundle = .main, 23 | tableName: String, 24 | comment: String = "") -> String { 25 | NSLocalizedString(self, tableName: tableName, value: self, comment: comment) 26 | } 27 | } 28 | 29 | enum LocalizedStrings: String, Localizable { 30 | case jobsTitle 31 | case emptyJobsTitle 32 | case relatedJobsTitle 33 | case emptyRelatedJobsTitle 34 | case themeSelectionTitle 35 | case themeSelectionHeaderTitle 36 | case themeSelectionBarButtonItemTitle 37 | case settingsTitle 38 | case settingsThemeSelectionRowTitle 39 | case settingsFeatureFlagRowTitle 40 | case settingsFAQsRowTitle 41 | case refreshControlTitle 42 | case errorTitle 43 | case faqsTitle 44 | case featureFlagsTitle 45 | } 46 | -------------------------------------------------------------------------------- /GithubJobs/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | import Kingfisher 10 | 11 | @main 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | configureGlobalAppearanceIfNeeded() 16 | ImageCache.default.memoryStorage.config.totalCostLimit = 1000 17 | return true 18 | } 19 | 20 | } 21 | 22 | extension AppDelegate { 23 | 24 | /** 25 | Configures UINavigationBar an UITabBar appearance to have a similar behavior as pre-iOS15. 26 | */ 27 | func configureGlobalAppearanceIfNeeded() { 28 | let navigationBarAppearance = UINavigationBarAppearance() 29 | UINavigationBar.appearance().standardAppearance = navigationBarAppearance 30 | UINavigationBar.appearance().scrollEdgeAppearance = UINavigationBar.appearance().standardAppearance 31 | 32 | let tabBarAppearance = UITabBarAppearance() 33 | UITabBar.appearance().standardAppearance = tabBarAppearance 34 | UITabBar.appearance().scrollEdgeAppearance = UITabBar.appearance().standardAppearance 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/DataSources/TableViewDataSourcePrefetching.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TableViewDataSourcePrefetching.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | 10 | final class TableViewDataSourcePrefetching: NSObject, UITableViewDataSourcePrefetching { 11 | 12 | private let cellCount: Int 13 | private let needsPrefetch: Bool 14 | private let prefetchHandler: () -> Void 15 | 16 | init(cellCount: Int, needsPrefetch: Bool, prefetchHandler: @escaping (() -> Void)) { 17 | self.cellCount = cellCount 18 | self.needsPrefetch = needsPrefetch 19 | self.prefetchHandler = prefetchHandler 20 | } 21 | 22 | // MARK: - Private 23 | 24 | private func isLoadingCell(for indexPath: IndexPath) -> Bool { 25 | indexPath.row >= cellCount - 1 26 | } 27 | 28 | private func prefetchIfNeeded(for indexPaths: [IndexPath]) { 29 | guard needsPrefetch else { return } 30 | if indexPaths.contains(where: isLoadingCell) { 31 | prefetchHandler() 32 | } 33 | } 34 | 35 | // MARK: - UITableViewDataSourcePrefetching 36 | 37 | func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { 38 | prefetchIfNeeded(for: indexPaths) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/ExpandCollapseControl/ExpandCollapseControlContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExpandCollapseControlContent.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 10/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ExpandCollapseControlContent: View { 11 | 12 | @Binding private var isExpanded: Bool 13 | 14 | @ViewBuilder private var collapsedContent: () -> CollapsedContent 15 | @ViewBuilder private var expandedContent: () -> ExpandedContent 16 | 17 | private let styleConfiguration: ExpandCollapseControlStyleConfiguration 18 | 19 | init(isExpanded: Binding, 20 | collapsedContent: @escaping () -> CollapsedContent, 21 | expandedContent: @escaping () -> ExpandedContent, 22 | styleConfiguration: ExpandCollapseControlStyleConfiguration) { 23 | _isExpanded = isExpanded 24 | self.collapsedContent = collapsedContent 25 | self.expandedContent = expandedContent 26 | self.styleConfiguration = styleConfiguration 27 | } 28 | 29 | var body: some View { 30 | DisclosureGroup(isExpanded: $isExpanded, content: expandedContent, label: collapsedContent) 31 | .disclosureGroupStyle(ExpandCollapseControlDisclosureStyle(styleConfiguration: styleConfiguration)) 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/JobDetail/ViewComponents/Section/JobDetailSectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobDetailSectionView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | 10 | final class JobDetailSectionView: UIView { 11 | 12 | private lazy var titleLabel: UILabel = { 13 | let label = UILabel() 14 | label.numberOfLines = 0 15 | label.translatesAutoresizingMaskIntoConstraints = false 16 | label.textAlignment = .center 17 | label.textColor = .black 18 | label.backgroundColor = .systemTeal 19 | label.font = FontHelper.Dynamic.body 20 | label.adjustsFontForContentSizeCategory = true 21 | 22 | return label 23 | }() 24 | 25 | var title: String? { 26 | didSet { 27 | titleLabel.text = title 28 | } 29 | } 30 | 31 | // MARK: - Initializers 32 | 33 | override init(frame: CGRect) { 34 | super.init(frame: frame) 35 | setupUI() 36 | } 37 | 38 | required init?(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | // MARK: - Private 43 | 44 | private func setupUI() { 45 | backgroundColor = .systemBackground 46 | 47 | addSubview(titleLabel) 48 | titleLabel.fillSuperview() 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/Theming/ThemeManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeManager.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 24/04/21. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | @globalActor actor ThemeManager: ThemeManagerProtocol { 12 | 13 | static let shared = ThemeManager() 14 | 15 | @AppStorage("Theme") 16 | private var themeRawValue: Int = Theme.system.rawValue 17 | 18 | init() { 19 | Task { 20 | await setupTheme() 21 | } 22 | } 23 | 24 | // MARK: - Private 25 | 26 | private func setupTheme() { 27 | themeSubject = CurrentValueSubject(storedTheme) 28 | } 29 | 30 | private var storedTheme: Theme { 31 | get { 32 | Theme(rawValue: themeRawValue) ?? .system 33 | } 34 | set { 35 | themeRawValue = newValue.rawValue 36 | // We update the style subject value. 37 | themeSubject.value = newValue 38 | } 39 | } 40 | 41 | // MARK: - ThemeManagerProtocol 42 | 43 | private(set) var themeSubject: CurrentValueSubject = CurrentValueSubject(.system) 44 | 45 | var theme: Theme { 46 | themeSubject.value 47 | } 48 | 49 | func updateTheme(_ theme: Theme) { 50 | self.storedTheme = theme 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/ErrorView/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 15/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ErrorView: View { 11 | let viewModel: ErrorViewModelProtocol 12 | 13 | var body: some View { 14 | VStack(spacing: 16.0) { 15 | icon 16 | VStack(spacing: 8.0) { 17 | title 18 | subtitles 19 | } 20 | } 21 | } 22 | 23 | private var icon: some View { 24 | Image("errorIcon") 25 | .resizable() 26 | .scaledToFit() 27 | .frame(width: viewModel.imageSize.width, height: viewModel.imageSize.height) 28 | } 29 | 30 | private var title: some View { 31 | viewModel.title.flatMap { 32 | Text($0) 33 | .font(.headline) 34 | } 35 | } 36 | 37 | private var subtitles: some View { 38 | ForEach(viewModel.subtitles, id: \.self) { 39 | Text($0) 40 | .font(.subheadline) 41 | } 42 | } 43 | } 44 | 45 | struct ErrorViewPreviews: PreviewProvider { 46 | static var previews: some View { 47 | let viewModel = ErrorViewModel(title: "Title", subtitles: ["Subtitle1", "Subtitle 2"]) 48 | return ErrorView(viewModel: viewModel) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/ThemeSelection/ThemeSelectionViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeSelectionViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 7/07/22. 6 | // 7 | 8 | import Combine 9 | 10 | final class ThemeSelectionViewModel: ThemeSelectionViewModelProtocol { 11 | 12 | private let interactor: ThemeSelectionInteractorProtocol 13 | 14 | // MARK: - Initializers 15 | 16 | init(interactor: ThemeSelectionInteractorProtocol) { 17 | self.interactor = interactor 18 | } 19 | 20 | // MARK: - ThemeSelectionViewModelProtocol 21 | 22 | private(set) var themes = CurrentValueSubject<[ThemeSelectionItemModel], Never>([]) 23 | 24 | func loadThemes() { 25 | Task { 26 | self.themes.value = try await interactor.getAllThemes() 27 | } 28 | } 29 | 30 | func screenTitle() -> String? { 31 | LocalizedStrings.themeSelectionTitle() 32 | } 33 | 34 | func headerTitle(for section: Int) -> String? { 35 | LocalizedStrings.themeSelectionHeaderTitle() 36 | } 37 | 38 | func isSelected(at index: Int) -> Bool { 39 | themes.value[index].isSelected 40 | } 41 | 42 | func selectTheme(at index: Int) { 43 | Task { 44 | let selectedTheme = themes.value[index] 45 | self.themes.value = try await interactor.updateTheme(selectedTheme.theme) 46 | } 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/UICollectionView+Register.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UICollectionView+Register.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 4/07/22. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UICollectionView { 11 | 12 | // MARK: - Register 13 | 14 | func register(cellType: T.Type) { 15 | let identifier = cellType.dequeueIdentifier 16 | register(cellType, forCellWithReuseIdentifier: identifier) 17 | } 18 | 19 | func register(viewType: T.Type, kind: String) { 20 | register(viewType, forSupplementaryViewOfKind: kind, withReuseIdentifier: kind) 21 | } 22 | 23 | // MARK: - Dequeuing 24 | 25 | func dequeueReusableView(with type: T.Type, 26 | kind: String, 27 | for indexPath: IndexPath) -> T { 28 | dequeueReusableSupplementaryView(ofKind: kind, 29 | withReuseIdentifier: kind, 30 | for: indexPath) as! T 31 | } 32 | 33 | func dequeueReusableCell(with type: T.Type, for indexPath: IndexPath) -> T { 34 | dequeueReusableCell(withReuseIdentifier: type.dequeueIdentifier, for: indexPath) as! T 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Base/UI/SplitViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SplitViewController.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 23/07/22. 6 | // 7 | 8 | import UIKit 9 | @preconcurrency import Combine 10 | 11 | class SplitViewController: UISplitViewController, Themeable { 12 | 13 | private let themeManager: ThemeManagerProtocol 14 | 15 | var cancellables: Set = [] 16 | 17 | // MARK: - Initializers 18 | 19 | init(themeManager: ThemeManagerProtocol) { 20 | self.themeManager = themeManager 21 | super.init(nibName: nil, bundle: nil) 22 | } 23 | 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | 28 | // MARK: - Lifecycle 29 | 30 | override func viewDidLoad() { 31 | super.viewDidLoad() 32 | Task { @MainActor in 33 | let currentTheme = await themeManager.theme 34 | updateTheme(currentTheme, animated: false) 35 | 36 | await themeManager.themeSubject 37 | .dropFirst() 38 | .removeDuplicates() 39 | .receive(on: DispatchQueue.main) 40 | .sink { [weak self] theme in 41 | guard let self else { return } 42 | self.updateTheme(theme, animated: true) 43 | }.store(in: &cancellables) 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/ExpandCollapseControl/ExpandCollapseControlDisclosureStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExpandCollapseControlDisclosureStyle.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 10/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ExpandCollapseControlDisclosureStyle: DisclosureGroupStyle { 11 | 12 | let styleConfiguration: ExpandCollapseControlStyleConfiguration 13 | 14 | func makeBody(configuration: Configuration) -> some View { 15 | VStack(alignment: .leading, spacing: styleConfiguration.verticalSpacing) { 16 | HStack(alignment: .center, spacing: styleConfiguration.horizontalSpacing) { 17 | configuration.label 18 | Spacer() 19 | Image(systemName: configuration.isExpanded ? styleConfiguration.expandedIconName : styleConfiguration.collapsedIconName) 20 | .scaledToFit() 21 | .frame(width: styleConfiguration.iconSize.width, height: styleConfiguration.iconSize.height) 22 | .padding(.trailing, styleConfiguration.iconTrailingPadding) 23 | } 24 | .contentShape(Rectangle()) 25 | .onTapGesture { 26 | withAnimation { 27 | configuration.isExpanded.toggle() 28 | } 29 | } 30 | if configuration.isExpanded { 31 | configuration.content 32 | } 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Jobs/JobsCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsCoordinator.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Coordinator 9 | import UIKit 10 | 11 | final class JobsCoordinator: BaseCoordinator, JobsCoordinatorProtocol { 12 | 13 | override func build() -> UIViewController { 14 | let interactor = JobsInteractor(jobsClient: JobsClient()) 15 | let viewModel = JobsViewModel(interactor: interactor) 16 | 17 | return JobsViewController(themeManager: ThemeManager.shared, 18 | viewModel: viewModel, 19 | coordinator: self) 20 | } 21 | 22 | // MARK: - JobsCoordinatorProtocol 23 | 24 | func showJobDetail(_ job: Job) { 25 | let coordinator = JobDetailCoordinator(navigationController: navigationController, 26 | detailNavigationController: UINavigationController(), 27 | job: job) 28 | start(coordinator, coordinatorMode: .push) 29 | } 30 | 31 | func showSettings() { 32 | guard let presentingViewController = navigationController.topViewController else { return } 33 | 34 | let coordinator = SettingsCoordinator(navigationController: UINavigationController()) 35 | start(coordinator, coordinatorMode: .present(presentingViewController: presentingViewController, configuration: nil)) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/UIViewController+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Extension.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 22/02/25. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIViewController { 11 | 12 | func add(asChildViewController viewController: UIViewController?) { 13 | guard let viewController else { return } 14 | 15 | addChild(viewController) 16 | 17 | view.addSubview(viewController.view) 18 | 19 | viewController.view.frame = view.bounds 20 | viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 21 | 22 | viewController.didMove(toParent: self) 23 | } 24 | 25 | func add(asChildViewController viewController: UIViewController?, containerView: UIView) { 26 | guard let viewController, containerView.isDescendant(of: view) else { 27 | return 28 | } 29 | 30 | addChild(viewController) 31 | 32 | containerView.addSubview(viewController.view) 33 | 34 | viewController.view.frame = containerView.bounds 35 | viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 36 | 37 | viewController.didMove(toParent: self) 38 | } 39 | 40 | func remove(asChildViewController viewController: UIViewController?) { 41 | guard let viewController else { return } 42 | 43 | viewController.willMove(toParent: nil) 44 | viewController.view.removeFromSuperview() 45 | viewController.removeFromParent() 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/JobDetail/JobDetailCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobDetailCoordinator.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Coordinator 9 | import UIKit 10 | 11 | final class JobDetailCoordinator: BaseCoordinator, JobDetailCoordinatorProtocol { 12 | 13 | private let job: Job 14 | 15 | init(navigationController: UINavigationController, 16 | detailNavigationController: UINavigationController? = nil, 17 | job: Job) { 18 | self.job = job 19 | super.init(navigationController: navigationController) 20 | self.detailNavigationController = detailNavigationController 21 | } 22 | 23 | override func build() -> UIViewController { 24 | let interactor = JobsInteractor(jobsClient: JobsClient()) 25 | let viewModel = JobDetailViewModel(job, interactor: interactor) 26 | return JobDetailViewController(themeManager: ThemeManager.shared, 27 | viewModel: viewModel, 28 | coordinator: self) 29 | } 30 | 31 | // MARK: - JobDetailCoordinatorProtocol 32 | 33 | func showJobDetail(_ job: Job) { 34 | let navController: UINavigationController 35 | if let detailNavigationController { 36 | navController = detailNavigationController 37 | } else { 38 | navController = navigationController 39 | } 40 | let coordinator = JobDetailCoordinator(navigationController: navController, job: job) 41 | start(coordinator, coordinatorMode: .push) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/LoadingFooterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingFooterView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | 10 | final class LoadingFooterView: UIView { 11 | 12 | static let recommendedFrame: CGRect = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 100) 13 | 14 | private lazy var activityIndicatorView: UIActivityIndicatorView = { 15 | let activityIndicatorView = UIActivityIndicatorView() 16 | activityIndicatorView.style = .large 17 | activityIndicatorView.color = .darkGray 18 | activityIndicatorView.startAnimating() 19 | return activityIndicatorView 20 | }() 21 | 22 | // MARK: - Initializers 23 | 24 | override init(frame: CGRect) { 25 | super.init(frame: frame) 26 | setupUI() 27 | } 28 | 29 | convenience init() { 30 | self.init(frame: LoadingFooterView.recommendedFrame) 31 | } 32 | 33 | required init?(coder aDecoder: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | // MARK: - Private 38 | 39 | private func setupUI() { 40 | setupActivityIndicatorView() 41 | } 42 | 43 | private func setupActivityIndicatorView() { 44 | addSubview(activityIndicatorView) 45 | activityIndicatorView.centerInSuperview() 46 | } 47 | 48 | // MARK: - Public 49 | 50 | func startAnimating() { 51 | activityIndicatorView.startAnimating() 52 | } 53 | 54 | func stopAnimating() { 55 | activityIndicatorView.stopAnimating() 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FAQs/FAQsItemContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsItemContent.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FAQsItemContent: View { 11 | 12 | @ObservedObject var viewModel: ViewModel 13 | 14 | var body: some View { 15 | ZStack { 16 | Color(.systemGroupedBackground) 17 | expandCollapseControl 18 | .padding(viewModel.padding) 19 | } 20 | .cornerRadius(viewModel.padding) 21 | .padding(.horizontal, viewModel.padding) 22 | } 23 | 24 | private var expandCollapseControl: some View { 25 | ExpandCollapseControlContent(isExpanded: $viewModel.expanded, collapsedContent: { 26 | Text(viewModel.title) 27 | .font(.headline) 28 | .multilineTextAlignment(.leading) 29 | }, expandedContent: { 30 | VStack(spacing: viewModel.subtitlesVerticalSpacing) { 31 | ForEach(viewModel.subtitles, id: \.self) { 32 | Text($0) 33 | .font(.body) 34 | .frame(maxWidth: .infinity, alignment: .leading) 35 | .multilineTextAlignment(.leading) 36 | } 37 | } 38 | }, styleConfiguration: viewModel.expandCollapseStyleConfiguration) 39 | } 40 | 41 | } 42 | 43 | struct FAQsItemContentPreviews: PreviewProvider { 44 | 45 | static var previews: some View { 46 | FAQsItemContent(viewModel: FAQsItemViewModel(title: "Title", subtitles: ["Subtitle"])) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/CustomFooterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomFooterView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | 10 | final class CustomFooterView: UIView { 11 | 12 | private static let recommendedFrame: CGRect = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 100) 13 | 14 | private lazy var messageLabel: UILabel = { 15 | let label = UILabel() 16 | label.numberOfLines = 0 17 | label.textAlignment = .center 18 | label.minimumScaleFactor = 0.5 19 | label.adjustsFontSizeToFitWidth = true 20 | label.font = FontHelper.Dynamic.body 21 | label.adjustsFontForContentSizeCategory = true 22 | label.translatesAutoresizingMaskIntoConstraints = false 23 | return label 24 | }() 25 | 26 | // MARK: - Initializers 27 | 28 | init(message: String, frame: CGRect = CustomFooterView.recommendedFrame) { 29 | super.init(frame: frame) 30 | setupUI() 31 | messageLabel.text = message 32 | } 33 | 34 | required init?(coder aDecoder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | // MARK: - Private 39 | 40 | private func setupUI() { 41 | addSubview(messageLabel) 42 | messageLabel.fillSuperview(padding: .init(top: 0, left: Constants.horizontalMargin, 43 | bottom: 0, right: Constants.horizontalMargin)) 44 | } 45 | 46 | // MARK: - Constants 47 | 48 | private struct Constants { 49 | static let horizontalMargin: CGFloat = 8.0 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /GithubJobsTests/Settings/SettingsInteractorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsInteractorTests.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 29/11/24. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | @testable import GithubJobs 11 | 12 | final class SettingsInteractorTests: XCTestCase { 13 | 14 | private var mockThemeManager: MockThemeManagerProtocol! 15 | private var mockFeatureFlagsManager: MockFeatureFlagsManagerProtocol! 16 | private var interactor: SettingsInteractor! 17 | 18 | override func setUpWithError() throws { 19 | try super.setUpWithError() 20 | mockThemeManager = MockThemeManagerProtocol() 21 | mockFeatureFlagsManager = MockFeatureFlagsManagerProtocol() 22 | interactor = SettingsInteractor(themeManager: mockThemeManager, featureFlagsManager: mockFeatureFlagsManager) 23 | } 24 | 25 | override func tearDownWithError() throws { 26 | mockThemeManager = nil 27 | mockFeatureFlagsManager = nil 28 | interactor = nil 29 | try super.tearDownWithError() 30 | } 31 | 32 | func testGetCurrentTheme() async { 33 | // Arrange 34 | await mockThemeManager.updateTheme(.dark) 35 | // Act 36 | let theme = await interactor.getCurrentTheme() 37 | // Assert 38 | XCTAssertEqual(theme, .dark) 39 | } 40 | 41 | func testGetFeatureFlagValue() async { 42 | // Arrange 43 | await mockFeatureFlagsManager.setValueForIdentifierResult(true) 44 | // Act 45 | let value = await interactor.getFeatureFlagValue(for: .customChevron) 46 | // Assert 47 | XCTAssertTrue(value) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Github Jobs App 2 | 3 | [![License](https://img.shields.io/cocoapods/l/DLAutoSlidePageViewController.svg?style=flat)]() 4 | [![Platform](https://img.shields.io/cocoapods/p/DLAutoSlidePageViewController.svg?style=flat)]() 5 | [![Swift 5](https://img.shields.io/badge/Swift-5-orange.svg?style=flat)](https://developer.apple.com/swift/) 6 | 7 | Simple universal app written in Swift 5 using the Github Jobs API: https://jobs.github.com/api 8 | 9 | ## Deprecation note 10 | 11 | Github Jobs API was deprecated so a mock API is being used to display jobs information. 12 | More info about the API deprecation can be found here: 13 | https://developer.github.com/changes/2019-11-05-deprecated-passwords-and-authorizations-api/ 14 | 15 | ## Screenshots 16 | 17 | ### iOS 18 | 19 | 20 | 21 | 22 | ### iPadOS 23 | 24 | 25 | 26 | ### MacOS 27 | 28 | 29 | 30 | ## Third-party libraries 31 | 32 | ### Kingfisher (https://github.com/onevcat/Kingfisher) 33 | Used for downloading and caching images. In the app, it is used to show the company's logo. 34 | 35 | ## Contributing 36 | 37 | Feel free to open an issue or submit a pull request if you have any improvement or feedback. 38 | 39 | ## Author 40 | 41 | Alonso Alvarez, alonso.alvarez.dev@gmail.com 42 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Jobs/JobsViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsViewState.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | enum JobsViewState: Equatable { 9 | 10 | case initial 11 | case empty 12 | case paging([Job], next: Int) 13 | case populated([Job]) 14 | case error(message: String) 15 | 16 | static func == (lhs: JobsViewState, rhs: JobsViewState) -> Bool { 17 | switch (lhs, rhs) { 18 | case (.initial, .initial): 19 | return true 20 | case (let .paging(lhsEntities, _), let .paging(rhsEntities, _)): 21 | return lhsEntities == rhsEntities 22 | case (let .populated(lhsEntities), let .populated(rhsEntities)): 23 | return lhsEntities == rhsEntities 24 | case (.empty, .empty): 25 | return true 26 | case (.error, .error): 27 | return true 28 | default: 29 | return false 30 | } 31 | } 32 | 33 | var currentPage: Int { 34 | switch self { 35 | case .initial, .populated, .empty, .error: 36 | return 1 37 | case .paging(_, let page): 38 | return page 39 | } 40 | } 41 | 42 | var currentJobs: [Job] { 43 | switch self { 44 | case .populated(let jobs): 45 | return jobs 46 | case .paging(let jobs, _): 47 | return jobs 48 | case .empty, .error, .initial: 49 | return [] 50 | } 51 | } 52 | 53 | var needsPrefetch: Bool { 54 | switch self { 55 | case .initial, .populated, .empty, .error: 56 | return false 57 | case .paging: 58 | return true 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /GithubJobs/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 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FAQs/FAQsItemViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsItemViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 25/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | protocol FAQsItemViewModelProtocol: ObservableObject { 12 | 13 | var title: String { get } 14 | var subtitles: [String] { get } 15 | var expanded: Bool { get set } 16 | var expandCollapseStyleConfiguration: ExpandCollapseControlStyleConfiguration { get } 17 | var padding: CGFloat { get } 18 | var subtitlesVerticalSpacing: CGFloat { get } 19 | 20 | } 21 | 22 | final class FAQsItemViewModel: FAQsItemViewModelProtocol { 23 | 24 | let title: String 25 | let subtitles: [String] 26 | @Published var expanded: Bool = false 27 | 28 | var expandCollapseStyleConfiguration: ExpandCollapseControlStyleConfiguration { 29 | ExpandCollapseControlStyleConfiguration(expandedIconName: "minus", 30 | collapsedIconName: "plus", 31 | iconSize: CGSize(width: 16.0, height: 16.0), 32 | iconTrailingPadding: 16.0, 33 | verticalSpacing: 8.0, 34 | horizontalSpacing: 8.0) 35 | } 36 | 37 | var padding: CGFloat { 16.0 } 38 | 39 | var subtitlesVerticalSpacing: CGFloat { 8.0 } 40 | 41 | init(title: String, 42 | subtitles: [String], 43 | expanded: Bool = false) { 44 | self.title = title 45 | self.subtitles = subtitles 46 | self.expanded = expanded 47 | } 48 | 49 | init(faq: FAQ) { 50 | self.title = faq.title 51 | self.subtitles = faq.descriptions 52 | self.expanded = false 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FAQs/FAQsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 10/11/24. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | @MainActor 12 | protocol FAQsViewModelProtocol: ObservableObject { 13 | 14 | var items: [FAQsItemViewModel] { get } 15 | var errorViewModel: ErrorViewModel? { get } 16 | 17 | var viewState: FAQsViewState { get } 18 | 19 | func load() async 20 | 21 | var verticalSpacing: CGFloat { get } 22 | var topPadding: CGFloat { get } 23 | 24 | } 25 | 26 | final class FAQsViewModel: FAQsViewModelProtocol { 27 | 28 | private let interactor: FAQsInteractorProtocol 29 | 30 | @Published var items: [FAQsItemViewModel] = [] 31 | @Published var errorViewModel: ErrorViewModel? 32 | 33 | @Published var viewState: FAQsViewState = .loading 34 | 35 | private weak var hostingConfiguration: HostingConfiguration? 36 | 37 | var verticalSpacing: CGFloat { 16.0 } 38 | 39 | var topPadding: CGFloat { 24.0 } 40 | 41 | // MARK: - Initializers 42 | 43 | init(interactor: FAQsInteractorProtocol, 44 | hostingConfiguration: HostingConfiguration) { 45 | self.interactor = interactor 46 | self.hostingConfiguration = hostingConfiguration 47 | self.hostingConfiguration?.title = LocalizedStrings.faqsTitle() 48 | } 49 | 50 | // MARK: - FAQsViewModelProtocol 51 | 52 | func load() async { 53 | viewState = .loading 54 | switch await interactor.getAllFAQs() { 55 | case .success(let faqs): 56 | self.items = faqs.map { FAQsItemViewModel(faq: $0) } 57 | self.viewState = .populated 58 | case .failure(let error): 59 | self.errorViewModel = ErrorViewModel(localizedError: error) 60 | self.viewState = .error 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /GithubJobs/Networking/Services/Jobs/JobsClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsClient.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class JobsClient: JobsClientProtocol, APIClient { 12 | 13 | let session: URLSession 14 | 15 | // MARK: - Initializers 16 | 17 | init(configuration: URLSessionConfiguration) { 18 | configuration.requestCachePolicy = .reloadIgnoringLocalCacheData 19 | self.session = URLSession(configuration: configuration) 20 | } 21 | 22 | convenience init() { 23 | self.init(configuration: .default) 24 | } 25 | 26 | // MARK: - JobsClientProtocol 27 | 28 | func getJobs(page: Int) -> AnyPublisher { 29 | getJobs(page: page, description: "") 30 | } 31 | 32 | func getJobs(page: Int) async throws -> JobsResult { 33 | try await getJobs(page: page, description: "") 34 | } 35 | 36 | func getJobs(description: String) -> AnyPublisher { 37 | getJobs(page: 0, description: description) 38 | } 39 | 40 | func getJobs(description: String) async throws -> JobsResult { 41 | try await getJobs(page: 0, description: description) 42 | } 43 | 44 | private func getJobs(page: Int, description: String) -> AnyPublisher { 45 | let request = JobsProvider.getAll(page: page, description: description).request 46 | return fetch(with: request) { json -> JobsResult? in 47 | guard let jobsResult = json as? JobsResult else { return nil } 48 | return jobsResult 49 | }.eraseToAnyPublisher() 50 | } 51 | 52 | private func getJobs(page: Int, description: String) async throws -> JobsResult { 53 | let request = JobsProvider.getAll(page: page, description: description).request 54 | return try await fetch(with: request, decodingType: JobsResult.self) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/SettingsCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsCoordinator.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 24/07/22. 6 | // 7 | 8 | import Coordinator 9 | import UIKit 10 | 11 | enum SettingsNavigation { 12 | case theme 13 | case faqs 14 | case featureFlags 15 | } 16 | 17 | final class SettingsCoordinator: BaseCoordinator, SettingsCoordinatorProtocol { 18 | 19 | override func build() -> UIViewController { 20 | let themeManager = ThemeManager.shared 21 | let featureFlagsManager = FeatureFlagsManager.shared 22 | let interactor = SettingsInteractor(themeManager: themeManager, featureFlagsManager: featureFlagsManager) 23 | let viewModel = SettingsViewModel(interactor: interactor) 24 | return SettingsViewController(themeManager: themeManager, 25 | viewModel: viewModel, 26 | coordinator: self) 27 | } 28 | 29 | // MARK: - SettingsCoordinatorProtocol 30 | 31 | func startNavigation(for navigation: SettingsNavigation) { 32 | switch navigation { 33 | case .theme: 34 | showThemeSelection() 35 | case .faqs: 36 | showFAQs() 37 | case .featureFlags: 38 | showFeatureFlags() 39 | } 40 | } 41 | 42 | private func showThemeSelection() { 43 | let coordinator = ThemeSelectionCoordinator(navigationController: navigationController) 44 | start(coordinator, coordinatorMode: .push) 45 | } 46 | 47 | private func showFAQs() { 48 | let coordinator = FAQsCoordinator(navigationController: navigationController) 49 | start(coordinator, coordinatorMode: .push) 50 | } 51 | 52 | private func showFeatureFlags() { 53 | let coordinator = FeatureFlagsCoordinator(navigationController: navigationController) 54 | start(coordinator, coordinatorMode: .push) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /GithubJobs/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/FeatureFlags/FeatureFlagsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorManager.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 27/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Protocol defining the interface for feature flag management 11 | /// This actor-based protocol ensures thread-safe access to feature flags 12 | protocol FeatureFlagsManagerProtocol: Actor { 13 | /// Retrieves all available feature flags 14 | /// - Returns: An array of feature flag objects conforming to `FeatureFlagProtocol` 15 | func getAllFlags() -> [FeatureFlagProtocol] 16 | 17 | /// Updates the value of a specific feature flag 18 | /// - Parameters: 19 | /// - identifier: The unique string identifier for the feature flag 20 | /// - value: The new boolean value to set for the flag 21 | func updateFlag(identifier: String, value: Bool) 22 | 23 | /// Gets the current value of a specific feature flag 24 | /// - Parameter identifier: The identifier of the flag to check 25 | /// - Returns: The boolean value of the flag, or false if the flag doesn't exist 26 | func value(for identifier: FeatureFlagIdentifier) -> Bool 27 | } 28 | 29 | @globalActor actor FeatureFlagsManager: FeatureFlagsManagerProtocol { 30 | 31 | static let shared = FeatureFlagsManager() 32 | 33 | init() {} 34 | 35 | let useCustomChevron: MutableFeatureFlagProtocol = CustomChevronFeatureFlag() 36 | let displayFaqs: MutableFeatureFlagProtocol = DisplayFAQsFeatureFlag() 37 | 38 | private var allFlags: [MutableFeatureFlagProtocol] { 39 | [useCustomChevron, displayFaqs] 40 | } 41 | 42 | func getAllFlags() -> [FeatureFlagProtocol] { 43 | allFlags.map(FeatureFlag.init) 44 | } 45 | 46 | func updateFlag(identifier: String, value: Bool) { 47 | let flagToUpdate = allFlags.first(where: { $0.identifier == identifier }) 48 | flagToUpdate?.setValue(value) 49 | } 50 | 51 | func value(for identifier: FeatureFlagIdentifier) -> Bool { 52 | allFlags.first(where: { $0.identifier == identifier.rawValue })?.value ?? false 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/JobDetail/JobDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobDetailViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Combine 9 | 10 | final class JobDetailViewModel: JobDetailViewModelProtocol { 11 | 12 | private let job: Job 13 | private let interactor: JobsInteractorProtocol 14 | 15 | @Published private var viewState: JobDetailViewState = .initial 16 | 17 | var viewStatePublisher: Published.Publisher { 18 | $viewState 19 | } 20 | 21 | // MARK: - Computed Properties 22 | 23 | var jobTitle: String? { 24 | job.title 25 | } 26 | 27 | var jobsCells: [JobCellViewModel] { 28 | let jobs = viewState.currentJobs 29 | return jobs.map { JobCellViewModel($0) } 30 | } 31 | 32 | // MARK: - Initializers 33 | 34 | init(_ job: Job, interactor: JobsInteractorProtocol) { 35 | self.job = job 36 | self.interactor = interactor 37 | } 38 | 39 | // MARK: - JobDetailViewModelProtocol 40 | 41 | func getRelatedJobs() { 42 | Task { @MainActor in 43 | do { 44 | let retrievedJobs = try await interactor.getJobs(description: job.title) 45 | self.viewState = processResult(retrievedJobs) 46 | } catch let error as APIError { 47 | self.viewState = .error(message: error.description) 48 | } 49 | } 50 | } 51 | 52 | func job(at index: Int) -> Job { 53 | let jobs = viewState.currentJobs 54 | return jobs[index] 55 | } 56 | 57 | func makeJobDetailHeaderViewModel() -> JobDetailHeaderViewModelProtocol { 58 | JobDetailHeaderViewModel(job) 59 | } 60 | 61 | // MARK: - Private 62 | 63 | private func processResult(_ jobs: [Job]) -> JobDetailViewState { 64 | // We only show related jobs that are not the one we are currently displaying. 65 | let filteredJobs = jobs.filter { $0.id != job.id } 66 | guard !filteredJobs.isEmpty else { return .empty } 67 | 68 | return .populated(filteredJobs) 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Utils/FeatureFlags/FeatureFlagProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagProtocol.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 5/02/25. 6 | // 7 | 8 | /// Protocol defining a read-only feature flag. 9 | /// Feature flags control the visibility and availability of features in the application. 10 | protocol FeatureFlagProtocol: Sendable { 11 | 12 | /// Unique identifier for the feature flag. 13 | var identifier: String { get } 14 | 15 | /// Human-readable name of the feature flag. 16 | var title: String { get } 17 | 18 | /// Current state of the feature flag (enabled/disabled). 19 | var value: Bool { get } 20 | 21 | } 22 | 23 | /// Protocol defining a mutable feature flag that can be toggled. 24 | /// Extends the read-only functionality with the ability to modify the flag's state. 25 | protocol MutableFeatureFlagProtocol { 26 | 27 | /// Unique identifier for the feature flag. 28 | var identifier: String { get } 29 | 30 | /// Human-readable name of the feature flag. 31 | var title: String { get } 32 | 33 | /// Current state of the feature flag (enabled/disabled). 34 | var value: Bool { get } 35 | 36 | /// Updates the state of the feature flag. 37 | /// - Parameter value: The new state to apply (true for enabled, false for disabled). 38 | func setValue(_ value: Bool) 39 | 40 | } 41 | 42 | /// Immutable implementation of a feature flag. 43 | /// Creates a read-only snapshot of a mutable feature flag. 44 | final class FeatureFlag: FeatureFlagProtocol { 45 | /// Unique identifier for the feature flag. 46 | let identifier: String 47 | 48 | /// Human-readable name of the feature flag. 49 | let title: String 50 | 51 | /// Current state of the feature flag (enabled/disabled). 52 | let value: Bool 53 | 54 | /// Creates a new immutable feature flag from a mutable feature flag. 55 | /// - Parameter mutableFeatureFlag: The mutable feature flag to copy. 56 | init(_ mutableFeatureFlag: MutableFeatureFlagProtocol) { 57 | self.identifier = mutableFeatureFlag.identifier 58 | self.title = mutableFeatureFlag.title 59 | self.value = mutableFeatureFlag.value 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /GithubJobs/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UIApplicationSupportsIndirectInputEvents 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Jobs/JobsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | @preconcurrency import Combine 9 | 10 | final class JobsViewModel: JobsViewModelProtocol { 11 | 12 | private let interactor: JobsInteractorProtocol 13 | 14 | @Published private var viewState: JobsViewState = .initial 15 | 16 | var viewStatePublisher: Published.Publisher { 17 | $viewState 18 | } 19 | 20 | // MARK: - Computed Properties 21 | 22 | private var currentJobs: [Job] { 23 | viewState.currentJobs 24 | } 25 | 26 | var needsPrefetch: Bool { 27 | viewState.needsPrefetch 28 | } 29 | 30 | var jobsCells: [JobCellViewModel] { 31 | currentJobs.map { JobCellViewModel($0) } 32 | } 33 | 34 | // MARK: - Initializers 35 | 36 | init(interactor: JobsInteractorProtocol) { 37 | self.interactor = interactor 38 | } 39 | 40 | // MARK: - JobsViewModelProtocol 41 | 42 | func getJobs() { 43 | fetchJobs(currentPage: viewState.currentPage) 44 | } 45 | 46 | func refreshJobs() { 47 | fetchJobs(currentPage: .zero) 48 | } 49 | 50 | func job(at index: Int) -> Job { 51 | currentJobs[index] 52 | } 53 | 54 | // MARK: - Private 55 | 56 | private func fetchJobs(currentPage: Int) { 57 | Task { @MainActor in 58 | do { 59 | let retrievedJobs = try await interactor.getJobs(page: currentPage) 60 | self.viewState = processResult(retrievedJobs, currentPage: currentPage, currentJobs: self.currentJobs) 61 | } catch let error as APIError { 62 | self.viewState = .error(message: error.description) 63 | } 64 | } 65 | } 66 | 67 | private func processResult(_ jobs: [Job], 68 | currentPage: Int, 69 | currentJobs: [Job]) -> JobsViewState { 70 | var allJobs = currentPage == 1 ? [] : currentJobs 71 | allJobs.append(contentsOf: jobs) 72 | guard !allJobs.isEmpty else { return .empty } 73 | 74 | return jobs.isEmpty ? .populated(allJobs) : .paging(allJobs, next: currentPage + 1) 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/ViewComponents/Animations/Animator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Animator.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 20/10/22. 6 | // 7 | 8 | import UIKit 9 | 10 | /// An actor-bound utility class that provides fade animation effects for UIKit views and cells. 11 | /// This class contains static methods to apply consistent fade-in animations across different UI components. 12 | /// 13 | /// Usage: 14 | /// ``` 15 | /// // Fade in a UIView 16 | /// Animator.fade(view: myView) 17 | /// 18 | /// // Fade in a UITableViewCell 19 | /// Animator.fade(tableViewCell: cell) 20 | /// ``` 21 | @MainActor 22 | final class Animator { 23 | 24 | /// Applies a fade-in animation to the specified view. 25 | /// 26 | /// This method animates the opacity of the provided view from 0.1 to 1.0 over a duration of 0.5 seconds. 27 | /// The animation allows user interaction during its execution. 28 | /// 29 | /// - Parameters: 30 | /// - view: The `UIView` to animate. 31 | /// - duration: The duration of the animation. 32 | /// - completion: An optional closure to be executed when the animation completes. The closure takes a Boolean 33 | /// parameter indicating whether the animation finished successfully. 34 | static func fade(view: UIView, duration: TimeInterval = 0.5, completion: ((Bool) -> Void)? = nil) { 35 | view.layer.opacity = 0.1 36 | UIView.animateKeyframes(withDuration: duration, delay: 0.0, options: .allowUserInteraction, animations: { 37 | view.layer.opacity = 1 38 | }, completion: completion) 39 | } 40 | 41 | /// Applies a fade-in animation to a table view cell's content view. 42 | /// 43 | /// This is a convenience method that calls `fade(view:)` with the cell's content view. 44 | /// 45 | /// - Parameter tableViewCell: The `UITableViewCell` to animate. 46 | static func fade(tableViewCell: UITableViewCell) { 47 | fade(view: tableViewCell.contentView) 48 | } 49 | 50 | /// Applies a fade-in animation to a collection view cell's content view. 51 | /// 52 | /// This is a convenience method that calls `fade(view:)` with the cell's content view. 53 | /// 54 | /// - Parameter collectionViewCell: The `UICollectionViewCell` to animate. 55 | static func fade(collectionViewCell: UICollectionViewCell) { 56 | fade(view: collectionViewCell.contentView) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Base/UI/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 29/06/22. 6 | // 7 | 8 | import UIKit 9 | @preconcurrency import Combine 10 | 11 | class ViewController: UIViewController, Themeable { 12 | 13 | private let themeManager: ThemeManagerProtocol 14 | 15 | var cancellables: Set = [] 16 | 17 | // MARK: - Lazy properties 18 | 19 | private lazy var closeBarButtonItem: UIBarButtonItem = { 20 | let barButtonItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(closeBarButtonItemTapped)) 21 | return barButtonItem 22 | }() 23 | 24 | // MARK: - Computed properties 25 | 26 | var shouldShowCloseBarButtonItem: Bool { 27 | isBeingPresented || navigationController?.isBeingPresented ?? false 28 | } 29 | 30 | // MARK: - Initializers 31 | 32 | init(themeManager: ThemeManagerProtocol) { 33 | self.themeManager = themeManager 34 | super.init(nibName: nil, bundle: nil) 35 | } 36 | 37 | required init?(coder: NSCoder) { 38 | fatalError("init(coder:) has not been implemented") 39 | } 40 | 41 | // MARK: - Lifecycle 42 | 43 | override func viewDidLoad() { 44 | super.viewDidLoad() 45 | 46 | Task { @MainActor in 47 | let currentTheme = await themeManager.theme 48 | updateTheme(currentTheme, animated: false) 49 | 50 | await themeManager.themeSubject 51 | .dropFirst() 52 | .removeDuplicates() 53 | .receive(on: DispatchQueue.main) 54 | .sink { [weak self] theme in 55 | guard let self else { return } 56 | self.updateTheme(theme, animated: true) 57 | }.store(in: &cancellables) 58 | } 59 | } 60 | 61 | override func viewWillAppear(_ animated: Bool) { 62 | super.viewWillAppear(animated) 63 | configureCloseBarButtonItem() 64 | } 65 | 66 | // MARK: - Private 67 | 68 | private func configureCloseBarButtonItem() { 69 | if shouldShowCloseBarButtonItem { 70 | navigationItem.leftBarButtonItem = closeBarButtonItem 71 | } 72 | } 73 | 74 | // MARK: - Selectors 75 | 76 | @objc func closeBarButtonItemTapped() { 77 | fatalError("Should be implemented in child class") 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /.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 | .DS_Store 92 | 93 | swiftlint.result.json -------------------------------------------------------------------------------- /GithubJobs/Networking/APIError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIError.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Foundation 9 | 10 | enum APIError: Error, CustomStringConvertible { 11 | 12 | case notAuthenticated 13 | case notFound 14 | case networkProblem 15 | case badRequest 16 | case requestFailed 17 | case invalidData 18 | case unknown(HTTPURLResponse?) 19 | 20 | init(response: URLResponse?) { 21 | guard let response = response as? HTTPURLResponse else { 22 | self = .unknown(nil) 23 | return 24 | } 25 | switch response.statusCode { 26 | case 400: 27 | self = .badRequest 28 | case 401: 29 | self = .notAuthenticated 30 | case 404: 31 | self = .notFound 32 | default: 33 | self = .unknown(response) 34 | } 35 | } 36 | 37 | var isAuthError: Bool { 38 | switch self { 39 | case .notAuthenticated: return true 40 | default: return false 41 | } 42 | } 43 | 44 | var description: String { 45 | switch self { 46 | case .notAuthenticated: 47 | return ErrorMessages.Default.NotAuthorized 48 | case .notFound: 49 | return ErrorMessages.Default.NotFound 50 | case .networkProblem, .unknown: 51 | return ErrorMessages.Default.ServerError 52 | case .requestFailed, .badRequest, .invalidData: 53 | return ErrorMessages.Default.RequestFailed 54 | } 55 | } 56 | 57 | } 58 | 59 | extension APIError: LocalizedError { 60 | 61 | public var errorDescription: String? { 62 | switch self { 63 | case .notAuthenticated: 64 | return ErrorMessages.Default.NotAuthorized 65 | case .notFound: 66 | return ErrorMessages.Default.NotFound 67 | case .networkProblem, .unknown: 68 | return ErrorMessages.Default.ServerError 69 | case .requestFailed, .badRequest, .invalidData: 70 | return ErrorMessages.Default.RequestFailed 71 | } 72 | } 73 | 74 | } 75 | 76 | // MARK: - Constants 77 | 78 | extension APIError { 79 | 80 | struct ErrorMessages { 81 | 82 | struct Default { 83 | static let ServerError = "Server Error. Please, try again later." 84 | static let NotAuthorized = "This information is not available." 85 | static let NotFound = "Bad request error." 86 | static let RequestFailed = "Resquest failed. Please, try again later." 87 | } 88 | 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /GithubJobsTests/Settings/FAQs/FAQsItemViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsItemViewModelTests.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 25/01/25. 6 | // 7 | 8 | import XCTest 9 | @testable import GithubJobs 10 | 11 | @MainActor 12 | final class FAQsItemViewModelTests: XCTestCase { 13 | 14 | func testInit() { 15 | // Act 16 | let viewModel = FAQsItemViewModel(title: "Title", subtitles: ["Description"]) 17 | // Assert 18 | XCTAssertEqual(viewModel.title, "Title") 19 | XCTAssertEqual(viewModel.subtitles.count, 1) 20 | XCTAssertEqual(viewModel.subtitles.first, "Description") 21 | XCTAssertFalse(viewModel.expanded) 22 | } 23 | 24 | func testInitWithFAQ() { 25 | // Arrange 26 | let faq = FAQ(id: "ID", title: "Title", descriptions: ["Description"]) 27 | // Act 28 | let viewModel = FAQsItemViewModel(faq: faq) 29 | // Assert 30 | XCTAssertEqual(viewModel.title, "Title") 31 | XCTAssertEqual(viewModel.subtitles.count, 1) 32 | XCTAssertEqual(viewModel.subtitles.first, "Description") 33 | XCTAssertFalse(viewModel.expanded) 34 | } 35 | 36 | func testPadding() { 37 | // Arrange 38 | let viewModel = FAQsItemViewModel(faq: .init(id: "", title: "", descriptions: [])) 39 | // Act 40 | let padding = viewModel.padding 41 | // Assert 42 | XCTAssertEqual(padding, 16.0) 43 | } 44 | 45 | func testSubtitlesVerticalSpacing() { 46 | // Arrange 47 | let viewModel = FAQsItemViewModel(faq: .init(id: "", title: "", descriptions: [])) 48 | // Act 49 | let subtitlesVerticalSpacing = viewModel.subtitlesVerticalSpacing 50 | // Assert 51 | XCTAssertEqual(subtitlesVerticalSpacing, 8.0) 52 | } 53 | 54 | func testExpandCollapseStyleConfiguration() { 55 | // Arrange 56 | let viewModel = FAQsItemViewModel(faq: .init(id: "", title: "", descriptions: [])) 57 | // Act 58 | let expandCollapseStyleConfiguration = viewModel.expandCollapseStyleConfiguration 59 | // Assert 60 | XCTAssertEqual(expandCollapseStyleConfiguration.expandedIconName, "minus") 61 | XCTAssertEqual(expandCollapseStyleConfiguration.collapsedIconName, "plus") 62 | XCTAssertEqual(expandCollapseStyleConfiguration.iconSize, CGSize(width: 16, height: 16)) 63 | XCTAssertEqual(expandCollapseStyleConfiguration.iconTrailingPadding, 16) 64 | XCTAssertEqual(expandCollapseStyleConfiguration.verticalSpacing, 8) 65 | XCTAssertEqual(expandCollapseStyleConfiguration.horizontalSpacing, 8) 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /GithubJobs/Networking/APIClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIClient.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | protocol APIClient { 12 | 13 | var session: URLSession { get } 14 | 15 | func fetch(with request: URLRequest, 16 | decode: @escaping (Decodable) -> T?) -> AnyPublisher 17 | 18 | func fetch(with request: URLRequest, decodingType: T.Type) async throws -> T 19 | 20 | } 21 | 22 | extension APIClient { 23 | 24 | private func decodingTask(with request: URLRequest, 25 | decodingType: T.Type) -> AnyPublisher { 26 | session.dataTaskPublisher(for: request) 27 | .tryMap { result -> Decodable in 28 | guard let httpResponse = result.response as? HTTPURLResponse else { throw APIError.requestFailed } 29 | guard httpResponse.statusCode == 200 || httpResponse.statusCode == 201 else { 30 | throw APIError(response: httpResponse) 31 | } 32 | let decoder = JSONDecoder() 33 | let genericModel = try decoder.decode(decodingType, from: result.data) 34 | return genericModel 35 | } 36 | .eraseToAnyPublisher() 37 | } 38 | 39 | func fetch(with request: URLRequest, 40 | decode: @escaping (Decodable) -> T?) -> AnyPublisher { 41 | decodingTask(with: request, decodingType: T.self).tryMap { model -> T in 42 | guard let decodedModel = decode(model) else { 43 | throw APIError.requestFailed 44 | } 45 | return decodedModel 46 | }.mapError({ error -> APIError in 47 | print(error) 48 | switch error { 49 | case let apiError as APIError: 50 | return apiError 51 | default: 52 | return APIError.requestFailed 53 | } 54 | }).eraseToAnyPublisher() 55 | } 56 | 57 | func fetch(with request: URLRequest, decodingType: T.Type) async throws -> T { 58 | let (data, response) = try await session.data(for: request) 59 | guard let httpResponse = response as? HTTPURLResponse else { 60 | throw APIError.requestFailed 61 | } 62 | guard httpResponse.statusCode == 200 || httpResponse.statusCode == 201 else { 63 | throw APIError(response: httpResponse) 64 | } 65 | do { 66 | let decoder = JSONDecoder() 67 | let genericModel = try decoder.decode(decodingType, from: data) 68 | return genericModel 69 | } catch { 70 | throw APIError.requestFailed 71 | } 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FetureFlags/FeatureFlagsContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagsContent.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 27/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FeatureFlagsContent: BaseView { 11 | @ObservedObject var viewModel: ViewModel 12 | 13 | // MARK: - View 14 | 15 | var body: some View { 16 | content 17 | .task { 18 | await viewModel.load() 19 | } 20 | } 21 | 22 | @ViewBuilder 23 | private var content: some View { 24 | switch viewModel.viewState { 25 | case .loading: loading 26 | case .populated: populated 27 | case .error: error 28 | } 29 | } 30 | 31 | private var populated: some View { 32 | List { 33 | ForEach(viewModel.toggles, id: \.identifier) { 34 | FeatureFlagToggleView(viewModel: $0) 35 | } 36 | } 37 | } 38 | 39 | // MARK: - ErrorPlaceholderView 40 | 41 | var errorViewModel: ErrorViewModel? { viewModel.errorViewModel } 42 | } 43 | 44 | struct FeatureFlagsContentPreviews: PreviewProvider { 45 | static var loadingViewModel: FeatureFlagsViewModel { 46 | let viewModel = FeatureFlagsViewModel(interactor: FeatureFlagsInteractor(featureFlagsManager: FeatureFlagsManager.shared), 47 | hostingConfiguration: HostingConfiguration()) 48 | viewModel.viewState = .loading 49 | return viewModel 50 | } 51 | 52 | static var populatedViewModel: FeatureFlagsViewModel { 53 | let viewModel = FeatureFlagsViewModel(interactor: FeatureFlagsInteractor(featureFlagsManager: FeatureFlagsManager.shared), 54 | hostingConfiguration: HostingConfiguration()) 55 | viewModel.toggles = [FeatureFlagToggleViewModel(FeatureFlag(CustomChevronFeatureFlag()))] 56 | viewModel.viewState = .populated 57 | return viewModel 58 | } 59 | 60 | static var errorViewModel: FeatureFlagsViewModel { 61 | let viewModel = FeatureFlagsViewModel(interactor: FeatureFlagsInteractor(featureFlagsManager: FeatureFlagsManager.shared), 62 | hostingConfiguration: HostingConfiguration()) 63 | viewModel.errorViewModel = ErrorViewModel(title: "Error", subtitles: ["Error Subtitle"]) 64 | viewModel.viewState = .error 65 | return viewModel 66 | } 67 | 68 | static var previews: some View { 69 | FeatureFlagsContent(viewModel: populatedViewModel) 70 | .previewDisplayName("Populated") 71 | 72 | FeatureFlagsContent(viewModel: loadingViewModel) 73 | .previewDisplayName("Loading") 74 | 75 | FeatureFlagsContent(viewModel: errorViewModel) 76 | .previewDisplayName("Error") 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /GithubJobs/Networking/Endpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Endpoint.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import Foundation 9 | 10 | enum HTTPMethod: String { 11 | case get = "GET" 12 | case post = "POST" 13 | } 14 | 15 | enum ParameterEnconding { 16 | case defaultEncoding 17 | case jsonEncoding 18 | case compositeEncoding 19 | } 20 | 21 | protocol Endpoint { 22 | 23 | var base: String { get } 24 | var path: String { get } 25 | var headers: [String: String]? { get } 26 | var params: [String: Any]? { get } 27 | var parameterEncoding: ParameterEnconding { get } 28 | var method: HTTPMethod { get } 29 | 30 | } 31 | 32 | extension Endpoint { 33 | 34 | var urlComponents: URLComponents { 35 | guard var components = URLComponents(string: base) else { 36 | fatalError("Invalid Base URL") 37 | } 38 | components.path = path 39 | var queryItems: [URLQueryItem] = [] 40 | switch parameterEncoding { 41 | case .defaultEncoding: 42 | if let params = params, method == .get { 43 | queryItems.append(contentsOf: params.map { 44 | URLQueryItem(name: "\($0)", value: "\($1)") 45 | }) 46 | } 47 | case .compositeEncoding: 48 | if let params = params, 49 | let queryParams = params["query"] as? [String: Any] { 50 | queryItems.append(contentsOf: queryParams.map { 51 | URLQueryItem(name: "\($0)", value: "\($1)") 52 | }) 53 | } 54 | case .jsonEncoding: 55 | break 56 | } 57 | 58 | components.queryItems = queryItems 59 | return components 60 | } 61 | 62 | var request: URLRequest { 63 | guard let url = urlComponents.url else { 64 | fatalError("Invalid URL components") 65 | } 66 | var request = URLRequest(url: url) 67 | request.httpMethod = method.rawValue 68 | 69 | if let headers = headers { 70 | for (key, value) in headers { 71 | request.setHeader(for: key, with: value) 72 | } 73 | } 74 | 75 | guard let params = params, method != .get else { return request } 76 | 77 | switch parameterEncoding { 78 | case .defaultEncoding: 79 | request.httpBody = params.percentEscaped().data(using: .utf8) 80 | case .jsonEncoding: 81 | request.setJSONContentType() 82 | let jsonData = try? JSONSerialization.data(withJSONObject: params) 83 | request.httpBody = jsonData 84 | case .compositeEncoding: 85 | if let bodyParams = params["body"] as? [String: Any] { 86 | request.setJSONContentType() 87 | let jsonData = try? JSONSerialization.data(withJSONObject: bodyParams) 88 | request.httpBody = jsonData 89 | } 90 | } 91 | 92 | return request 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 24/07/22. 6 | // 7 | 8 | import Combine 9 | 10 | final class SettingsViewModel: SettingsViewModelProtocol { 11 | 12 | private let interactor: SettingsInteractorProtocol 13 | 14 | @Published private var sectionModels: [SettingsSection] = [] 15 | 16 | var sectionModelsPublisher: Published<[SettingsSection]>.Publisher { 17 | $sectionModels 18 | } 19 | 20 | private(set) var didUpdateNavigation = PassthroughSubject() 21 | 22 | init(interactor: SettingsInteractorProtocol) { 23 | self.interactor = interactor 24 | } 25 | 26 | // MARK: - SettingsViewModelProtocol 27 | 28 | func screenTitle() -> String? { 29 | LocalizedStrings.settingsTitle() 30 | } 31 | 32 | func selectItem(at index: Int, and section: Int) { 33 | let section = sectionModels[section] 34 | section.items[index].actionHandler() 35 | } 36 | 37 | func loadItems() async { 38 | var sections = [await createMainSection()] 39 | #if DEBUG 40 | sections.append(await createDebugSection()) 41 | #endif 42 | sectionModels = sections 43 | } 44 | 45 | // MARK: - Private 46 | 47 | private func createMainSection() async -> SettingsSection { 48 | let items = [ 49 | SettingsItemModel(title: LocalizedStrings.settingsThemeSelectionRowTitle(), 50 | value: await interactor.getCurrentTheme().description, 51 | actionHandler: { [weak self] in 52 | Task { @MainActor in 53 | self?.navigate(to: .theme) 54 | } 55 | }), 56 | SettingsItemModel(featureFlagValue: await interactor.getFeatureFlagValue(for: .displayFAQs), 57 | title: LocalizedStrings.settingsFAQsRowTitle(), 58 | value: nil, 59 | actionHandler: { [weak self] in 60 | Task { @MainActor in 61 | self?.navigate(to: .faqs) 62 | } 63 | }) 64 | ].compactMap { $0 } 65 | return .main(items: items) 66 | } 67 | 68 | private func createDebugSection() async -> SettingsSection { 69 | let items = [ 70 | SettingsItemModel(title: LocalizedStrings.settingsFeatureFlagRowTitle(), 71 | value: nil, 72 | actionHandler: { [weak self] in 73 | Task { @MainActor in 74 | self?.navigate(to: .featureFlags) 75 | } 76 | }) 77 | ].compactMap { $0 } 78 | return .debug(items: items) 79 | } 80 | 81 | private func navigate(to navigation: SettingsNavigation) { 82 | didUpdateNavigation.send(navigation) 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/FetureFlags/FeatureFlagsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorSelectionViewModel.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 27/10/24. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | @MainActor 12 | protocol FeatureFlagsViewModelProtocol: ObservableObject { 13 | 14 | var toggles: [FeatureFlagToggleViewModel] { get } 15 | var errorViewModel: ErrorViewModel? { get } 16 | 17 | var viewState: FeatureFlagsViewState { get } 18 | 19 | func load() async 20 | 21 | } 22 | 23 | final class FeatureFlagsViewModel: FeatureFlagsViewModelProtocol { 24 | 25 | @Published var toggles: [FeatureFlagToggleViewModel] = [] 26 | @Published var errorViewModel: ErrorViewModel? 27 | 28 | @Published var viewState: FeatureFlagsViewState = .loading 29 | 30 | private let interactor: FeatureFlagsInteractorProtocol 31 | 32 | private weak var hostingConfiguration: HostingConfiguration? 33 | 34 | init(interactor: FeatureFlagsInteractorProtocol, 35 | hostingConfiguration: HostingConfiguration) { 36 | self.interactor = interactor 37 | self.hostingConfiguration = hostingConfiguration 38 | self.hostingConfiguration?.title = LocalizedStrings.featureFlagsTitle() 39 | } 40 | 41 | func load() async { 42 | viewState = .loading 43 | switch await interactor.getAllFeatureFlags() { 44 | case .success(let flags): 45 | self.toggles = flags.map { 46 | FeatureFlagToggleViewModel($0) { [weak self] identifier, value in 47 | self?.updateFeatureFlag(identifier: identifier, value: value) 48 | } 49 | } 50 | self.viewState = .populated 51 | case .failure(let error): 52 | self.errorViewModel = ErrorViewModel(localizedError: error) 53 | self.viewState = .error 54 | } 55 | 56 | } 57 | 58 | private func updateFeatureFlag(identifier: String, value: Bool) { 59 | Task { 60 | await interactor.updateFeatureFlag(identifier: identifier, value: value) 61 | } 62 | } 63 | 64 | } 65 | 66 | // MARK: - Toggle 67 | 68 | @MainActor 69 | protocol FeatureFlagToggleViewModelProtocol: ObservableObject { 70 | 71 | var identifier: String { get } 72 | var title: String { get } 73 | var value: Bool { get set } 74 | 75 | var verticalPadding: CGFloat { get } 76 | 77 | } 78 | 79 | final class FeatureFlagToggleViewModel: FeatureFlagToggleViewModelProtocol { 80 | 81 | let identifier: String 82 | let title: String 83 | 84 | @Published var value: Bool 85 | 86 | private let onTapHandler: OnTapHandler? 87 | 88 | typealias OnTapHandler = (String, Bool) -> Void 89 | 90 | private var cancellables: Set = [] 91 | 92 | var verticalPadding: CGFloat { 93 | 4.0 94 | } 95 | 96 | init(_ featureFlag: FeatureFlagProtocol, onTapHandler: OnTapHandler? = nil) { 97 | self.identifier = featureFlag.identifier 98 | self.title = featureFlag.title 99 | self.value = featureFlag.value 100 | self.onTapHandler = onTapHandler 101 | 102 | setupBindables() 103 | } 104 | 105 | private func setupBindables() { 106 | $value 107 | .dropFirst() 108 | .sink { [weak self] value in 109 | guard let self else { return } 110 | self.onTapHandler?(self.identifier, value) 111 | } 112 | .store(in: &cancellables) 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /GithubJobsTests/JobDetailViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobDetailViewModelTests.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | @testable import GithubJobs 11 | 12 | @MainActor 13 | final class JobDetailViewModelTests: XCTestCase { 14 | 15 | private var jobsInteractor: MockJobsInteractor! 16 | private var viewModelToTest: JobDetailViewModel! 17 | 18 | private var cancellables: Set = [] 19 | 20 | override func setUp() async throws { 21 | jobsInteractor = MockJobsInteractor() 22 | viewModelToTest = JobDetailViewModel(Job.with(id: "1"), interactor: jobsInteractor) 23 | } 24 | 25 | override func tearDown() async throws { 26 | jobsInteractor = nil 27 | viewModelToTest = nil 28 | } 29 | 30 | func testJobTitle() { 31 | // Act 32 | let title = viewModelToTest.jobTitle 33 | // Assert 34 | XCTAssertEqual(title, "Job 1") 35 | } 36 | 37 | func testGetRelatedJobsPopulated() { 38 | // Arrange 39 | let jobsToTest = [Job.with(id: "2")] 40 | let expectation = XCTestExpectation(description: "State is set to populated") 41 | // Act 42 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 43 | state == .populated(jobsToTest) ? expectation.fulfill() : XCTFail("State wasn't set to populated") 44 | }.store(in: &cancellables) 45 | jobsInteractor.jobs = jobsToTest 46 | viewModelToTest.getRelatedJobs() 47 | // Assert 48 | wait(for: [expectation], timeout: 1) 49 | } 50 | 51 | func testGetRelatedJobsEmpty() { 52 | // Arrange 53 | let jobsToTest: [Job] = [] 54 | let expectation = XCTestExpectation(description: "State is set to empty") 55 | // Act 56 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 57 | state == .empty ? expectation.fulfill() : XCTFail("State wasn't set to empty") 58 | }.store(in: &cancellables) 59 | jobsInteractor.getJobResult = Result.success(jobsToTest).publisher.eraseToAnyPublisher() 60 | viewModelToTest.getRelatedJobs() 61 | // Assert 62 | wait(for: [expectation], timeout: 1) 63 | } 64 | 65 | func testGetRelatedJobsEmptyAfterFilter() { 66 | // Arrange 67 | let jobsToTest = [Job.with(id: "1")] 68 | let expectation = XCTestExpectation(description: "State is set to empty because it solely fetched itself") 69 | // Act 70 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 71 | state == .empty ? expectation.fulfill() : XCTFail("State wasn't set to empty") 72 | }.store(in: &cancellables) 73 | jobsInteractor.getJobResult = Result.success(jobsToTest).publisher.eraseToAnyPublisher() 74 | viewModelToTest.getRelatedJobs() 75 | // Assert 76 | wait(for: [expectation], timeout: 1) 77 | } 78 | 79 | func testGetJobsError() { 80 | // Arrange 81 | let errorToTest = APIError.badRequest 82 | let expectation = XCTestExpectation(description: "State is set to error") 83 | // Act 84 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 85 | state == .error(message: errorToTest.description) ? expectation.fulfill() : XCTFail("State wasn't set to error") 86 | }.store(in: &cancellables) 87 | jobsInteractor.error = APIError.badRequest 88 | viewModelToTest.getRelatedJobs() 89 | // Assert 90 | wait(for: [expectation], timeout: 1) 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /GithubJobs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "87e304a1b7df7f4b80b93e5b5c41eb613f6c5b56eae8605c51ee2053f56a2cf2", 3 | "pins" : [ 4 | { 5 | "identity" : "collectionconcurrencykit", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", 8 | "state" : { 9 | "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", 10 | "version" : "0.2.0" 11 | } 12 | }, 13 | { 14 | "identity" : "coordinator", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/DeluxeAlonso/Coordinator.git", 17 | "state" : { 18 | "revision" : "bbcf76d9fa24354a1541f278f4e438f69dfeb4a5", 19 | "version" : "0.3.0" 20 | } 21 | }, 22 | { 23 | "identity" : "cryptoswift", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", 26 | "state" : { 27 | "revision" : "729e01bc9b9dab466ac85f21fb9ee2bc1c61b258", 28 | "version" : "1.8.4" 29 | } 30 | }, 31 | { 32 | "identity" : "kingfisher", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/onevcat/Kingfisher.git", 35 | "state" : { 36 | "revision" : "1a0c2df04b31ed7aa318354f3583faea24f006fc", 37 | "version" : "5.15.8" 38 | } 39 | }, 40 | { 41 | "identity" : "sourcekitten", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/jpsim/SourceKitten.git", 44 | "state" : { 45 | "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7", 46 | "version" : "0.35.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-argument-parser", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-argument-parser.git", 53 | "state" : { 54 | "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", 55 | "version" : "1.2.3" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-syntax", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/swiftlang/swift-syntax.git", 62 | "state" : { 63 | "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", 64 | "version" : "600.0.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swiftlint", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/realm/SwiftLint.git", 71 | "state" : { 72 | "revision" : "eba420f77846e93beb98d516b225abeb2fef4ca2", 73 | "version" : "0.58.2" 74 | } 75 | }, 76 | { 77 | "identity" : "swiftytexttable", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", 80 | "state" : { 81 | "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", 82 | "version" : "0.9.0" 83 | } 84 | }, 85 | { 86 | "identity" : "swxmlhash", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/drmohundro/SWXMLHash.git", 89 | "state" : { 90 | "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", 91 | "version" : "7.0.2" 92 | } 93 | }, 94 | { 95 | "identity" : "yams", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/jpsim/Yams.git", 98 | "state" : { 99 | "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b", 100 | "version" : "5.3.1" 101 | } 102 | } 103 | ], 104 | "version" : 3 105 | } 106 | -------------------------------------------------------------------------------- /GithubJobsTests/Settings/FAQs/FAQsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FAQsViewModelTests.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 18/01/25. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | @testable import GithubJobs 11 | 12 | @MainActor 13 | final class FAQsViewModelTests: XCTestCase { 14 | 15 | private var mockInteractor: MockFAQsInteractor! 16 | private var viewModel: FAQsViewModel! 17 | 18 | private var cancellables: Set = [] 19 | 20 | override func setUp() async throws { 21 | mockInteractor = MockFAQsInteractor() 22 | viewModel = FAQsViewModel(interactor: mockInteractor, hostingConfiguration: HostingConfiguration()) 23 | } 24 | 25 | override func tearDown() async throws { 26 | mockInteractor = nil 27 | viewModel = nil 28 | } 29 | 30 | func testLoadSuccess() async throws { 31 | // Arrange 32 | let faqsExpectation = expectation(description: "We should retrieve FAQs models") 33 | let stateExpectation = expectation(description: "State is set to populated") 34 | let faqs = [ 35 | FAQ(id: "ID", title: "Title", descriptions: ["Description"]), 36 | FAQ(id: "ID2", title: "Title2", descriptions: ["Description2"]) 37 | ] 38 | mockInteractor.getAllFAQsResult = .success(faqs) 39 | // Act 40 | viewModel.$items 41 | .dropFirst() 42 | .sink { faqs in 43 | XCTAssertEqual(faqs.count, 2) 44 | faqsExpectation.fulfill() 45 | } 46 | .store(in: &cancellables) 47 | viewModel.$viewState 48 | .dropFirst(2) 49 | .sink { state in 50 | XCTAssertEqual(state, .populated) 51 | stateExpectation.fulfill() 52 | } 53 | .store(in: &cancellables) 54 | await viewModel.load() 55 | // Assert 56 | await fulfillment(of: [faqsExpectation, stateExpectation], timeout: 1.0) 57 | } 58 | 59 | func testLoadError() async throws { 60 | // Arrange 61 | let errorModelExpectation = expectation(description: "We should get an error view model") 62 | let stateExpectation = expectation(description: "State is set to error") 63 | mockInteractor.getAllFAQsResult = .failure(.badRequest) 64 | // Act 65 | viewModel.$items 66 | .dropFirst() 67 | .sink { _ in 68 | XCTFail("We should not get any FAQs item") 69 | } 70 | .store(in: &cancellables) 71 | viewModel.$errorViewModel 72 | .dropFirst() 73 | .sink { errorViewModel in 74 | XCTAssertNotNil(errorViewModel) 75 | errorModelExpectation.fulfill() 76 | } 77 | .store(in: &cancellables) 78 | viewModel.$viewState 79 | .dropFirst(2) 80 | .sink { state in 81 | XCTAssertEqual(state, .error) 82 | stateExpectation.fulfill() 83 | } 84 | .store(in: &cancellables) 85 | await viewModel.load() 86 | // Assert 87 | await fulfillment(of: [errorModelExpectation, stateExpectation], timeout: 1.0) 88 | } 89 | 90 | func testVerticalSpacing() { 91 | // Act 92 | let verticalSpacing = viewModel.verticalSpacing 93 | // Assert 94 | XCTAssertEqual(verticalSpacing, 16.0) 95 | } 96 | 97 | func testTopPadding() { 98 | // Act 99 | let topPadding = viewModel.topPadding 100 | // Assert 101 | XCTAssertEqual(topPadding, 24.0) 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /GithubJobs/Assets.xcassets/errorIcon.imageset/errorIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 22 | 43 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 61 | 66 | 72 | error 74 | 75 | 76 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/SettingsProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsCoordinator.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 24/07/22. 6 | // 7 | 8 | import Combine 9 | import Coordinator 10 | import UIKit 11 | 12 | /** 13 | * SettingsViewModelProtocol defines the interface for the Settings screen view model. 14 | * 15 | * This protocol separates the presentation logic from the view controller, 16 | * providing data and handling user interactions for the Settings screen. 17 | * It manages the display of settings sections and items, and coordinates 18 | * navigation requests through a coordinator. 19 | */ 20 | @MainActor 21 | protocol SettingsViewModelProtocol { 22 | 23 | /// Publisher for section models that represent the structure of the settings screen. 24 | /// The view subscribes to this publisher to receive updates when settings data changes. 25 | var sectionModelsPublisher: Published<[SettingsSection]>.Publisher { get } 26 | 27 | /// Subject that emits navigation events when a settings option requires navigation. 28 | /// The coordinator observes this subject to respond to navigation requests. 29 | var didUpdateNavigation: PassthroughSubject { get } 30 | 31 | /** 32 | * Asynchronously loads settings items from the data source. 33 | * This method populates the settings sections with appropriate items 34 | * based on current application state and user preferences. 35 | */ 36 | func loadItems() async 37 | 38 | /** 39 | * Returns the localized title for the settings screen. 40 | * 41 | * - Returns: A string representing the screen title, or nil if a default title should be used. 42 | */ 43 | func screenTitle() -> String? 44 | 45 | /** 46 | * Handles the selection of a settings item at the specified index. 47 | * This typically triggers navigation or performs an action based on the selected item. 48 | * 49 | * - Parameter index: The index of the selected item within its section. 50 | * - Parameter section: The index of the section containing the selected item. 51 | */ 52 | func selectItem(at index: Int, and section: Int) 53 | } 54 | 55 | /** 56 | * SettingsCoordinatorProtocol defines the navigation responsibilities for the Settings flow. 57 | * 58 | * Following the Coordinator pattern, this protocol abstracts navigation logic from the view model, 59 | * allowing the Settings flow to be managed independently of its presentation. 60 | * The coordinator responds to navigation requests and manages the presentation of new screens. 61 | */ 62 | @MainActor 63 | protocol SettingsCoordinatorProtocol: Coordinator { 64 | 65 | /** 66 | * Initiates navigation to a new screen based on the specified navigation type. 67 | * 68 | * - Parameter navigation: The type of navigation to perform, defining the destination 69 | * and any parameters needed for the navigation. 70 | */ 71 | func startNavigation(for navigation: SettingsNavigation) 72 | } 73 | 74 | /** 75 | * SettingsInteractorProtocol defines the interface for retrieving application settings data. 76 | * 77 | * This protocol abstracts the business logic and data access for settings-related operations, 78 | * providing a clean separation between the data layer and presentation layer. 79 | * The interactor is responsible for fetching current settings values from the appropriate data sources. 80 | */ 81 | protocol SettingsInteractorProtocol: Sendable { 82 | 83 | /** 84 | * Retrieves the current value of a feature flag. 85 | * 86 | * - Parameter identifier: The identifier of the feature flag to check. 87 | * - Returns: A boolean indicating whether the feature is enabled. 88 | */ 89 | func getFeatureFlagValue(for identifier: FeatureFlagIdentifier) async -> Bool 90 | 91 | /** 92 | * Retrieves the current theme setting for the application. 93 | * 94 | * - Returns: The currently selected application theme. 95 | */ 96 | func getCurrentTheme() async -> Theme 97 | } 98 | -------------------------------------------------------------------------------- /Scripts/LocalizedStringsGenerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @main 4 | public struct LocalizedStringsGenerator { 5 | static func main() { 6 | createLocalizedStringsFile(filePath: CommandLine.arguments[1], stringsFileName: CommandLine.arguments[2]) 7 | } 8 | 9 | private static func createLocalizedStringsFile(filePath: String, stringsFileName: String) { 10 | let fileContentString = 11 | """ 12 | // 13 | // Auto-generated code. Do not modify this file manually. 14 | // 15 | 16 | \(imports) 17 | 18 | \(localizableProtocol(stringsFileName)) 19 | 20 | \(stringExtension) 21 | 22 | \(localizedStringsEnum(stringsFileName)) 23 | 24 | """ 25 | 26 | do { 27 | try fileContentString.write(toFile: filePath, atomically: true, encoding: .utf8) 28 | print("LocalizedStrings file successfully generated:\n \(filePath)\n") 29 | } catch { 30 | print(error) 31 | } 32 | } 33 | 34 | private static func localizedStringsEnum(_ stringsFileName: String) -> String { 35 | guard let localizableStringsFileURL = findPath(for: stringsFileName) else { return "" } 36 | do { 37 | let data = try String(contentsOfFile: localizableStringsFileURL.path, encoding: .utf8) 38 | let stringsLine = data.components(separatedBy: .newlines).filter { $0.contains(";") && $0.contains("=") } 39 | let stringsKeys = stringsLine 40 | .compactMap { $0.split(separator: "=").first } 41 | .map { $0?.replacingOccurrences(of: " ", with: "") } 42 | .map { $0?.replacingOccurrences(of: "\"", with: "") } 43 | let enumCases = stringsKeys.compactMap { $0 }.map { "case \($0)" } 44 | return """ 45 | enum LocalizedStrings: String, Localizable { 46 | \(enumCases.joined(separator: "\n\t")) 47 | } 48 | """ 49 | } catch { 50 | print("Error: \(error)") 51 | } 52 | return "" 53 | } 54 | 55 | private static func findPath(for stringsFileName: String) -> URL? { 56 | let url = URL(fileURLWithPath: Bundle.main.bundlePath) 57 | var files = [URL]() 58 | guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { 59 | return nil 60 | } 61 | for case let fileURL as URL in enumerator { 62 | do { 63 | let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) 64 | if fileAttributes.isRegularFile == true, fileURL.lastPathComponent.contains(stringsFileName) { 65 | files.append(fileURL) 66 | } 67 | } catch { print(error, fileURL) } 68 | } 69 | return files.first 70 | } 71 | 72 | // MARK: - Utils 73 | 74 | static let imports = 75 | """ 76 | import Foundation 77 | """ 78 | 79 | static func localizableProtocol(_ stringsFileName: String) -> String { 80 | """ 81 | protocol Localizable { 82 | var tableName: String { get } 83 | } 84 | 85 | extension Localizable where Self: RawRepresentable, Self.RawValue == String { 86 | var tableName: String { 87 | "\(stringsFileName)" 88 | } 89 | 90 | func callAsFunction() -> String { 91 | rawValue.localized(tableName: tableName) 92 | } 93 | } 94 | """ 95 | } 96 | 97 | static let stringExtension: String = 98 | """ 99 | private extension String { 100 | func localized(bundle: Bundle = .main, 101 | tableName: String, 102 | comment: String = "") -> String { 103 | NSLocalizedString(self, tableName: tableName, value: self, comment: comment) 104 | } 105 | } 106 | """ 107 | } 108 | -------------------------------------------------------------------------------- /GithubJobs.xcodeproj/xcshareddata/xcschemes/GithubJobs.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 35 | 41 | 42 | 43 | 45 | 51 | 52 | 53 | 54 | 55 | 65 | 67 | 73 | 74 | 75 | 76 | 82 | 84 | 90 | 91 | 92 | 93 | 95 | 96 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/JobDetail/ViewComponents/Header/JobDetailHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobDetailHeaderView.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | 10 | final class JobDetailHeaderView: UIView { 11 | 12 | private lazy var companyLogoContainerView: BackgroundCurvedView = { 13 | let view = BackgroundCurvedView() 14 | view.backgroundColor = .systemTeal 15 | view.contentMode = .redraw 16 | view.translatesAutoresizingMaskIntoConstraints = false 17 | return view 18 | }() 19 | 20 | private lazy var companyLogoImageView: UIImageView = { 21 | let imageView = UIImageView() 22 | imageView.contentMode = .scaleAspectFit 23 | imageView.translatesAutoresizingMaskIntoConstraints = false 24 | return imageView 25 | }() 26 | 27 | private lazy var titleLabel: UILabel = { 28 | let label = UILabel() 29 | label.setContentHuggingPriority(.required, for: .vertical) 30 | label.setContentCompressionResistancePriority(.required, for: .vertical) 31 | label.numberOfLines = 0 32 | label.font = FontHelper.Dynamic.body 33 | label.adjustsFontForContentSizeCategory = true 34 | label.translatesAutoresizingMaskIntoConstraints = false 35 | return label 36 | }() 37 | 38 | var title: String? { 39 | didSet { 40 | titleLabel.text = title 41 | } 42 | } 43 | 44 | var logoURLString: String? { 45 | didSet { 46 | guard let urlString = logoURLString, 47 | let url = URL(string: urlString) else { 48 | return 49 | } 50 | companyLogoImageView.setImage(with: url) 51 | } 52 | } 53 | 54 | var viewModel: JobDetailHeaderViewModelProtocol? { 55 | didSet { 56 | setupBindings() 57 | } 58 | } 59 | 60 | // MARK: - Initializers 61 | 62 | override init(frame: CGRect) { 63 | super.init(frame: frame) 64 | setupUI() 65 | } 66 | 67 | required init?(coder: NSCoder) { 68 | fatalError("init(coder:) has not been implemented") 69 | } 70 | 71 | // MARK: - Private 72 | 73 | private func setupUI() { 74 | setupCompanyLogoView() 75 | setupTitleLabel() 76 | 77 | titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width 78 | } 79 | 80 | private func setupCompanyLogoView() { 81 | addSubview(companyLogoContainerView) 82 | 83 | NSLayoutConstraint.activate([ 84 | companyLogoContainerView.topAnchor.constraint(equalTo: topAnchor), 85 | companyLogoContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), 86 | companyLogoContainerView.trailingAnchor.constraint(equalTo: trailingAnchor), 87 | companyLogoContainerView.heightAnchor.constraint(equalToConstant: 100) 88 | ]) 89 | 90 | companyLogoContainerView.addSubview(companyLogoImageView) 91 | companyLogoImageView.centerInSuperview(size: .init(width: 80, height: 80)) 92 | } 93 | 94 | private func setupTitleLabel() { 95 | addSubview(titleLabel) 96 | 97 | let trailingContraint = titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8) 98 | let leadingContraint = titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8) 99 | 100 | // Add a 999 priority to supress width constraint warning. 101 | trailingContraint.priority = .init(999) 102 | leadingContraint.priority = .init(999) 103 | 104 | NSLayoutConstraint.activate([ 105 | titleLabel.topAnchor.constraint(equalTo: companyLogoContainerView.bottomAnchor, constant: 16), 106 | titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16), 107 | leadingContraint, 108 | trailingContraint 109 | ]) 110 | } 111 | 112 | // MARK: - Reactive Behavior 113 | 114 | private func setupBindings() { 115 | guard let viewModel = viewModel else { return } 116 | 117 | titleLabel.text = viewModel.jobDescription 118 | 119 | guard let urlString = viewModel.companyLogoURLString, 120 | let url = URL(string: urlString) else { 121 | return 122 | } 123 | companyLogoImageView.setImage(with: url) 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /GithubJobs/Helpers/Extensions/UIView+Layout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+Layout.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | 12 | @discardableResult 13 | func anchor(top: NSLayoutYAxisAnchor?, 14 | leading: NSLayoutXAxisAnchor?, 15 | bottom: NSLayoutYAxisAnchor?, 16 | trailing: NSLayoutXAxisAnchor?, 17 | padding: UIEdgeInsets = .zero, 18 | size: CGSize? = nil) -> AnchoredConstraints { 19 | translatesAutoresizingMaskIntoConstraints = false 20 | var anchoredConstraints = AnchoredConstraints() 21 | 22 | anchoredConstraints.top = top.flatMap { topAnchor.constraint(equalTo: $0, constant: padding.top) } 23 | anchoredConstraints.leading = leading.flatMap { leadingAnchor.constraint(equalTo: $0, constant: padding.left) } 24 | anchoredConstraints.bottom = bottom.flatMap { bottomAnchor.constraint(equalTo: $0, constant: -padding.bottom) } 25 | anchoredConstraints.trailing = trailing.flatMap { trailingAnchor.constraint(equalTo: $0, constant: -padding.right) } 26 | 27 | anchoredConstraints.width = size.flatMap { widthAnchor.constraint(equalToConstant: $0.width) } 28 | anchoredConstraints.height = size.flatMap { heightAnchor.constraint(equalToConstant: $0.height) } 29 | 30 | [anchoredConstraints.top, 31 | anchoredConstraints.leading, 32 | anchoredConstraints.bottom, 33 | anchoredConstraints.trailing, 34 | anchoredConstraints.width, 35 | anchoredConstraints.height].forEach { $0?.isActive = true } 36 | 37 | return anchoredConstraints 38 | } 39 | 40 | func fillSuperview(padding: UIEdgeInsets = .zero) { 41 | guard let superview else { return } 42 | translatesAutoresizingMaskIntoConstraints = false 43 | 44 | let safeAreaLayoutGuide = superview.safeAreaLayoutGuide 45 | topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: padding.top).isActive = true 46 | bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -padding.bottom).isActive = true 47 | leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: padding.left).isActive = true 48 | trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -padding.right).isActive = true 49 | } 50 | 51 | func centerInSuperview(size: CGSize? = nil) { 52 | guard let superview else { return } 53 | translatesAutoresizingMaskIntoConstraints = false 54 | 55 | centerXAnchor.constraint(equalTo: superview.centerXAnchor).isActive = true 56 | centerYAnchor.constraint(equalTo: superview.centerYAnchor).isActive = true 57 | 58 | if let size = size { 59 | widthAnchor.constraint(equalToConstant: size.width).isActive = true 60 | heightAnchor.constraint(equalToConstant: size.height).isActive = true 61 | } 62 | } 63 | 64 | func centerXInSuperview() { 65 | guard let superview else { return } 66 | translatesAutoresizingMaskIntoConstraints = false 67 | centerXAnchor.constraint(equalTo: superview.centerXAnchor).isActive = true 68 | } 69 | 70 | func centerYInSuperview() { 71 | guard let superview else { return } 72 | translatesAutoresizingMaskIntoConstraints = false 73 | centerYAnchor.constraint(equalTo: superview.centerYAnchor).isActive = true 74 | } 75 | 76 | func constraintWidth(constant: CGFloat) { 77 | translatesAutoresizingMaskIntoConstraints = false 78 | widthAnchor.constraint(equalToConstant: constant).isActive = true 79 | } 80 | 81 | func constraintHeight(constant: CGFloat) { 82 | translatesAutoresizingMaskIntoConstraints = false 83 | heightAnchor.constraint(equalToConstant: constant).isActive = true 84 | } 85 | 86 | func constraintWidthAspectRatio(constant: CGFloat) { 87 | translatesAutoresizingMaskIntoConstraints = false 88 | widthAnchor.constraint(equalTo: heightAnchor, multiplier: constant).isActive = true 89 | } 90 | 91 | func constraintHeightAspectRatio(constant: CGFloat) { 92 | translatesAutoresizingMaskIntoConstraints = false 93 | heightAnchor.constraint(equalTo: widthAnchor, multiplier: constant).isActive = true 94 | } 95 | } 96 | 97 | struct AnchoredConstraints { 98 | var top, leading, bottom, trailing, width, height: NSLayoutConstraint? 99 | } 100 | -------------------------------------------------------------------------------- /GithubJobsTests/Settings/SettingsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModelTests.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 23/11/24. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | @testable import GithubJobs 11 | 12 | @MainActor 13 | final class SettingsViewModelTests: XCTestCase { 14 | 15 | private var mockInteractor: MockSettingsInteractor! 16 | private var viewModel: SettingsViewModel! 17 | 18 | private var cancellables: Set = [] 19 | 20 | override func setUp() async throws { 21 | mockInteractor = MockSettingsInteractor() 22 | viewModel = SettingsViewModel(interactor: mockInteractor) 23 | } 24 | 25 | override func tearDown() async throws { 26 | mockInteractor = nil 27 | viewModel = nil 28 | } 29 | 30 | func testScreenTitle() { 31 | // Act 32 | let screenTitle = viewModel.screenTitle() 33 | // Assert 34 | XCTAssertEqual(screenTitle, "Settings") 35 | } 36 | 37 | func testLoadItems() async { 38 | // Arrange 39 | let expectation = expectation(description: "We should get two sections with one item each.") 40 | // Act 41 | viewModel.sectionModelsPublisher 42 | .dropFirst() 43 | .sink { sections in 44 | XCTAssertEqual(sections.count, 2) 45 | let mainSection = sections.first 46 | XCTAssertEqual(mainSection?.items.count, 1) 47 | XCTAssertEqual(mainSection?.items.first?.title, "Themes") 48 | 49 | let debugSection = sections.last 50 | XCTAssertEqual(debugSection?.items.count, 1) 51 | XCTAssertEqual(debugSection?.items.first?.title, "Feature Flags") 52 | expectation.fulfill() 53 | } 54 | .store(in: &cancellables) 55 | await viewModel.loadItems() 56 | // Assert 57 | await fulfillment(of: [expectation], timeout: 1.0) 58 | } 59 | 60 | func testLoadItemsWithFAQs() async { 61 | // Arrange 62 | mockInteractor.getFeatureFlagValueResult = true 63 | let expectation = expectation(description: "We should get two sections") 64 | // Act 65 | viewModel.sectionModelsPublisher 66 | .dropFirst() 67 | .sink { sections in 68 | XCTAssertEqual(sections.count, 2) 69 | let mainSection = sections.first 70 | XCTAssertEqual(mainSection?.items.count, 2) 71 | XCTAssertEqual(mainSection?.items.first?.title, "Themes") 72 | XCTAssertEqual(mainSection?.items.last?.title, "FAQs") 73 | 74 | let debugSection = sections.last 75 | XCTAssertEqual(debugSection?.items.count, 1) 76 | XCTAssertEqual(debugSection?.items.first?.title, "Feature Flags") 77 | expectation.fulfill() 78 | } 79 | .store(in: &cancellables) 80 | await viewModel.loadItems() 81 | // Assert 82 | await fulfillment(of: [expectation], timeout: 1.0) 83 | } 84 | 85 | func testSelectThemeSelectionItem() async { 86 | // Arrange 87 | mockInteractor.getFeatureFlagValueResult = true 88 | let expectation = expectation(description: "We should get a theme navigation") 89 | // Act 90 | viewModel.didUpdateNavigation 91 | .sink { navigation in 92 | XCTAssertEqual(navigation, .theme) 93 | expectation.fulfill() 94 | } 95 | .store(in: &cancellables) 96 | await viewModel.loadItems() 97 | viewModel.selectItem(at: 0, and: 0) 98 | // Assert 99 | await fulfillment(of: [expectation], timeout: 1.0) 100 | } 101 | 102 | func testSelectFAQsSelectionItem() async { 103 | // Arrange 104 | mockInteractor.getFeatureFlagValueResult = true 105 | let expectation = expectation(description: "We should get a faqs navigation") 106 | // Act 107 | viewModel.didUpdateNavigation 108 | .sink { navigation in 109 | XCTAssertEqual(navigation, .faqs) 110 | expectation.fulfill() 111 | } 112 | .store(in: &cancellables) 113 | await viewModel.loadItems() 114 | viewModel.selectItem(at: 1, and: 0) 115 | // Assert 116 | await fulfillment(of: [expectation], timeout: 1.0) 117 | } 118 | 119 | func testSelectFeatureFlagsSelectionItem() async { 120 | // Arrange 121 | mockInteractor.getFeatureFlagValueResult = true 122 | let expectation = expectation(description: "We should get a featureFlags navigation") 123 | // Act 124 | viewModel.didUpdateNavigation 125 | .sink { navigation in 126 | XCTAssertEqual(navigation, .featureFlags) 127 | expectation.fulfill() 128 | } 129 | .store(in: &cancellables) 130 | await viewModel.loadItems() 131 | viewModel.selectItem(at: 0, and: 1) 132 | // Assert 133 | await fulfillment(of: [expectation], timeout: 1.0) 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/ThemeSelection/ThemeSelectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeSelectionViewController.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 4/07/22. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | typealias ThemeSelectionCollectionViewDataSource = UICollectionViewDiffableDataSource 12 | 13 | final class ThemeSelectionViewController: ViewController, UICollectionViewDelegate { 14 | 15 | private lazy var collectionView: UICollectionView = { 16 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) 17 | collectionView.register(viewType: ThemeSelectionSectionHeaderView.self, kind: UICollectionView.elementKindSectionHeader) 18 | collectionView.delegate = self 19 | collectionView.translatesAutoresizingMaskIntoConstraints = false 20 | return collectionView 21 | }() 22 | 23 | private let viewModel: ThemeSelectionViewModelProtocol 24 | private weak var coordinator: ThemeSelectionCoordinatorProtocol? 25 | 26 | private var dataSource: ThemeSelectionCollectionViewDataSource? 27 | 28 | // MARK: - Initializers 29 | 30 | init(themeManager: ThemeManagerProtocol, 31 | viewModel: ThemeSelectionViewModelProtocol, 32 | coordinator: ThemeSelectionCoordinatorProtocol) { 33 | self.viewModel = viewModel 34 | self.coordinator = coordinator 35 | super.init(themeManager: themeManager) 36 | } 37 | 38 | required init?(coder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | // MARK: - Lifecycle 43 | 44 | override func viewDidLoad() { 45 | super.viewDidLoad() 46 | 47 | configureUI() 48 | setupBindings() 49 | 50 | viewModel.loadThemes() 51 | } 52 | 53 | override func closeBarButtonItemTapped() { 54 | coordinator?.dismiss() 55 | } 56 | 57 | // MARK: - Private 58 | 59 | private func configureUI() { 60 | title = viewModel.screenTitle() 61 | 62 | view.backgroundColor = .systemBackground 63 | 64 | configureCollectionView() 65 | } 66 | 67 | private func configureCollectionView() { 68 | view.addSubview(collectionView) 69 | collectionView.fillSuperview(padding: .zero) 70 | 71 | configureCollectionViewLayout() 72 | configureCollectionViewDataSource() 73 | } 74 | 75 | private func configureCollectionViewLayout() { 76 | var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped) 77 | config.headerMode = .supplementary 78 | 79 | let layout = UICollectionViewCompositionalLayout.list(using: config) 80 | collectionView.collectionViewLayout = layout 81 | } 82 | 83 | private func configureCollectionViewDataSource() { 84 | let cellRegistration = UICollectionView.CellRegistration { cell, _, theme in 85 | var content = UIListContentConfiguration.valueCell() 86 | 87 | content.text = theme.title 88 | content.textToSecondaryTextVerticalPadding = 4 89 | content.secondaryTextProperties.numberOfLines = 0 90 | 91 | cell.contentConfiguration = content 92 | } 93 | 94 | dataSource = ThemeSelectionCollectionViewDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, identifier in 95 | guard let self else { fatalError("Inconsistent state") } 96 | let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, 97 | for: indexPath, item: identifier) 98 | cell.accessories = [.checkmark(displayed: .always, options: .init(isHidden: !viewModel.isSelected(at: indexPath.row)))] 99 | return cell 100 | } 101 | 102 | dataSource?.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in 103 | let headerView = collectionView.dequeueReusableView(with: ThemeSelectionSectionHeaderView.self, 104 | kind: kind, 105 | for: indexPath) 106 | headerView.title = self?.viewModel.headerTitle(for: indexPath.section) 107 | return headerView 108 | } 109 | } 110 | 111 | private func updateUI(_ themes: [ThemeSelectionItemModel]) { 112 | var snapshot = NSDiffableDataSourceSnapshot() 113 | snapshot.appendSections([ThemeSelectionSection.main]) 114 | snapshot.appendItems(themes, toSection: ThemeSelectionSection.main) 115 | dataSource?.apply(snapshot, animatingDifferences: false) 116 | } 117 | 118 | private func setupBindings() { 119 | viewModel.themes 120 | .receive(on: DispatchQueue.main) 121 | .sink { [weak self] themes in 122 | guard let self else { return } 123 | self.updateUI(themes) 124 | } 125 | .store(in: &cancellables) 126 | } 127 | 128 | // MARK: - UICollectionViewDelegate 129 | 130 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 131 | viewModel.selectTheme(at: indexPath.row) 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /GithubJobsTests/JobsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsViewModelTests.swift 3 | // GithubJobsTests 4 | // 5 | // Created by Alonso on 11/8/20. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | @testable import GithubJobs 11 | 12 | @MainActor 13 | final class JobsViewModelTests: XCTestCase { 14 | 15 | private var mockJobsInteractor: MockJobsInteractor! 16 | private var viewModelToTest: JobsViewModel! 17 | 18 | private var cancellables: Set = [] 19 | 20 | override func setUp() async throws { 21 | mockJobsInteractor = MockJobsInteractor() 22 | viewModelToTest = JobsViewModel(interactor: mockJobsInteractor) 23 | } 24 | 25 | override func tearDown() async throws { 26 | mockJobsInteractor = nil 27 | viewModelToTest = nil 28 | } 29 | 30 | func testGetJobsPaging() { 31 | // Arrange 32 | let expectation = XCTestExpectation(description: "State is set to paging") 33 | 34 | let jobsToTest = [Job.with()] 35 | mockJobsInteractor.jobs = jobsToTest 36 | // Act 37 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 38 | state == .paging(jobsToTest, next: 2) ? expectation.fulfill() : XCTFail("State wasn't set to paging") 39 | }.store(in: &cancellables) 40 | viewModelToTest.getJobs() 41 | // Assert 42 | wait(for: [expectation], timeout: 1) 43 | } 44 | 45 | func testGetJobsPopulated() async throws { 46 | // Arrange 47 | let jobsToTest = [Job.with()] 48 | mockJobsInteractor.jobs = jobsToTest 49 | let expectation = XCTestExpectation(description: "State is set to populated") 50 | // Act 51 | viewModelToTest.viewStatePublisher.dropFirst(2).sink { state in 52 | state == .populated(jobsToTest) ? expectation.fulfill() : XCTFail("State wasn't set to populated") 53 | }.store(in: &cancellables) 54 | viewModelToTest.getJobs() 55 | 56 | try await Task.sleep(nanoseconds: 100_000_000) 57 | 58 | mockJobsInteractor.jobs = [] 59 | viewModelToTest.getJobs() 60 | // Assert 61 | await fulfillment(of: [expectation], timeout: 1) 62 | } 63 | 64 | func testGetJobsEmpty() { 65 | // Arrange 66 | let jobsToTest: [Job] = [] 67 | let expectation = XCTestExpectation(description: "State is set to empty") 68 | // Act 69 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 70 | state == .empty ? expectation.fulfill() : XCTFail("State wasn't set to populated") 71 | }.store(in: &cancellables) 72 | mockJobsInteractor.jobs = jobsToTest 73 | viewModelToTest.getJobs() 74 | // Assert 75 | wait(for: [expectation], timeout: 1) 76 | } 77 | 78 | func testGetJobsError() { 79 | // Arrange 80 | let errorToTest = APIError.badRequest 81 | let expectation = XCTestExpectation(description: "State is set to error") 82 | // Act 83 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 84 | state == .error(message: APIError.badRequest.description) ? expectation.fulfill() : XCTFail("State wasn't set to error") 85 | }.store(in: &cancellables) 86 | mockJobsInteractor.error = errorToTest 87 | viewModelToTest.getJobs() 88 | // Assert 89 | wait(for: [expectation], timeout: 1) 90 | } 91 | 92 | func testJobCellsCountWhenPaging() { 93 | // Arrange 94 | let jobsToTest = [Job.with()] 95 | let expectation = XCTestExpectation(description: "State is set to paging") 96 | // Act 97 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 98 | state == .paging(jobsToTest, next: 2) ? expectation.fulfill() : XCTFail("State wasn't set to paging") 99 | }.store(in: &cancellables) 100 | mockJobsInteractor.jobs = jobsToTest 101 | viewModelToTest.getJobs() 102 | // Assert 103 | wait(for: [expectation], timeout: 1) 104 | XCTAssertEqual(jobsToTest.count, viewModelToTest.jobsCells.count) 105 | } 106 | 107 | func testJobCellsCountWhenPopulated() async throws { 108 | // Arrange 109 | let jobsToTest = [Job.with()] 110 | mockJobsInteractor.jobs = jobsToTest 111 | let expectation = XCTestExpectation(description: "State is set to populated") 112 | // Act 113 | viewModelToTest.viewStatePublisher.dropFirst(2).sink { state in 114 | state == .populated(jobsToTest) ? expectation.fulfill() : XCTFail("State wasn't set to populated") 115 | }.store(in: &cancellables) 116 | viewModelToTest.getJobs() 117 | 118 | try await Task.sleep(nanoseconds: 100_000_000) 119 | 120 | mockJobsInteractor.jobs = [] 121 | viewModelToTest.getJobs() 122 | // Assert 123 | await fulfillment(of: [expectation], timeout: 1) 124 | XCTAssertEqual(jobsToTest.count, viewModelToTest.jobsCells.count) 125 | } 126 | 127 | func testJobAtIndex() { 128 | // Arrange 129 | let jobsToTest = [Job.with(id: "1"), Job.with(id: "2")] 130 | let expectation = XCTestExpectation(description: "State is set to paging") 131 | // Act 132 | viewModelToTest.viewStatePublisher.dropFirst().sink { state in 133 | state == .paging(jobsToTest, next: 2) ? expectation.fulfill() : XCTFail("State wasn't set to paging") 134 | }.store(in: &cancellables) 135 | mockJobsInteractor.jobs = jobsToTest 136 | viewModelToTest.getJobs() 137 | // Assert 138 | wait(for: [expectation], timeout: 1) 139 | let job = viewModelToTest.job(at: 0) 140 | XCTAssertEqual(job.id, jobsToTest.first?.id) 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Settings/SettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewController.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 24/07/22. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | typealias SettingsCollectionViewDataSource = UICollectionViewDiffableDataSource 12 | 13 | final class SettingsViewController: ViewController, UICollectionViewDelegate { 14 | 15 | private lazy var collectionView: UICollectionView = { 16 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()) 17 | collectionView.register(viewType: ThemeSelectionSectionHeaderView.self, kind: UICollectionView.elementKindSectionHeader) 18 | collectionView.delegate = self 19 | collectionView.translatesAutoresizingMaskIntoConstraints = false 20 | 21 | return collectionView 22 | }() 23 | 24 | private let viewModel: SettingsViewModelProtocol 25 | private weak var coordinator: SettingsCoordinatorProtocol? 26 | 27 | private var dataSource: SettingsCollectionViewDataSource? 28 | 29 | // MARK: - Initializers 30 | 31 | init(themeManager: ThemeManagerProtocol, 32 | viewModel: SettingsViewModelProtocol, 33 | coordinator: SettingsCoordinatorProtocol) { 34 | self.viewModel = viewModel 35 | self.coordinator = coordinator 36 | super.init(themeManager: themeManager) 37 | } 38 | 39 | required init?(coder: NSCoder) { 40 | fatalError("init(coder:) has not been implemented") 41 | } 42 | 43 | // MARK: - Lifecycle 44 | 45 | override func viewDidLoad() { 46 | super.viewDidLoad() 47 | 48 | configureUI() 49 | setupBindings() 50 | } 51 | 52 | override func viewWillAppear(_ animated: Bool) { 53 | super.viewWillAppear(animated) 54 | Task { 55 | await viewModel.loadItems() 56 | } 57 | if let indexPathsForSelectedItem = collectionView.indexPathsForSelectedItems?.first { 58 | collectionView.deselectItem(at: indexPathsForSelectedItem, animated: true) 59 | } 60 | } 61 | 62 | override func closeBarButtonItemTapped() { 63 | coordinator?.dismiss() 64 | } 65 | 66 | // MARK: - Private 67 | 68 | private func configureUI() { 69 | title = viewModel.screenTitle() 70 | 71 | view.backgroundColor = .systemBackground 72 | 73 | navigationController?.presentationController?.delegate = self 74 | 75 | configureCollectionView() 76 | } 77 | 78 | private func configureCollectionView() { 79 | view.addSubview(collectionView) 80 | collectionView.fillSuperview(padding: .zero) 81 | 82 | configureCollectionViewLayout() 83 | configureCollectionViewDataSource() 84 | } 85 | 86 | private func configureCollectionViewLayout() { 87 | var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped) 88 | config.headerMode = .supplementary 89 | 90 | let layout = UICollectionViewCompositionalLayout.list(using: config) 91 | collectionView.collectionViewLayout = layout 92 | } 93 | 94 | private func configureCollectionViewDataSource() { 95 | let cellRegistration = UICollectionView.CellRegistration { cell, _, item in 96 | var content = UIListContentConfiguration.valueCell() 97 | 98 | content.text = item.title 99 | content.secondaryText = item.value 100 | content.textToSecondaryTextVerticalPadding = 4 101 | content.secondaryTextProperties.numberOfLines = 0 102 | 103 | cell.contentConfiguration = content 104 | } 105 | 106 | dataSource = SettingsCollectionViewDataSource(collectionView: collectionView) { collectionView, indexPath, identifier in 107 | let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, 108 | for: indexPath, item: identifier) 109 | cell.accessories = [.disclosureIndicator()] 110 | return cell 111 | } 112 | 113 | dataSource?.supplementaryViewProvider = { collectionView, kind, indexPath in 114 | let headerView = collectionView.dequeueReusableView(with: ThemeSelectionSectionHeaderView.self, 115 | kind: kind, 116 | for: indexPath) 117 | return headerView 118 | } 119 | } 120 | 121 | // MARK: - Reactive Behavior 122 | 123 | private func setupBindings() { 124 | viewModel.didUpdateNavigation 125 | .receive(on: DispatchQueue.main) 126 | .sink { [weak self] navigation in 127 | guard let self = self else { return } 128 | self.coordinator?.startNavigation(for: navigation) 129 | }.store(in: &cancellables) 130 | 131 | viewModel.sectionModelsPublisher 132 | .receive(on: DispatchQueue.main) 133 | .sink { [weak self] sections in 134 | guard let self = self else { return } 135 | self.updateUI(with: sections) 136 | }.store(in: &cancellables) 137 | } 138 | 139 | private func updateUI(with sections: [SettingsSection]) { 140 | var snapshot = NSDiffableDataSourceSnapshot() 141 | for section in sections { 142 | snapshot.appendSections([section]) 143 | snapshot.appendItems(section.items, toSection: section) 144 | } 145 | dataSource?.apply(snapshot, animatingDifferences: false) 146 | } 147 | 148 | // MARK: - UICollectionViewDelegate 149 | 150 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 151 | viewModel.selectItem(at: indexPath.item, and: indexPath.section) 152 | } 153 | 154 | } 155 | 156 | // MARK: - UIAdaptivePresentationControllerDelegate 157 | 158 | extension SettingsViewController: UIAdaptivePresentationControllerDelegate { 159 | 160 | func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { 161 | coordinator?.dismiss() 162 | } 163 | 164 | } 165 | -------------------------------------------------------------------------------- /GithubJobs/Scenes/Jobs/JobsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JobsViewController.swift 3 | // GithubJobs 4 | // 5 | // Created by Alonso on 11/7/20. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | final class JobsViewController: ViewController { 12 | 13 | private lazy var themeSelectionBarButtonItem: UIBarButtonItem = { 14 | let barButtonItem = UIBarButtonItem(image: .init(systemName: "gear"), 15 | landscapeImagePhone: .init(systemName: "gearshape.2"), 16 | style: .plain, 17 | target: self, 18 | action: #selector(settingsAction)) 19 | return barButtonItem 20 | }() 21 | 22 | private lazy var refreshControl: RefreshControl = { 23 | let refreshControl = RefreshControl(title: LocalizedStrings.refreshControlTitle(), backgroundColor: .systemBackground) 24 | return refreshControl 25 | }() 26 | 27 | private lazy var tableView: UITableView = { 28 | let tableView = UITableView(frame: .zero) 29 | tableView.register(cellType: JobTableViewCell.self) 30 | tableView.dataSource = self 31 | tableView.delegate = self 32 | 33 | tableView.estimatedRowHeight = 100 34 | tableView.rowHeight = UITableView.automaticDimension 35 | 36 | tableView.refreshControl = refreshControl 37 | 38 | tableView.translatesAutoresizingMaskIntoConstraints = false 39 | 40 | return tableView 41 | }() 42 | 43 | private var displayedCellsIndexPaths = Set() 44 | private var prefetchDataSource: TableViewDataSourcePrefetching? 45 | 46 | private let viewModel: JobsViewModelProtocol 47 | private weak var coordinator: JobsCoordinatorProtocol? 48 | 49 | // MARK: - Initializers 50 | 51 | init(themeManager: ThemeManagerProtocol, 52 | viewModel: JobsViewModelProtocol, 53 | coordinator: JobsCoordinatorProtocol) { 54 | self.viewModel = viewModel 55 | self.coordinator = coordinator 56 | super.init(themeManager: themeManager) 57 | } 58 | 59 | required init?(coder: NSCoder) { 60 | fatalError("init(coder:) has not been implemented") 61 | } 62 | 63 | // MARK: - Lifecycle 64 | 65 | override func viewDidLoad() { 66 | super.viewDidLoad() 67 | view.backgroundColor = .systemBackground 68 | title = LocalizedStrings.jobsTitle() 69 | 70 | setupUI() 71 | setupBindings() 72 | viewModel.getJobs() 73 | } 74 | 75 | override func viewWillAppear(_ animated: Bool) { 76 | super.viewWillAppear(animated) 77 | if let indexPathForSelectedRow = tableView.indexPathForSelectedRow { 78 | tableView.deselectRow(at: indexPathForSelectedRow, animated: true) 79 | } 80 | } 81 | 82 | // MARK: - Private 83 | 84 | private func setupUI() { 85 | navigationItem.rightBarButtonItem = themeSelectionBarButtonItem 86 | 87 | view.addSubview(tableView) 88 | tableView.fillSuperview() 89 | } 90 | 91 | private func configureTableViewDataSource() { 92 | prefetchDataSource = TableViewDataSourcePrefetching(cellCount: viewModel.jobsCells.count, 93 | needsPrefetch: viewModel.needsPrefetch, 94 | prefetchHandler: { [weak self] in 95 | self?.viewModel.refreshJobs() 96 | }) 97 | tableView.prefetchDataSource = prefetchDataSource 98 | } 99 | 100 | private func configureView(with state: JobsViewState) { 101 | switch state { 102 | case .empty: 103 | tableView.tableFooterView = CustomFooterView(message: LocalizedStrings.emptyJobsTitle()) 104 | case .populated, .paging: 105 | tableView.tableFooterView = UIView() 106 | case .initial: 107 | tableView.tableFooterView = LoadingFooterView() 108 | case .error(let error): 109 | tableView.tableFooterView = CustomFooterView(message: error.description) 110 | } 111 | } 112 | 113 | // MARK: - Reactive Behavior 114 | 115 | private func setupBindings() { 116 | refreshControl 117 | .valueChanged 118 | .receive(on: DispatchQueue.main) 119 | .sink { [weak self] in 120 | guard let self else { return } 121 | self.viewModel.getJobs() 122 | }.store(in: &cancellables) 123 | 124 | viewModel 125 | .viewStatePublisher 126 | .receive(on: DispatchQueue.main) 127 | .sink { [weak self] state in 128 | guard let self else { return } 129 | self.configureView(with: state) 130 | self.configureTableViewDataSource() 131 | self.refreshControl.endRefreshing() 132 | self.tableView.reloadData() 133 | }.store(in: &cancellables) 134 | } 135 | 136 | // MARK: - Selectors 137 | 138 | @objc private func settingsAction() { 139 | coordinator?.showSettings() 140 | } 141 | 142 | } 143 | 144 | // MARK: - UITableViewDataSource 145 | 146 | extension JobsViewController: UITableViewDataSource { 147 | 148 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 149 | viewModel.jobsCells.count 150 | } 151 | 152 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 153 | let cell = tableView.dequeueReusableCell(with: JobTableViewCell.self, for: indexPath) 154 | cell.viewModel = viewModel.jobsCells[indexPath.row] 155 | 156 | return cell 157 | } 158 | 159 | } 160 | 161 | // MARK: - UITableViewDelegate 162 | 163 | extension JobsViewController: UITableViewDelegate { 164 | 165 | func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { 166 | if !displayedCellsIndexPaths.contains(indexPath) { 167 | displayedCellsIndexPaths.insert(indexPath) 168 | Animator.fade(tableViewCell: cell) 169 | } 170 | } 171 | 172 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 173 | coordinator?.showJobDetail(viewModel.job(at: indexPath.row)) 174 | } 175 | 176 | } 177 | --------------------------------------------------------------------------------