├── 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 | []()
4 | []()
5 | [](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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------