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", diff --git a/README.md b/README.md index 934d2c9..299aade 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # 오름 > 한국의 산 정보를 제공하고, 등산 기록을 측정/관리하는 iOS 앱 > [앱스토어](https://apps.apple.com/kr/app/%EC%98%A4%EB%A6%84-%EB%82%98%EC%9D%98-%EB%93%B1%EC%82%B0-%EA%B8%B0%EB%A1%9D/id6753770017) -> 현재 버전: 1.3.0 +> 현재 버전: 1.4.1
## 📌 프로젝트 소개 > 프로젝트 기간: 2025/09/21 ~ 2025/10/09 +> 업데이트 기간: 2025/10/10 ~ 현재 > 개인 프로젝트 **오름**은 등산 기록 및 관리 앱입니다. -- Restful API를 통해 한국의 산 정보를 제공하고 -- Healthkit을 활용하여 등산 활동을 추적하며 -- Realm DB를 통해 사진, 후기 등의 기록을 관리할 수 있습니다. +- 한국의 산 정보를 제공하고 +- 등산 활동을 추적해 통계를 제공하며 +- 사진, 후기, 별점으로 등산 기록을 관리할 수 있습니다.
@@ -25,18 +26,59 @@
-## 📌 기술스택 +## 📌 구현 사항 -- Tuist를 활용해 Clean Architecture 계층을 모듈화하여 의존성 관리 및 빌드 효율 향상 +**Architecture** +- Tuist를 활용한 Clean Architecture 계층 모듈화로 의존성 관리 및 빌드 효율 향상 - MVVM-C와 Combine 기반 구조체 Input/Output 패턴으로 명확한 데이터 흐름과 반응형 프로그래밍 구현 -- DI Container를 적용해 서비스/개발 환경별 Repository 주입 분리 및 테스트 용이성 확보 -- Alamofire와 Router 패턴으로 API 통신 구조 표준화, NWPathMonitor로 네트워크 상태 감지, 응답 값 기반 에러 처리 + +**Network** +- Alamofire와 Router 패턴으로 API 통신 구조 표준화, protocol을 활용한 응답 값 기반 에러 처리 +- NWPathMonitor로 네트워크 상태를 감지해 단절/재연결 배너를 상단 표기 + +**Database** - Realm을 활용한 1:N 관계형 DB 설계, 양방향 관계 설정으로 데이터 무결성 보장 -- 지도 줌 레벨 기반 Grid 클러스터링으로 시각적 가독성 개선 -- HealthKit을 활용해 앱 종료 중에도 데이터 수집 -- 기기 크기에 따른 이미지 리사이징으로 저장 공간 절감 및 성능 향상 +- Index를 활용한 DB 검색 및 정렬 성능 최적화 + +**Caching** +- NSCache와 FileManager를 활용한 캐싱 구현으로 데이터 접근 속도 개선 및 네트워크 단절 대응 +- NSCache로 메모리 캐시, FileManager로 디스크 캐시를 LRU 방식으로 구현 +- 캐시 유효기간 설정으로 E-Tag 미지원 환경에서 데이터 정합성 확보 +- 메모리 캐시 -> 디스크 캐시 -> 네트워크 파이프라인 구축 + +**HealthKit** +- HealthKit을 활용한 앱 종료 상태에서의 데이터 수집, 휴리스틱 필터와 칼만 필터로 데이터 보정 + 1. iOS HealthKit의 배터리 최적화로 일부 기록이 누락되고 데이터가 일괄 업데이트 되는 문제 발견 + 2. 휴리스틱 필터 기반 이상치 감지 및 균등 배분 + 3. 칼만 필터 기반 보정을 통해 실제 걸음 수 / 이동 거리에 가깝게 복원 +- 권한 미허용 시 설정으로 이동 유도, 앱이 Active 상태가 되었을 때 권한 확인 + +**Clustering** +- Quad-Tree 기반 클러스터링으로 지돚의 시각적 가독성과 효율성 개선 + 1. 전체 데이터의 경계를 계산하고, Quad-Tree 생성하여, capacity 초과 시 재귀적으로 4분할하며 데이터 삽입 + 2. 지도의 줌 레벨에 따라 Quad-Tree의 최대 탐색 깊이를 조정하여 확대 시 개별 마커, 축소 시 클러스터를 표시 + 3. 가시 영역 검사로 필요한 노드만 조회, 실제 거리 기반 서브 클러스터링 +- 지도 범위 한국으로 한정 + +**Widget** +- App Group과 UserDefaults를 활용하며 메인 앱과 위젯 간 측정 데이터를 공유 +- 측정 상태에서 5분 간격의 Timeline Entry를 미리 생성하는 방식으로 위젯의 실시간 업데이트 한계 극복 +- 측정 종료 시 새로운 정적 Timeline으로 교체하여 불필요한 리소스 소비 방지 및 배터리 효율성 최적화 + +**CI / CD** +- DI Container를 적용해 서비스/개발 환경별 Repository 주입 분리 및 테스트 용이성 확보 +- Github Actions와 Fastlane으로 자동화된 테스트와 배포 시스템 구현 + +**Mornitoring** - Firebase Analytics와 Crashlytics로 사용자 행동 및 안정성 모니터링 -- UIKit(code base, +SwiftUI), Combine, MapKit, CoreLocation, HealthKit, Charts, NWPathMonitor, Tuist, Realm, Alamofire, Kingfisher, SnapKit, Toast, Firebase Analytics, Firebase Crashlytics + +**etc.** +- 기기 크기 기반 이미지 리사이징으로 저장 공간 절감 및 성능 향상 +- 로컬 Push 알림으로 등산 완료 시 앱에서의 측정 종료 유도 +- Offset 기반 페이지네이션 중복 데이터 제거 + +**Frameworks** +- UIKit(code base, +SwiftUI), Combine, MapKit, CoreLocation, HealthKit, Charts, NWPathMonitor, Tuist, Realm, Alamofire,XMLCoder, Kingfisher, SnapKit, Toast, Firebase Analytics, Firebase Crashlytics
@@ -49,53 +91,36 @@ 검색/산 정보 보기 - 등산 기록 관리 - 기록 측정/통계 - 명산 지도 - 검색/산 정보 보기 + 등산 기록 관리 + 기록 측정/통계 + 명산 지도 + 검색/산 정보 보기 -
-등산 기록 관리 - -- Realm DB를 활용하여 등산 기록을 로컬에 저장하고 관리 -- 등산 중 촬영한 사진과 후기, 별점과 측정 정보 등을 함께 저장 -- 디바이스 크기에 따른 이미지 Resizing으로 저장 공간 관리 -- 수정/삭제, 검색/북마크 기능 지원 - -
- -
-기록 측정/통계 - -- HealthKit을 활용하여 앱이 실행 중이 아니더라도 걸음 수와 이동 거리 측정 -- 실시간으로 등산 진행 상황을 확인 -- 운동 시간, 휴식 시간 분석 및 걸음 수, 이동 거리 통계 정보 제공 -- Charts를 활용한 시간 별 걸음 수/이동 거리 시각화 - -
+**등산 기록 관리** +- 등산 기록을 사진, 후기, 별점 등으로 저장 +- 등산 기록 통계 제공(산 개수, 총 등산 횟수, 총 높이) +- 기록 수정 / 삭제, 검색 및 북마크 기능 -
-명산 지도 -- MapKit을 사용하여 전국의 명산 위치를 지도에 표시 -- 줌 레벨에 따른 Grid 기반 Clustering -- 현재 위치 기반으로 주변 산 탐색 -- 지도에서 산을 선택하여 상세 정보 확인 가능 +**기록 측정/통계** +- 등산 중 걸음 수와 이동 거리 측정 +- 5분 단위, 평균 대비 기록 분석 및 차트로 시각화된 통계 데이터 제공 +- 위젯을 통한 등산 측정 진행 상황 표기(산 이름, 측정 경과 시간) +- 로컬 Push 알림 발송으로 등산 완료 시 측정 종료 유도 -
-
-검색/산 정보 보기 +**명산 지도** +- 전국 100대 명산 지도 표기 +- 현재 위치 기반 산과의 거리 표기 +- 산이 밀집된 지역은 그룹화하여 개수로 표기 +- 산 선택 시 상세 정보 확인 가능 -- 전국 산 정보 검색 -- 검색 결과에서 산을 선택하여 상세 페이지로 이동 -- Geocoding과 기상청 API를 연결해 날씨 정보 제공 -- 산 이름, 위치, 높이 등의 기본 정보 제공 -- Realm DB를 통한 최근 검색어 기능 지원 -
+**검색/산 정보 보기** +- 전국 산 정보 검색으로 산 상세 정보와 해당 지역 날씨 정보 제공 +- 최근 검색어 기능 지원
@@ -148,7 +173,7 @@ tuist generate ### Try -- **API 복구 이후에는 더 풍부한 데이터를 활용해 로직을 단순화하고, 제공 정보를 확장할 예정입니다.** +- **API 복구 이후에는 더 풍부한 데이터를 활용해 로직을 단순화하고, 제공 정보를 확장할 예정입니다. (업데이트 완료)** - **Clean Architecture, Coordinator, DIContainer 등 이론적으로 학습했던 개념을 직접 적용해보며 많은 인사이트를 얻었습니다.** 앞으로도 다양한 설계 개념을 지속적으로 학습하고 적용하여, 보다 완성도 높은 구조와 코드 품질을 구현할 수 있도록 노력하겠습니다.