Skip to content

Commit

Permalink
Merge pull request #6 from f-lab-edu/feature/feedList
Browse files Browse the repository at this point in the history
피드 리스트를 네트워크와 통신하기 위해 NetworkService,RecipeFetchService를 생성,피드리스트 UI에 관련된 객체만들 정의 하는 RecipeListItemViewModel을 정의
  • Loading branch information
GeonH0 authored Jun 17, 2024
2 parents 1cb35f5 + f971668 commit 9e4b382
Show file tree
Hide file tree
Showing 6 changed files with 444 additions and 57 deletions.
210 changes: 153 additions & 57 deletions HomeCafeRecipes/HomeCafeRecipes.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions HomeCafeRecipes/HomeCafeRecipes/Data/Network/NetworkService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// NetworkService.swift
// HomeCafeRecipes
//
// Created by 김건호 on 6/12/24.
//

import Foundation
import RxSwift

protocol NetworkService {
func getRequest<T: Decodable>(url: URL, responseType: T.Type) -> Single<T>
}

class BaseNetworkService: NetworkService {
let baseURL = URL(string: "https://meog0.store/api")!

func getRequest<T: Decodable>(url: URL, responseType: T.Type) -> Single<T> {
var request = URLRequest(url: url)
request.httpMethod = "GET"
return Single.create { single in
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
single(.failure(error))
} else if let data = data {
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let responseObject = try decoder.decode(T.self, from: data)
single(.success(responseObject))
} catch let decodingError {
single(.failure(decodingError))
}
}
}
task.resume()

return Disposables.create {
task.cancel()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// RecipeFetchService.swift
// HomeCafeRecipes
//
// Created by 김건호 on 6/10/24.
//

import Foundation
import RxSwift

protocol RecipeFetchService {
func fetchRecipes(pageNumber: Int) -> Single<[Recipe]>
func searchRecipes(title: String, pageNumber: Int) -> Single<[Recipe]>
}

class DefaultRecipeFetchService: RecipeFetchService {
private let networkService: NetworkService
private static let baseURL: URL = URL(string: "https://meog0.store/api")!

init(networkService: NetworkService) {
self.networkService = networkService
}

private func makeURL(endpoint: String, queryItems: [URLQueryItem]) -> URL? {
let URL = DefaultRecipeFetchService.baseURL.appendingPathComponent(endpoint)
var URLComponents = URLComponents(url: URL, resolvingAgainstBaseURL: false)
URLComponents?.queryItems = queryItems
return URLComponents?.url
}

func fetchRecipes(pageNumber: Int) -> Single<[Recipe]> {
guard let URL = makeURL(endpoint: "recipes", queryItems: [URLQueryItem(name: "pageNumber", value: String(pageNumber))]) else {
return Single.error(NSError(domain: "URLComponentsError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))
}
return networkService.getRequest(url: URL, responseType: NetworkResponseDTO<RecipePageDTO>.self)
.map { $0.data.recipes.map{ $0.toDomain() } }
}


func searchRecipes(title: String, pageNumber: Int) -> Single<[Recipe]> {
guard let URL = makeURL(endpoint: "recipes", queryItems: [
URLQueryItem(name: "keyword", value: title),
URLQueryItem(name: "pageNumber", value: String(pageNumber))
]) else {
return Single.error(NSError(domain: "URLComponentsError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))
}
return networkService.getRequest(url: URL, responseType: NetworkResponseDTO<RecipePageDTO>.self)
.map { $0.data.recipes.map{ $0.toDomain() } }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// RecipeListViewModel.swift
// HomeCafeRecipes
//
// Created by 김건호 on 6/10/24.
//

import Foundation
import RxSwift

protocol RecipeListViewModelDelegate: AnyObject {
func didFetchRecipes(_ recipes: [RecipeListItemViewModel])
func didFail(with error: Error)
}

protocol InputRecipeListViewModel {
func viewDidLoad()
func fetchNextPage()
func didSelectItem(id: Int) -> RecipeItemViewModel?
func searchRecipes(with query: String)
func resetSearch()
}

protocol OutputRecipeListViewModel {
var recipes: Observable<[RecipeListItemViewModel]> { get }
var error: Observable<Error?> { get }
}

class RecipeListViewModel: InputRecipeListViewModel, OutputRecipeListViewModel {

private let disposeBag = DisposeBag()
private let fetchFeedListUseCase: FetchFeedListUseCase
private let searchFeedListUseCase: SearchFeedListUseCase
private weak var delegate: RecipeListViewModelDelegate?

private var currentPage: Int = 1
private var isFetching = false
private var isSearching = false
private var currentSearchQuery: String?
private var allRecipes: [Recipe] = []

private let recipesSubject = BehaviorSubject<[RecipeListItemViewModel]>(value: [])
private let errorSubject = BehaviorSubject<Error?>(value: nil)

var recipes: Observable<[RecipeListItemViewModel]> {
return recipesSubject.asObservable()
}

var error: Observable<Error?> {
return errorSubject.asObservable()
}

init(fetchFeedListUseCase: FetchFeedListUseCase, searchFeedListUseCase: SearchFeedListUseCase) {
self.fetchFeedListUseCase = fetchFeedListUseCase
self.searchFeedListUseCase = searchFeedListUseCase
}

func setDelegate(_ delegate: RecipeListViewModelDelegate) {
self.delegate = delegate
bindOutputs()
}

private func bindOutputs() {
recipes
.subscribe(onNext: { [weak self] recipes in
self?.delegate?.didFetchRecipes(recipes)
})
.disposed(by: disposeBag)

error
.subscribe(onNext: { [weak self] error in
if let error = error {
self?.delegate?.didFail(with: error)
}
})
.disposed(by: disposeBag)
}

func viewDidLoad() {
fetchRecipes()
}

func fetchNextPage() {
fetchNextRecipes(nextPage: currentPage)
}

func didSelectItem(id: Int) -> RecipeItemViewModel? {
guard let recipe = allRecipes.first(where: { $0.id == id }) else {
return nil
}
return RecipeMapper.mapToRecipeItemViewModel(from: recipe)
}

func resetSearch() {
isSearching = false
currentSearchQuery = nil
currentPage = 1
recipesSubject.onNext([])
fetchRecipes()
}

func searchRecipes(with title: String) {
guard !isFetching else { return }
isFetching = true
currentSearchQuery = title
isSearching = true
currentPage = 1
searchFeedListUseCase.execute(title: title, pageNumber: currentPage)
.subscribe(onSuccess: handleSuccess, onFailure: handleError)
.disposed(by: disposeBag)
}

private func fetchRecipes() {
guard !isFetching else { return }
isFetching = true
fetchFeedListUseCase.execute(pageNumber: currentPage)
.subscribe(onSuccess: handleSuccess, onFailure: handleError)
.disposed(by: disposeBag)
}

private func fetchNextRecipes(nextPage: Int){
guard !isFetching else { return }
isFetching = true
fetchFeedListUseCase.execute(pageNumber: nextPage)
.subscribe(onSuccess: handleSuccess, onFailure: handleError)
.disposed(by: disposeBag)
}

private func handleSuccess(result: Result<[Recipe], Error>) {
isFetching = false
switch result {
case .success(let recipes):
if recipes.isEmpty {
return
}
if currentPage == 1 {
allRecipes = recipes
} else {
allRecipes.append(contentsOf: recipes)
}
let recipeViewModels = RecipeMapper.mapToRecipeListItemViewModels(from: recipes)
var currentRecipes = try! recipesSubject.value()
if isSearching {
currentRecipes = recipeViewModels
isSearching = false
} else {
currentRecipes.append(contentsOf: recipeViewModels)
}
recipesSubject.onNext(currentRecipes)
currentPage += 1
case .failure(let error):
errorSubject.onNext(error)
}
}

private func handleError(error: Error) {
isFetching = false
errorSubject.onNext(error)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// RecipeListItemViewModel.swift
// HomeCafeRecipes
//
// Created by 김건호 on 6/12/24.
//

import Foundation

struct RecipeListItemViewModel {
let id: Int
let name: String
let imageURL: URL?

init(recipe: Recipe) {
self.id = recipe.id
self.name = recipe.name
self.imageURL = URL(string: recipe.imageUrls.first ?? "")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// RecipeMapper.swift
// HomeCafeRecipes
//
// Created by 김건호 on 6/14/24.
//

import Foundation

struct RecipeMapper {
static func mapToRecipeListItemViewModels(from recipes: [Recipe]) -> [RecipeListItemViewModel] {
return recipes.map { RecipeListItemViewModel(recipe: $0) }
}

static func mapToRecipeItemViewModel(from recipe: Recipe) -> RecipeItemViewModel {
return RecipeItemViewModel(recipe: recipe)
}
}

0 comments on commit 9e4b382

Please sign in to comment.