Skip to content

Commit

Permalink
Merge pull request #1 from stoola20/feature/list-user
Browse files Browse the repository at this point in the history
Fetch GitHub Users
  • Loading branch information
stoola20 authored May 16, 2024
2 parents e05fce9 + dd7568a commit 9d9f637
Show file tree
Hide file tree
Showing 37 changed files with 2,362 additions and 43 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,6 @@ iOSInjectionProject/
!*.xcworkspace/contents.xcworkspacedata
/*.gcno
**/xcshareddata/WorkspaceSettings.xcsettings

### Others ###
PersonalAccessToken.swift
367 changes: 356 additions & 11 deletions AccessAppExercise.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions AccessAppExercise.xcworkspace/contents.xcworkspacedata

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>AccessAppExercise.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
36 changes: 36 additions & 0 deletions AccessAppExercise/Extention/String+Ext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
////
// String+Ext.swift
// AccessAppExercise
//
// Created by Jesse Chen on 2024/5/14.
//

import Foundation

extension String {
func getNextLinkFromLinkHeader() -> String? {
// Define the regular expression pattern
let pattern = "<([^>]*)>; rel=\"next\""

// Create a regular expression object
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
return nil
}

// Search for the string that matches the regular expression pattern
let range = NSRange(self.startIndex..<self.endIndex, in: self)
let matches = regex.matches(in: self, options: [], range: range)

// Get the matching result
if let match = matches.first {
// Extract the matched string from the matching result
let nsString = self as NSString
let matchRange = match.range(at: 1)
if matchRange.location != NSNotFound {
return nsString.substring(with: matchRange)
}
}

return nil
}
}
23 changes: 23 additions & 0 deletions AccessAppExercise/Extention/TableView+Ext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
////
// TableView+Ext.swift
// AccessAppExercise
//
// Created by Jesse Chen on 2024/5/14.
//

import UIKit

extension UITableView {
/// Registers a cell class for use in creating new table view cells.
///
/// - Parameters:
/// - cell: The cell class to register.
/// - bundle: The bundle containing the nib file. If nil, the main bundle is used.
func register(cell: AnyClass, inBundle bundle: Bundle? = nil) {
// Register the cell class using a nib file with the same name as the cell class.
register(
UINib(nibName: String(describing: cell.self), bundle: bundle),
forCellReuseIdentifier: String(describing: cell.self)
)
}
}
34 changes: 34 additions & 0 deletions AccessAppExercise/Extention/UIImage+Ext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
////
// UIImage+Ext.swift
// AccessAppExercise
//
// Created by Jesse Chen on 2024/5/14.
//

import UIKit
import Kingfisher

extension UIImageView {
/// Loads an image from the specified URL asynchronously using Kingfisher and sets it as the image of the image view.
///
/// - Parameters:
/// - urlString: The URL string from which to load the image.
/// - placeHolder: An optional placeholder image to display while the image is being loaded. Defaults to nil.
func loadImage(_ urlString: String?, placeHolder: UIImage? = nil) {
// Ensure that the urlString is not nil and can be converted to a URL.
guard let urlString = urlString, let url = URL(string: urlString) else { return }

// Set the Kingfisher indicator type to activity indicator.
self.kf.indicatorType = .activity

// Use Kingfisher to asynchronously load the image from the URL and set it as the image of the image view.
self.kf.setImage(
with: url,
placeholder: placeHolder,
options: [
.transition(.fade(1)), // Apply a fade transition when the image is loaded.
.cacheOriginalImage // Cache the original image for future use.
]
)
}
}
44 changes: 44 additions & 0 deletions AccessAppExercise/Extention/UIViewController+Ext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
////
// UIViewController+Ext.swift
// AccessAppExercise
//
// Created by Jesse Chen on 2024/5/15.
//

import UIKit

extension UIViewController {
/// Displays an alert with the specified title and message.
///
/// - Parameters:
/// - title: The title of the alert.
/// - message: The message to display in the alert.
/// - confirm: A closure to execute when the user confirms the alert. Defaults to nil.
/// - cancel: A closure to execute when the user cancels the alert. Defaults to nil.
func showAlert(
title: String,
message: String,
confirm: (() -> Void)? = nil,
cancel: (() -> Void)? = nil
) {
let alert = UIAlertController(title: title,
message: message,
preferredStyle: .alert)

// Add cancel action if provided
if let cancel {
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
cancel()
}
alert.addAction(cancelAction)
}

// add confirm action
let confirmAction = UIAlertAction(title: "OK", style: .default) { _ in
confirm?()
}
alert.addAction(confirmAction)

present(alert, animated: true, completion: nil)
}
}
77 changes: 77 additions & 0 deletions AccessAppExercise/Interactor/UserInteractor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
////
// UserInteractor.swift
// AccessAppExercise
//
// Created by Jesse Chen on 2024/5/13.
//

import Alamofire
import Foundation
import RxSwift

protocol UserInteractorProtocol {
func getUserList(since: Int, pageSize: Int) -> Observable<([GitHubUser], [String: Any]?)>
func getNextUserPage(link: String) -> Observable<([GitHubUser], [String: Any]?)>
func getUserDetail(userName: String) -> Observable<DetailUser>
}

/// Interactor responsible for fetching user data from the GitHub API.
final class UserInteractor: UserInteractorProtocol, RequestProtocol {
/// Fetches a list of GitHub users.
///
/// - Parameters:
/// - since: The user ID to start fetching users from.
/// - pageSize: The number of users to fetch per page. Default is 20.
/// - Returns: An observable sequence of GitHub users.
func getUserList(since: Int, pageSize: Int = 20) -> Observable<([GitHubUser], [String: Any]?)> {

// Construct API endpoint for fetching user list
let api = GitHubAPI.getUserList(since: since, pageSize: pageSize)
let url = URL(string: api.baseURL + api.path)

// Make a network request to fetch the user list
return request(
url: url,
method: api.method,
parameters: api.parameters,
header: api.header,
type: [GitHubUser].self
)
}

/// Fetches the next page of GitHub users based on the provided link.
///
/// - Parameter link: The link to the next page of users.
/// - Returns: An observable sequence containing a tuple of GitHub users and response headers.
func getNextUserPage(link: String) -> Observable<([GitHubUser], [String: Any]?)> {
let url = URL(string: link)

// Make a network request to fetch the user list
return request(
url: url,
method: .get,
parameters: nil,
header: HTTPHeaderManager.shared.getDefaultHeaders(),
type: [GitHubUser].self
)
}

/// Fetches detailed information for a specific GitHub user.
///
/// - Parameter userName: The username of the GitHub user.
/// - Returns: An observable sequence containing the detailed information of the user.
func getUserDetail(userName: String) -> Observable<DetailUser> {
// Construct API endpoint for fetching user detail info
let api = GitHubAPI.getUserDetail(userName: userName)
let url = URL(string: api.baseURL + api.path)

// Make a network request to fetch the user detail
return request(
url: url,
method: api.method,
parameters: api.parameters,
header: api.header,
type: DetailUser.self
)
}
}
55 changes: 55 additions & 0 deletions AccessAppExercise/Model/UserModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
////
// UserModel.swift
// AccessAppExercise
//
// Created by Jesse Chen on 2024/5/13.
//

import Foundation
import RxDataSources

/// Structure representing a section in a user list, containing a header and a list of items.
/// This custom structure can be pass to RxDataSources as section type.
struct UserListSection {
/// The header title of the section.
var header: String
/// The items contained within the section.
var items: [Item]
}

extension UserListSection: SectionModelType {
typealias Item = GitHubUser

init(original: UserListSection, items: [GitHubUser]) {
self = original
self.items = items
}
}

/// Struct representing a GitHub user.
struct GitHubUser: Decodable {
/// The username of the GitHub user.
let login: String
/// The URL of the avatar image for the GitHub user.
let avatarUrl: String
/// A boolean value indicates whether showing STAFF badge or not.
let siteAdmin: Bool
}

/// Struct representing detailed information about a GitHub user.
struct DetailUser: Decodable, Equatable {
/// The URL of the avatar image for the GitHub user.
let avatarUrl: String
/// The full name of the GitHub user.
let name: String?
/// The biography of the GitHub user.
let bio: String?
/// The username of the GitHub user.
let login: String
/// A boolean value indicates whether showing STAFF badge or not.
let siteAdmin: Bool
/// The location of the GitHub user.
let location: String?
/// The URL of the personal website or blog of the GitHub user.
let blog: String?
}
14 changes: 10 additions & 4 deletions AccessAppExercise/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {


func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let _ = (scene as? UIWindowScene) else { return }
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = UINavigationController(
rootViewController: ListViewController(
viewModel: ListViewModel(
interactor: UserInteractor()
)
)
)
window?.makeKeyAndVisible()
}

func sceneDidDisconnect(_ scene: UIScene) {
Expand Down
61 changes: 61 additions & 0 deletions AccessAppExercise/Server/GitHubAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
////
// APIEndpoint.swift
// AccessAppExercise
//
// Created by Jesse Chen on 2024/5/13.
//

import Foundation
import Alamofire

/// Enum representing different endpoints of the GitHub API.
enum GitHubAPI {
/// Endpoint to fetch a list of GitHub users.
///
/// - `since`: A user ID. Only return users with an ID greater than this ID.
/// - `pageSize`: The number of results per page (max 100)
case getUserList(since: Int, pageSize: Int)

/// Endpoint to fetch detailed information about a specific GitHub user.
///
/// - `userName`: The handle for the GitHub user account.
case getUserDetail(userName: String)
}

extension GitHubAPI {
/// The base URL of the GitHub API.
var baseURL: String {
return "https://api.github.com"
}

/// The path component of the URL for the API endpoint.
var path: String {
switch self {
case .getUserList:
return "/users"
case .getUserDetail(let userName):
return "/users/\(userName)"
}
}

/// The HTTP method used for the API request.
var method: Alamofire.HTTPMethod {
return .get
}

/// The HTTP header fields for the API request.
var header: [String: String] {
HTTPHeaderManager.shared.getDefaultHeaders()
}

/// The parameters to be included in the API request.
var parameters: [String: String]? {
switch self {
case .getUserList(let since, let pageSize):
return ["since": String(since),
"per_page": String(pageSize)]
case .getUserDetail:
return nil
}
}
}
Loading

0 comments on commit 9d9f637

Please sign in to comment.