diff --git a/Presentation/Sources/Scenes/ClimbRecordScene/AddClimbRecord/AddClimbRecordViewModel.swift b/Presentation/Sources/Scenes/ClimbRecordScene/AddClimbRecord/AddClimbRecordViewModel.swift index 3aabc77..39802c1 100644 --- a/Presentation/Sources/Scenes/ClimbRecordScene/AddClimbRecord/AddClimbRecordViewModel.swift +++ b/Presentation/Sources/Scenes/ClimbRecordScene/AddClimbRecord/AddClimbRecordViewModel.swift @@ -62,6 +62,7 @@ final class AddClimbRecordViewModel: BaseViewModel { let currentKeywordSubject = CurrentValueSubject("") let isLastPageSubject = CurrentValueSubject(false) let currentMountainsSubject = CurrentValueSubject<[Mountain], Never>([]) + let totalReceivedCountSubject = CurrentValueSubject(0) // 새로운 검색 input.searchTrigger @@ -70,6 +71,7 @@ final class AddClimbRecordViewModel: BaseViewModel { currentKeywordSubject.send(keyword) currentPageSubject.send(1) isLastPageSubject.send(false) + totalReceivedCountSubject.send(0) isLoadingSubject.send(true) }) .flatMap { [weak self] keyword -> AnyPublisher, Never> in @@ -83,10 +85,18 @@ final class AddClimbRecordViewModel: BaseViewModel { switch result { case .success(let response): let mountains = response.body.items.item.map { $0.toMountain() } - currentMountainsSubject.send(mountains) - searchResultsSubject.send(mountains) - // 마지막 페이지 체크 + // API 응답 자체에 중복이 있을 수 있으므로 중복 제거 (순서 유지) + var seenIds = Set() + let uniqueMountains = mountains.filter { seenIds.insert($0.id).inserted } + + currentMountainsSubject.send(uniqueMountains) + searchResultsSubject.send(uniqueMountains) + + // 받은 데이터 개수 업데이트 (중복 포함) + totalReceivedCountSubject.send(mountains.count) + + // 마지막 페이지 체크 (중복 포함 받은 데이터 개수 기준) if mountains.count >= response.body.totalCount { isLastPageSubject.send(true) } @@ -94,6 +104,7 @@ final class AddClimbRecordViewModel: BaseViewModel { errorSubject.send(("검색 실패", error.localizedDescription)) currentMountainsSubject.send([]) searchResultsSubject.send([]) + totalReceivedCountSubject.send(0) isLastPageSubject.send(true) } } @@ -117,13 +128,27 @@ final class AddClimbRecordViewModel: BaseViewModel { switch result { case .success(let response): let newMountains = response.body.items.item.map { $0.toMountain() } - let allMountains = currentMountainsSubject.value + newMountains + + // API 응답 자체에 중복이 있을 수 있으므로 먼저 중복 제거 (순서 유지) + var seenIdsInResponse = Set() + let deduplicatedNewMountains = newMountains.filter { seenIdsInResponse.insert($0.id).inserted } + + // 기존 데이터와 비교하여 중복 체크 + let existingMountains = currentMountainsSubject.value + let existingIds = Set(existingMountains.map { $0.id }) + let uniqueNewMountains = deduplicatedNewMountains.filter { !existingIds.contains($0.id) } + + let allMountains = existingMountains + uniqueNewMountains currentMountainsSubject.send(allMountains) searchResultsSubject.send(allMountains) currentPageSubject.send(currentPageSubject.value + 1) - // 마지막 페이지 체크 - if allMountains.count >= response.body.totalCount { + // 누적 받은 데이터 개수 업데이트 (API 응답 내 중복 제거 후) + let totalReceived = totalReceivedCountSubject.value + deduplicatedNewMountains.count + totalReceivedCountSubject.send(totalReceived) + + // 마지막 페이지 체크 (누적 개수 기준 또는 새 데이터가 없는 경우) + if totalReceived >= response.body.totalCount || uniqueNewMountains.isEmpty { isLastPageSubject.send(true) } case .failure(let error): diff --git a/Presentation/Sources/Scenes/ClimbRecordScene/RecordDetail/ClimbRecordDetailViewModel.swift b/Presentation/Sources/Scenes/ClimbRecordScene/RecordDetail/ClimbRecordDetailViewModel.swift index 9e2e4b6..6b5cd8e 100644 --- a/Presentation/Sources/Scenes/ClimbRecordScene/RecordDetail/ClimbRecordDetailViewModel.swift +++ b/Presentation/Sources/Scenes/ClimbRecordScene/RecordDetail/ClimbRecordDetailViewModel.swift @@ -14,6 +14,7 @@ protocol ClimbRecordDetailViewModelDelegate: AnyObject { func updateReview(id: String, rating: Int, comment: String) func deleteRecord(id: String) func updateImages(id: String, images: [String]) + func refreshStats() } final class ClimbRecordDetailViewModel: BaseViewModel { @@ -495,6 +496,7 @@ final class ClimbRecordDetailViewModel: BaseViewModel { switch result { case .success: delegate?.deleteRecord(id: climbRecord.id) + delegate?.refreshStats() popVCSubject.send(()) case .failure(let error): errorMesssageSubject.send(("기록 삭제에 실패했습니다", error.localizedDescription)) @@ -555,15 +557,15 @@ final class ClimbRecordDetailViewModel: BaseViewModel { .eraseToAnyPublisher() } } - .sink { result in - if let result = result { - switch result { - case .success: - NotificationCenter.default.post(name: .climbRecordDidSave, object: nil) - saveCompletedSubject.send(()) - case .failure(let error): - errorMesssageSubject.send(("저장 실패", error.localizedDescription)) - } + .sink { [weak self] result in + guard let self, let result else { return } + switch result { + case .success: + NotificationCenter.default.post(name: .climbRecordDidSave, object: nil) + delegate?.refreshStats() + saveCompletedSubject.send(()) + case .failure(let error): + errorMesssageSubject.send(("저장 실패", error.localizedDescription)) } } .store(in: &cancellables) diff --git a/Presentation/Sources/Scenes/ClimbRecordScene/RecordList/ClimbRecordListViewModel.swift b/Presentation/Sources/Scenes/ClimbRecordScene/RecordList/ClimbRecordListViewModel.swift index 9c42426..216e3c1 100644 --- a/Presentation/Sources/Scenes/ClimbRecordScene/RecordList/ClimbRecordListViewModel.swift +++ b/Presentation/Sources/Scenes/ClimbRecordScene/RecordList/ClimbRecordListViewModel.swift @@ -26,6 +26,7 @@ final class ClimbRecordListViewModel: BaseViewModel { private let reloadDataSubject = PassthroughSubject() private lazy var emptyStateTextSubject = CurrentValueSubject(emptyText) private let recordUpdatedSubject = PassthroughSubject() + private let statSubject = PassthroughSubject<(mountainCount: Int, climbCount: Int, totalHeight: Int), Never>() init(fetchUseCase: FetchClimbRecordUseCase, toggleBookmarkUseCase: ToggleBookmarkUseCase, fetchRecordImageUseCase: FetchRecordImageUseCase, getRecordStatsUseCase: GetRecordStatsUseCase) { self.fetchUseCase = fetchUseCase @@ -55,7 +56,6 @@ final class ClimbRecordListViewModel: BaseViewModel { let isOnlyBookmarkedSubject = CurrentValueSubject(isOnlyBookmarked) let searchTextSubject = CurrentValueSubject("") let errorMessageSubject = PassthroughSubject<(String, String), Never>() - let statSubject = PassthroughSubject<(mountainCount: Int, climbCount: Int, totalHeight: Int), Never>() // viewDidLoad 시 전체 데이터 불러와서 통계와 일지 모두 업데이트 input.viewDidLoad @@ -105,18 +105,11 @@ final class ClimbRecordListViewModel: BaseViewModel { } .store(in: &cancellables) - // 검색, 북마크 필터, 새 기록 저장 시 일지만 다시 불러오기 + // 검색, 북마크 필터 변경 시 일지 다시 불러오기 let searchText = searchTextSubject.eraseToAnyPublisher() let isOnlyBookmarked = isOnlyBookmarkedSubject.eraseToAnyPublisher() - let climbRecordDidSave = NotificationCenter.default - .publisher(for: .climbRecordDidSave) - .map { _ in (searchTextSubject.value, isOnlyBookmarkedSubject.value) } - .eraseToAnyPublisher() - Publishers.Merge( - Publishers.CombineLatest(searchText, isOnlyBookmarked), - climbRecordDidSave - ) + Publishers.CombineLatest(searchText, isOnlyBookmarked) .flatMap { [fetchUseCase] keyword, isOnlyBookmarked in fetchUseCase.execute(keyword: keyword, isOnlyBookmarked: isOnlyBookmarked) .map { result -> (Result<[ClimbRecord], Error>, String) in @@ -147,6 +140,40 @@ final class ClimbRecordListViewModel: BaseViewModel { } } .store(in: &cancellables) + + // 새 기록 저장 시 일지 + 통계 갱신 + NotificationCenter.default + .publisher(for: .climbRecordDidSave) + .flatMap { [fetchUseCase] _ in + fetchUseCase.execute(keyword: searchTextSubject.value, isOnlyBookmarked: isOnlyBookmarkedSubject.value) + } + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .success(let records): + climbRecordList = records + recordImageDatas = [:] + reloadDataSubject.send(()) + + if records.isEmpty { + if searchTextSubject.value.isEmpty { + emptyStateTextSubject.send(emptyText) + } else { + emptyStateTextSubject.send("검색 결과가 없습니다\n다른 키워드로 검색해보세요") + } + } + + // 통계 갱신 (전체 데이터 기준) + self.refreshStats() + + case .failure(let error): + errorMessageSubject.send(("기록 불러오기 실패", error.localizedDescription)) + climbRecordList = [] + reloadDataSubject.send(()) + } + } + .store(in: &cancellables) // 셀 북마크 토글 let bookmarkToggledSubject = PassthroughSubject() @@ -268,4 +295,22 @@ extension ClimbRecordListViewModel: ClimbRecordDetailViewModelDelegate { recordUpdatedSubject.send(id) } } + + // 통계 갱신 + func refreshStats() { + fetchUseCase.execute(keyword: "", isOnlyBookmarked: false) + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .success(let records): + // 전체 데이터로 통계 재계산 + let stats = self.getRecordStatsUseCase.execute(records: records) + self.statSubject.send((mountainCount: stats.mountainCount, climbCount: stats.climbCount, totalHeight: stats.totalHeight)) + case .failure: + break + } + } + .store(in: &cancellables) + } } diff --git a/Presentation/Sources/Scenes/MapScene/MapViewController.swift b/Presentation/Sources/Scenes/MapScene/MapViewController.swift index d086144..c114e85 100644 --- a/Presentation/Sources/Scenes/MapScene/MapViewController.swift +++ b/Presentation/Sources/Scenes/MapScene/MapViewController.swift @@ -10,15 +10,12 @@ import Combine import MapKit import Domain -final class MapViewController: UIViewController, BaseViewController, NetworkStatusObservable { +final class MapViewController: UIViewController, BaseViewController { var pushInfoVC: ((MountainInfo) -> Void)? let mainView = MapView() let viewModel: MapViewModel - - var networkStatusBanner: NetworkStatusBannerView? - var networkStatusCancellable: AnyCancellable? private var cancellables = Set() private lazy var dataSource = createDataSource() @@ -52,7 +49,6 @@ final class MapViewController: UIViewController, BaseViewController, NetworkStat setupDelegates() setupMapBoundary() setupAnnotationViewBuilder() - setupNetworkStatusObserver() bind() viewDidLoadSubject.send(()) @@ -67,10 +63,6 @@ final class MapViewController: UIViewController, BaseViewController, NetworkStat super.viewWillDisappear(animated) navigationController?.setNavigationBarHidden(false, animated: animated) } - - deinit { - removeNetworkStatusObserver() - } func bind() { let input = MapViewModel.Input( diff --git a/Presentation/Sources/Scenes/MeasureScene/MeasureViewModel.swift b/Presentation/Sources/Scenes/MeasureScene/MeasureViewModel.swift index eaa75cc..d43501f 100644 --- a/Presentation/Sources/Scenes/MeasureScene/MeasureViewModel.swift +++ b/Presentation/Sources/Scenes/MeasureScene/MeasureViewModel.swift @@ -96,6 +96,7 @@ final class MeasureViewModel: BaseViewModel { let currentKeywordSubject = CurrentValueSubject("") let isLastPageSubject = CurrentValueSubject(false) let currentMountainsSubject = CurrentValueSubject<[MountainInfo], Never>([]) + let totalReceivedCountSubject = CurrentValueSubject(0) let viewDidLoad = input.viewDidLoad .share() @@ -195,6 +196,7 @@ final class MeasureViewModel: BaseViewModel { currentKeywordSubject.send(keyword) currentPageSubject.send(1) isLastPageSubject.send(false) + totalReceivedCountSubject.send(0) isLoadingSubject.send(true) }) .flatMap { [weak self] keyword -> AnyPublisher, Never> in @@ -208,10 +210,18 @@ final class MeasureViewModel: BaseViewModel { switch result { case .success(let response): let mountains = response.body.items.item - currentMountainsSubject.send(mountains) - searchResultsSubject.send(mountains) - // 마지막 페이지 체크 + // API 응답 자체에 중복이 있을 수 있으므로 중복 제거 (순서 유지) + var seenIds = Set() + let uniqueMountains = mountains.filter { seenIds.insert($0.id).inserted } + + currentMountainsSubject.send(uniqueMountains) + searchResultsSubject.send(uniqueMountains) + + // 받은 데이터 개수 업데이트 (중복 포함) + totalReceivedCountSubject.send(mountains.count) + + // 마지막 페이지 체크 (중복 포함 받은 데이터 개수 기준) if mountains.count >= response.body.totalCount { isLastPageSubject.send(true) } @@ -219,6 +229,7 @@ final class MeasureViewModel: BaseViewModel { errorMessageSubject.send(("검색 실패", error.localizedDescription)) currentMountainsSubject.send([]) searchResultsSubject.send([]) + totalReceivedCountSubject.send(0) isLastPageSubject.send(true) } } @@ -242,13 +253,27 @@ final class MeasureViewModel: BaseViewModel { switch result { case .success(let response): let newMountains = response.body.items.item - let allMountains = currentMountainsSubject.value + newMountains + + // API 응답 자체에 중복이 있을 수 있으므로 먼저 중복 제거 (순서 유지) + var seenIdsInResponse = Set() + let deduplicatedNewMountains = newMountains.filter { seenIdsInResponse.insert($0.id).inserted } + + // 기존 데이터와 비교하여 중복 체크 + let existingMountains = currentMountainsSubject.value + let existingIds = Set(existingMountains.map { $0.id }) + let uniqueNewMountains = deduplicatedNewMountains.filter { !existingIds.contains($0.id) } + + let allMountains = existingMountains + uniqueNewMountains currentMountainsSubject.send(allMountains) searchResultsSubject.send(allMountains) currentPageSubject.send(currentPageSubject.value + 1) - // 마지막 페이지 체크 - if allMountains.count >= response.body.totalCount { + // 누적 받은 데이터 개수 업데이트 (API 응답 내 중복 제거 후) + let totalReceived = totalReceivedCountSubject.value + deduplicatedNewMountains.count + totalReceivedCountSubject.send(totalReceived) + + // 마지막 페이지 체크 (누적 개수 기준 또는 새 데이터가 없는 경우) + if totalReceived >= response.body.totalCount || uniqueNewMountains.isEmpty { isLastPageSubject.send(true) } case .failure(let error): diff --git a/Presentation/Sources/Scenes/SearchScene/SearchViewModel.swift b/Presentation/Sources/Scenes/SearchScene/SearchViewModel.swift index a48c8fd..0283992 100644 --- a/Presentation/Sources/Scenes/SearchScene/SearchViewModel.swift +++ b/Presentation/Sources/Scenes/SearchScene/SearchViewModel.swift @@ -59,6 +59,7 @@ final class SearchViewModel: BaseViewModel { let currentKeywordSubject = CurrentValueSubject("") let isLastPageSubject = CurrentValueSubject(false) let currentMountainsSubject = CurrentValueSubject<[MountainInfo], Never>([]) + let totalReceivedCountSubject = CurrentValueSubject(0) // 최근 검색어 Fetch let loadRecentSearchTrigger = PassthroughSubject() @@ -134,6 +135,7 @@ final class SearchViewModel: BaseViewModel { currentKeywordSubject.send(keyword) currentPageSubject.send(1) isLastPageSubject.send(false) + totalReceivedCountSubject.send(0) isLoadingSubject.send(true) }) .flatMap { [weak self] keyword -> AnyPublisher, Never> in @@ -147,10 +149,18 @@ final class SearchViewModel: BaseViewModel { switch result { case .success(let response): let mountains = response.body.items.item - currentMountainsSubject.send(mountains) - searchResultsSubject.send(mountains) - // 마지막 페이지 체크 + // API 응답 자체에 중복이 있을 수 있으므로 중복 제거 (순서 유지) + var seenIds = Set() + let uniqueMountains = mountains.filter { seenIds.insert($0.id).inserted } + + currentMountainsSubject.send(uniqueMountains) + searchResultsSubject.send(uniqueMountains) + + // 받은 데이터 개수 업데이트 (중복 포함) + totalReceivedCountSubject.send(mountains.count) + + // 마지막 페이지 체크 (중복 포함 받은 데이터 개수 기준) if mountains.count >= response.body.totalCount { isLastPageSubject.send(true) } @@ -158,6 +168,7 @@ final class SearchViewModel: BaseViewModel { errorMessageSubject.send(("검색 실패", error.localizedDescription)) currentMountainsSubject.send([]) searchResultsSubject.send([]) + totalReceivedCountSubject.send(0) isLastPageSubject.send(true) } } @@ -181,13 +192,27 @@ final class SearchViewModel: BaseViewModel { switch result { case .success(let response): let newMountains = response.body.items.item - let allMountains = currentMountainsSubject.value + newMountains + + // API 응답 자체에 중복이 있을 수 있으므로 먼저 중복 제거 (순서 유지) + var seenIdsInResponse = Set() + let deduplicatedNewMountains = newMountains.filter { seenIdsInResponse.insert($0.id).inserted } + + // 기존 데이터와 비교하여 중복 체크 + let existingMountains = currentMountainsSubject.value + let existingIds = Set(existingMountains.map { $0.id }) + let uniqueNewMountains = deduplicatedNewMountains.filter { !existingIds.contains($0.id) } + + let allMountains = existingMountains + uniqueNewMountains currentMountainsSubject.send(allMountains) searchResultsSubject.send(allMountains) currentPageSubject.send(currentPageSubject.value + 1) - // 마지막 페이지 체크 - if allMountains.count >= response.body.totalCount { + // 누적 받은 데이터 개수 업데이트 (API 응답 내 중복 제거 후) + let totalReceived = totalReceivedCountSubject.value + deduplicatedNewMountains.count + totalReceivedCountSubject.send(totalReceived) + + // 마지막 페이지 체크 (누적 개수 기준 또는 새 데이터가 없는 경우) + if totalReceived >= response.body.totalCount || uniqueNewMountains.isEmpty { isLastPageSubject.send(true) } case .failure(let error): diff --git a/Project.swift b/Project.swift index 5687651..dd14c4e 100644 --- a/Project.swift +++ b/Project.swift @@ -3,7 +3,7 @@ import ProjectDescription let iOSVersion = "16.0" let teamID = "4QUWH828P3" let appVersion = "1.4.1" -let buildNumber = "1" +let buildNumber = "2" let project = Project( name: "Oreum",