From a6f15229856b2668651b7d13c1bd4dd3f4766fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD=20Miya?= Date: Wed, 29 Oct 2025 18:08:32 +0900 Subject: [PATCH 01/15] Create README.md --- README.md | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..63a1266 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +AppIcon + + +## 사하라 + +> 사진을 포토카드로 만들어 기록, 편집하고 탐색할 수 있는 사진 아카이빙 앱 + +[AppStore Link](https://apps.apple.com) + +image 6 | image 7 | IMG_2300 | IMG_2492 | +|:--:|:--:|:--:|:--:| + +|구분|내용| +|:--:|:--| +|**팀 인원**|1명 / 기획, 디자인, 개발| +|**기획 및 개발 기간**|2025.09 - 2025.10 (3주, 핵심 개발 기간 1주)| +|**최소지원버전**|iOS 16.0+| + +## 핵심 기능 + +- 사진 촬영 및 포토카드 편집 - 스티커, 필터, 펜, 메모, 사진 분류 +- 날짜/지역/테마/폴더별 사진 분류 +- 비밀 카드 잠금 설정 +- 카드 검색 및 정렬 통계 확인 +- 서비스 소식 알림 수신 +- 4개 국어 지원(한국어, 영어, 중국어, 일본어) + +## 기술 스택 + +| 분류 | 기술 스택 | +|:--:|:--| +| **UI Framework** | ![UIKit](https://img.shields.io/badge/UIKit-2396F3?style=flat-square&logo=uikit&logoColor=white) ![SnapKit](https://img.shields.io/badge/SnapKit-FF6B6B?style=flat-square&logo=swift&logoColor=white) | +| **Asynchronous Programming** | ![RxSwift](https://img.shields.io/badge/RxSwift-B7178C?style=flat-square&logo=reactivex&logoColor=white) ![RxCocoa](https://img.shields.io/badge/RxCocoa-B7178C?style=flat-square&logo=reactivex&logoColor=white) ![RxDataSources](https://img.shields.io/badge/RxDataSources-B7178C?style=flat-square&logo=reactivex&logoColor=white) | +| **Architecture** | ![MVVM](https://img.shields.io/badge/MVVM-6DB33F?style=flat-square&logo=databricks&logoColor=white) ![Input/Output Pattern](https://img.shields.io/badge/Input%2FOutput_Pattern-6DB33F?style=flat-square&logo=databricks&logoColor=white) | +| **Networking** | ![Alamofire](https://img.shields.io/badge/Alamofire-F05138?style=flat-square&logo=swift&logoColor=white) ![Router Pattern](https://img.shields.io/badge/Router_Pattern-F05138?style=flat-square&logo=swift&logoColor=white) | +| **Database** | ![Realm](https://img.shields.io/badge/Realm-39477F?style=flat-square&logo=realm&logoColor=white) | +| **Libraries** | ![Kingfisher](https://img.shields.io/badge/Kingfisher-FFA500?style=flat-square&logo=swift&logoColor=white) ![DiffableDataSource](https://img.shields.io/badge/DiffableDataSource-147EFB?style=flat-square&logo=apple&logoColor=white) | +| **Apple Frameworks** | ![MapKit](https://img.shields.io/badge/MapKit-007AFF?style=flat-square&logo=apple&logoColor=white) ![PencilKit](https://img.shields.io/badge/PencilKit-007AFF?style=flat-square&logo=apple&logoColor=white) ![CoreLocation](https://img.shields.io/badge/CoreLocation-007AFF?style=flat-square&logo=apple&logoColor=white) ![AVFoundation](https://img.shields.io/badge/AVFoundation-007AFF?style=flat-square&logo=apple&logoColor=white) ![CoreImage](https://img.shields.io/badge/CoreImage-007AFF?style=flat-square&logo=apple&logoColor=white) ![Photos](https://img.shields.io/badge/Photos-007AFF?style=flat-square&logo=apple&logoColor=white) ![PhotosUI](https://img.shields.io/badge/PhotosUI-007AFF?style=flat-square&logo=apple&logoColor=white) ![LocalAuthentication](https://img.shields.io/badge/LocalAuthentication-007AFF?style=flat-square&logo=apple&logoColor=white) | +| **Tools** | ![Xcode](https://img.shields.io/badge/Xcode-147EFB?style=flat-square&logo=xcode&logoColor=white) ![Git](https://img.shields.io/badge/Git-F05032?style=flat-square&logo=git&logoColor=white) | +| **Testing** | ![XCTest](https://img.shields.io/badge/XCTest-6C757D?style=flat-square&logo=xcode&logoColor=white) | +| **Firebase** | ![Firebase Analytics](https://img.shields.io/badge/Analytics-FFCA28?style=flat-square&logo=firebase&logoColor=black) ![Firebase Crashlytics](https://img.shields.io/badge/Crashlytics-FFCA28?style=flat-square&logo=firebase&logoColor=black) ![Firebase Cloud Messaging](https://img.shields.io/badge/Cloud_Messaging-FFCA28?style=flat-square&logo=firebase&logoColor=black) | + +### 전체 구조 +**MVVM + Reactive Programming + Input/Output** + +- Protocol 기반 DI를 적용해 데이터 레이어 의존성 분리, 테스트 용이성 확보 +- 외부 의존성인 데이터베이스, 네트워크만 DI로 분리하여 핵심 비즈니스 로직 테스트 +- 도메인 요구사항의 잦은 변화로 인해 과도한 추상화 배제, MVVM으로 관심사 분리 + +### 데이터 관리 +**데이터베이스 설계** +- Card-Sticker 1:N 정규화 관계 설계 +- 날짜 기반 인덱스 활용하여 쿼리 최적화 +- 데이터베이스 변경사항 발생시 Observable 스트림으로 UI 실시간 동기화 + +**DTO 패턴** + +- Realm 객체의 스레드 제약을 DTO 변환으로 해결하여 안전하게 데이터 전달 +- View는 DTO만 참조하여 write 트랜잭션 충돌 방지 + +### 캐싱 + +**이미지 캐싱 전략 (Kingfisher)** + +- 메모리 캐시는 100MB 또는 100개 초과 시, 디스크 캐시 는 500MB 초과 시 LRU 방식 삭제로 용량 제한 +- 메모리 캐시는 10분 후 자동 삭제, 캐시는 디스크 7일 후 자동 삭제로 시간 제한 +- Downsampling을 활용하여 썸네일 200x200, 실사용은 뷰 크기 이미지 사용 + +## 구현 기능 + +### 갤러리 + +1| 2 | 3 | 4 +|:--:|:--:|:--:|:--:| + +#### 날짜별 보기 + +- 월별 캘린더 뷰(Custom Calendar) +- 각 날짜에 최대 4개 표시 (레이아웃 3개) +- 동적 미니/최대 레이아웃 (1개/2개/3개 지도 배치) +- 월 이동, 오늘 날짜 하이라이트 + +#### 장소별 보기 + +- MapKit 기반 지도 +- 줌 레벨에 따라 핀 클러스터링 +- 대표 이미지 표시 (점유 여때에 따라 우선순위 클러스터 정렬) +- 클러스터 개수 표시 + + + + +#### 주제별 보기 + +- Vision Framework 지능 분류 (사람/음식/식물/동물/중국어 간체) + +#### 폴더별 보기 + +- 커스텀 폴더 생성/색상/센상 +- 폴더별 카드 필터링 + +### 편집 + +| 5 | 6 | 7 | +|:--:|:--:|:--:| +| 8 | 9 | 10 | + + +#### 스티커 + +- RESTful API + offset 기반 페이지네이션 +- 드래그/핀치/회전 제스처 지원 +- 신규 스티커 추가 (사진 라이브러리 접근) +- 다중 스티커 배치 지원 +- 신규 스티커 추가 시 템플릿에서 선택 가능하도록 틀 제공 + +#### 그리기(PencilKit) + +- 자유 그리기 +- 실행 취소/재실행 기능 + +#### 필터 + +- 10가지 필터 제공 +- 실시간 프리뷰 + +#### 자료 기입 + +- 날짜 입력 (OCR 자동 추출, 디폴트 오늘) +- 메모 입력 (OCR 텍스트 Vision Framework 추출) +- 위치 검색 (MapKit 장소 검색, 현재 위치) +- 폴더 선택 +- 생체 인증 (Face ID/Touch ID, 실패 시 기기 비밀번호 사용) + + +### 카드작성 +| 11 | 12 | 13 | +|:--:|:--:|:--:| +| 14 | 15 | + + +#### 미디어 선택 + +- 시스템 카메라/앨범 +- Photo Picker (전체 라이브러리 접근) +- 권한 기반 위치 그리드 (GPS/내재 메타데이터 포함) +- Limited/Authorized 권한별 그리드 데이터 템플릿 +- 그리드 표시 후 내/외부에서 선택 가능하도록 기능 제공 + +#### 카드 정보 입력 + +- 날짜 선택 +- 메모 입력 (OCR 텍스트 Vision Framework 추출) +- 위치 검색 (MapKit 장소 검색, 현재 위치) +- 폴더 선택 +- 생체 인증 (Face ID/Touch ID 생체 인증, 실패 시 기기 비밀번호 사용) + + +### 검색 / 통계 / 설정 + +| 16 | 17 | 18 | 19 | +|:--:|:--:|:--:|:--:| + +### 실시간 검색 + +- 메모 (서브텍스트 검색) 입력, OCR 텍스트 (Vision Framework 추출) 검색 +- Masonry Layout 그리드 + +### 통계 + +- 총 카드 수 / 이번 달 카드 수 / 연속 작성 일수 (Streak) 통계 +- 막대 차트 + +### 설정 + +**일반** +- 언어 선택 (한국어/영어/일본어/중국어 간체) + +**알림** +- 서비스 소식 토픽 구독 방식 FCM Topic 구독 관리 + +**지원** +- 앱 메일을 활용한 개발자 문의 +- 기기 정보 자동 수집 +- 버전별 변경사항 릴리즈 노트 From 7190cb2f2108afdacb3dc03fdf4b994ee4a9dcd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Mon, 20 Oct 2025 20:38:07 +0900 Subject: [PATCH 02/15] Refactor MediaEditor to MeidaEdotirCoordinator --- .../CardInfo/CardInfoCoordinator.swift | 35 +++++++-- .../MediaEditor/MediaEditorCoordinator.swift | 77 +++++++++++++++++++ .../MediaEditorViewController+Mode.swift | 19 +---- .../MediaEditorViewController.swift | 17 +--- 4 files changed, 112 insertions(+), 36 deletions(-) create mode 100644 Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorCoordinator.swift diff --git a/Sahara/Feature/CardInfo/CardInfoCoordinator.swift b/Sahara/Feature/CardInfo/CardInfoCoordinator.swift index dd9085a..a4b900f 100644 --- a/Sahara/Feature/CardInfo/CardInfoCoordinator.swift +++ b/Sahara/Feature/CardInfo/CardInfoCoordinator.swift @@ -18,6 +18,8 @@ final class CardInfoCoordinator: Coordinator { var navigationController: UINavigationController? weak var delegate: CardInfoCoordinatorDelegate? weak var parentViewController: UIViewController? + private var onMediaEditingComplete: ((UIImage) -> Void)? + private var mediaEditorCoordinator: MediaEditorCoordinator? init(parentViewController: UIViewController) { self.parentViewController = parentViewController @@ -44,14 +46,22 @@ final class CardInfoCoordinator: Coordinator { selectedImageSubject: BehaviorSubject, onEditingComplete: @escaping (UIImage) -> Void ) { + self.onMediaEditingComplete = onEditingComplete + parentViewController?.dismiss(animated: true) { [weak self] in - let viewModel = MediaEditorViewModel(originalImage: image) - let editorVC = MediaEditorViewController(viewModel: viewModel) - editorVC.onEditingComplete = onEditingComplete + guard let self = self else { return } - let navController = UINavigationController(rootViewController: editorVC) + let navController = UINavigationController() navController.modalPresentationStyle = .fullScreen - self?.parentViewController?.present(navController, animated: true) + + self.mediaEditorCoordinator = MediaEditorCoordinator( + navigationController: navController, + originalImage: image + ) + self.mediaEditorCoordinator?.delegate = self + self.mediaEditorCoordinator?.start() + + self.parentViewController?.present(navController, animated: true) } } @@ -101,3 +111,18 @@ final class CardInfoCoordinator: Coordinator { } } } + +extension CardInfoCoordinator: MediaEditorCoordinatorDelegate { + func didFinishEditing(with image: UIImage) { + parentViewController?.dismiss(animated: true) { [weak self] in + self?.onMediaEditingComplete?(image) + self?.onMediaEditingComplete = nil + self?.mediaEditorCoordinator = nil + } + } + + func didCancelEditing() { + onMediaEditingComplete = nil + mediaEditorCoordinator = nil + } +} diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorCoordinator.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorCoordinator.swift new file mode 100644 index 0000000..f97e7ae --- /dev/null +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorCoordinator.swift @@ -0,0 +1,77 @@ +// +// MediaEditorCoordinator.swift +// Sahara +// +// Created by 금가경 on 10/20/25. +// + +import UIKit + +protocol MediaEditorCoordinatorDelegate: AnyObject { + func didFinishEditing(with image: UIImage) + func didCancelEditing() +} + +final class MediaEditorCoordinator: Coordinator { + var navigationController: UINavigationController? + weak var delegate: MediaEditorCoordinatorDelegate? + + private let originalImage: UIImage + + init(navigationController: UINavigationController?, originalImage: UIImage) { + self.navigationController = navigationController + self.originalImage = originalImage + } + + func start() { + let viewModel = MediaEditorViewModel(originalImage: originalImage) + let editorVC = MediaEditorViewController(viewModel: viewModel) + editorVC.coordinator = self + + navigationController?.pushViewController(editorVC, animated: true) + } + + func presentStickerModal( + viewModel: MediaEditorViewModel, + onStickerSelected: @escaping (KlipySticker) -> Void + ) { + guard let currentVC = navigationController?.topViewController else { return } + + let stickerModalVC = StickerModalViewController(viewModel: viewModel) + stickerModalVC.onStickerSelected = onStickerSelected + + let navController = UINavigationController(rootViewController: stickerModalVC) + if let sheet = navController.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + currentVC.present(navController, animated: true) + } + + func presentPhotoSelection(onPhotoSelected: @escaping (UIImage) -> Void) { + guard let currentVC = navigationController?.topViewController else { return } + + let mediaSelectionVC = MediaSelectionViewController() + mediaSelectionVC.onMediaSelected = { image, _, _ in + onPhotoSelected(image) + } + + let navController = UINavigationController(rootViewController: mediaSelectionVC) + if let sheet = navController.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + currentVC.present(navController, animated: true) + } + + func finishEditing(with image: UIImage) { + delegate?.didFinishEditing(with: image) + } + + func cancelEditing() { + if let navController = navigationController { + navController.dismiss(animated: true) + } + delegate?.didCancelEditing() + } +} diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Mode.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Mode.swift index 18cc7c8..c30ded8 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Mode.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Mode.swift @@ -166,31 +166,16 @@ extension MediaEditorViewController { } func presentStickerModal() { - let stickerModalVC = StickerModalViewController(viewModel: viewModel) - stickerModalVC.onStickerSelected = { [weak self] sticker in + coordinator?.presentStickerModal(viewModel: viewModel) { [weak self] sticker in self?.addStickerToPhoto(sticker) } - - let navController = UINavigationController(rootViewController: stickerModalVC) - if let sheet = navController.sheetPresentationController { - sheet.detents = [.large()] - sheet.prefersGrabberVisible = true - } - present(navController, animated: true) } func presentPhotoSelectionModal() { - let mediaSelectionVC = MediaSelectionViewController() - mediaSelectionVC.onMediaSelected = { [weak self] image, _, _ in + coordinator?.presentPhotoSelection { [weak self] image in self?.addPhotoToCanvas(image) self?.currentMode.accept(nil) } - let navController = UINavigationController(rootViewController: mediaSelectionVC) - if let sheet = navController.sheetPresentationController { - sheet.detents = [.large()] - sheet.prefersGrabberVisible = true - } - present(navController, animated: true) } } diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift index b8d62bf..8c98cfa 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift @@ -245,7 +245,7 @@ final class MediaEditorViewController: UIViewController { let viewModel: MediaEditorViewModel private let disposeBag = DisposeBag() - var onEditingComplete: ((UIImage) -> Void)? + weak var coordinator: MediaEditorCoordinator? private var stickerViews: [DraggableStickerView] = [] private var photoViews: [DraggableImageView] = [] @@ -528,24 +528,13 @@ final class MediaEditorViewController: UIViewController { AnalyticsManager.shared.logPhotoEditComplete(toolsUsedCount: self.usedTools.count) }) .drive(with: self) { owner, editedImage in - if let callback = owner.onEditingComplete { - callback(editedImage) - owner.dismiss(animated: true) - } else { - let metadataViewModel = CardInfoViewModel(editedImage: editedImage) - let metadataVC = CardInfoViewController(viewModel: metadataViewModel) - owner.navigationController?.pushViewController(metadataVC, animated: true) - } + owner.coordinator?.finishEditing(with: editedImage) } .disposed(by: disposeBag) output.dismiss .drive(with: self) { owner, _ in - if let navController = owner.navigationController { - navController.dismiss(animated: true) - } else { - owner.dismiss(animated: true) - } + owner.coordinator?.cancelEditing() } .disposed(by: disposeBag) From 8e2f357578c5a59a5e2bc72b24f43fc1f79993a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Mon, 20 Oct 2025 20:53:56 +0900 Subject: [PATCH 03/15] Refactor MediaEditorViewController --- .../MediaEditorImageStateHandler.swift | 62 +++++++++++++++++++ .../MediaEditorViewController+Crop.swift | 5 +- .../MediaEditorViewController+Mode.swift | 2 +- .../MediaEditorViewController.swift | 48 +++++++------- .../MediaEditor/MediaEditorViewModel.swift | 23 ++++--- 5 files changed, 102 insertions(+), 38 deletions(-) create mode 100644 Sahara/Feature/CardInfo/Component/MediaEditor/Handler/MediaEditorImageStateHandler.swift diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/Handler/MediaEditorImageStateHandler.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/Handler/MediaEditorImageStateHandler.swift new file mode 100644 index 0000000..b0e0d2c --- /dev/null +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/Handler/MediaEditorImageStateHandler.swift @@ -0,0 +1,62 @@ +// +// MediaEditorImageStateHandler.swift +// Sahara +// +// Created by 금가경 on 10/20/25. +// + +import RxCocoa +import RxSwift +import UIKit + +final class MediaEditorImageStateHandler { + private let originalImageRelay: BehaviorRelay + private let currentEditingImageRelay: BehaviorRelay + private let croppedImageRelay: BehaviorRelay + private let uncroppedOriginalImageRelay: BehaviorRelay + + init(originalImage: UIImage) { + self.originalImageRelay = BehaviorRelay(value: originalImage) + self.currentEditingImageRelay = BehaviorRelay(value: originalImage) + self.croppedImageRelay = BehaviorRelay(value: nil) + self.uncroppedOriginalImageRelay = BehaviorRelay(value: originalImage) + } + + var originalImage: Driver { + originalImageRelay.asDriver() + } + + var currentEditingImage: Driver { + currentEditingImageRelay.asDriver() + } + + var croppedImage: Driver { + croppedImageRelay.asDriver() + } + + var uncroppedOriginalImage: Driver { + uncroppedOriginalImageRelay.asDriver() + } + + func updateCurrentEditingImage(_ image: UIImage) { + currentEditingImageRelay.accept(image) + } + + func applyCrop(_ croppedImage: UIImage) { + croppedImageRelay.accept(croppedImage) + currentEditingImageRelay.accept(croppedImage) + originalImageRelay.accept(croppedImage) + } + + func applyFilter(_ filteredImage: UIImage) { + currentEditingImageRelay.accept(filteredImage) + } + + func getCurrentImage() -> UIImage { + currentEditingImageRelay.value + } + + func getCroppedOrOriginalImage() -> UIImage { + croppedImageRelay.value ?? originalImageRelay.value + } +} diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Crop.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Crop.swift index c3e12db..7ab0c6f 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Crop.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Crop.swift @@ -9,7 +9,7 @@ import UIKit extension MediaEditorViewController { func setupCropOverlay() { - guard let uncropped = uncropedOriginalImage else { return } + guard let uncropped = cachedUncroppedOriginalImage else { return } DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -49,7 +49,7 @@ extension MediaEditorViewController { } func applyCrop() { - guard let uncropped = uncropedOriginalImage else { return } + guard let uncropped = cachedUncroppedOriginalImage else { return } let cropRectInOverlay = cropOverlayView.cropRect let imageRectInOverlay = cropOverlayView.imageRect @@ -79,7 +79,6 @@ extension MediaEditorViewController { lastCropRect = cropRectInImage photoImageView.image = croppedImage - originalImage = croppedImage currentMode.accept(nil) } diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Mode.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Mode.swift index c30ded8..22d4aba 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Mode.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController+Mode.swift @@ -140,7 +140,7 @@ extension MediaEditorViewController { cropApplyButton.isHidden = false cropCancelButton.isHidden = false - guard let uncropped = uncropedOriginalImage else { return } + guard let uncropped = cachedUncroppedOriginalImage else { return } photoImageView.image = uncropped UIView.animate(withDuration: 0.3, animations: { diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift index 8c98cfa..6b7066f 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift @@ -244,30 +244,33 @@ final class MediaEditorViewController: UIViewController { }() let viewModel: MediaEditorViewModel - private let disposeBag = DisposeBag() weak var coordinator: MediaEditorCoordinator? + private let disposeBag = DisposeBag() + + let currentMode = BehaviorRelay(value: nil) + let toolPicker = PKToolPicker() private var stickerViews: [DraggableStickerView] = [] private var photoViews: [DraggableImageView] = [] - private var lastContainerSize: CGSize = .zero - let currentMode = BehaviorRelay(value: nil) private var selectedView: BaseGestureView? - let toolPicker = PKToolPicker() - var originalImage: UIImage? - private var croppedImage: UIImage? - var uncropedOriginalImage: UIImage? + private var lastContainerSize: CGSize = .zero + + var cachedUncroppedOriginalImage: UIImage? var lastCropRect: CGRect? - private let filterHandler = MediaEditorFilterHandler() - private let filterSelectedRelay = PublishRelay<(Int, UIImage?)>() - let photoSelectedRelay = PublishRelay() - private let viewWillAppearRelay = PublishRelay() - private var usedTools: Set = [] + private var cachedOriginalImageForFilter: UIImage? + private let filterHandler = MediaEditorFilterHandler() private lazy var dragHandler = MediaEditorDragHandler( trashIconView: trashIconView, parentView: view ) + private let viewWillAppearRelay = PublishRelay() + private let filterSelectedRelay = PublishRelay<(Int, UIImage?)>() + let photoSelectedRelay = PublishRelay() + + private var usedTools: Set = [] + init(viewModel: MediaEditorViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -499,20 +502,19 @@ final class MediaEditorViewController: UIViewController { output.originalImage .drive(with: self) { owner, image in owner.photoImageView.image = image - owner.originalImage = image - owner.uncropedOriginalImage = image + owner.cachedOriginalImageForFilter = image } .disposed(by: disposeBag) - output.currentEditingImage + output.uncroppedOriginalImage .drive(with: self) { owner, image in - owner.photoImageView.image = image + owner.cachedUncroppedOriginalImage = image } .disposed(by: disposeBag) - output.croppedImage + output.currentEditingImage .drive(with: self) { owner, image in - owner.croppedImage = image + owner.photoImageView.image = image } .disposed(by: disposeBag) @@ -540,9 +542,11 @@ final class MediaEditorViewController: UIViewController { filterCollectionView.rx.itemSelected .withUnretained(self) - .map { owner, indexPath -> (Int, UIImage?) in - let baseImage = owner.croppedImage ?? owner.originalImage - return (indexPath.item, baseImage) + .withLatestFrom(output.croppedImage) { ($0.0, $0.1, $1) } + .withLatestFrom(output.originalImage) { (owner: $0.0, indexPath: $0.1, croppedImage: $0.2, originalImage: $1) } + .map { data -> (Int, UIImage?) in + let baseImage = data.croppedImage ?? data.originalImage + return (data.indexPath.item, baseImage) } .bind(to: filterSelectedRelay) .disposed(by: disposeBag) @@ -701,7 +705,7 @@ extension MediaEditorViewController: UICollectionViewDataSource { let filterItem = MediaEditorFilterHandler.filters[indexPath.item] let filter = filterItem.filterName != nil ? CIFilter(name: filterItem.filterName!) : nil - cell.configure(with: filterItem.name, image: originalImage, filter: filter, context: filterHandler.context) + cell.configure(with: filterItem.name, image: cachedOriginalImageForFilter, filter: filter, context: filterHandler.context) return cell } return UICollectionViewCell() diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift index 1c6b45d..7ae04b5 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift @@ -12,7 +12,7 @@ import UIKit final class MediaEditorViewModel: BaseViewModelProtocol { private let disposeBag = DisposeBag() - private let originalImage: UIImage + private let imageStateHandler: MediaEditorImageStateHandler private let context = CIContext() private let currentPageRelay = BehaviorRelay(value: 1) private let hasNextRelay = BehaviorRelay(value: true) @@ -35,6 +35,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { let originalImage: Driver let currentEditingImage: Driver let croppedImage: Driver + let uncroppedOriginalImage: Driver let filteredImage: Driver let stickers: Driver<[KlipySticker]> let isLoadingMore: Driver @@ -45,13 +46,11 @@ final class MediaEditorViewModel: BaseViewModelProtocol { } init(originalImage: UIImage) { - self.originalImage = originalImage + self.imageStateHandler = MediaEditorImageStateHandler(originalImage: originalImage) } func transform(input: Input) -> Output { let stickersRelay = BehaviorRelay<[KlipySticker]>(value: []) - let currentEditingImageRelay = BehaviorRelay(value: originalImage) - let croppedImageRelay = BehaviorRelay(value: nil) let filteredImageRelay = BehaviorRelay(value: nil) let isLoadingMoreRelay = BehaviorRelay(value: false) @@ -186,8 +185,8 @@ final class MediaEditorViewModel: BaseViewModelProtocol { guard let filter = owner.createFilter(at: index) else { return nil } return owner.applyFilter(filter, to: baseImage) } - .bind { image in - currentEditingImageRelay.accept(image) + .bind(with: self) { owner, image in + owner.imageStateHandler.applyFilter(image) filteredImageRelay.accept(image) } .disposed(by: disposeBag) @@ -202,9 +201,8 @@ final class MediaEditorViewModel: BaseViewModelProtocol { ) return MediaEditorCropHandler.cropImage(image, to: scaledCropRect) } - .bind { croppedImage in - croppedImageRelay.accept(croppedImage) - currentEditingImageRelay.accept(croppedImage) + .bind(with: self) { owner, croppedImage in + owner.imageStateHandler.applyCrop(croppedImage) } .disposed(by: disposeBag) @@ -221,9 +219,10 @@ final class MediaEditorViewModel: BaseViewModelProtocol { .asDriver(onErrorJustReturn: ()) return Output( - originalImage: Driver.just(originalImage), - currentEditingImage: currentEditingImageRelay.asDriver(), - croppedImage: croppedImageRelay.asDriver(), + originalImage: imageStateHandler.originalImage, + currentEditingImage: imageStateHandler.currentEditingImage, + croppedImage: imageStateHandler.croppedImage, + uncroppedOriginalImage: imageStateHandler.uncroppedOriginalImage, filteredImage: filteredImageRelay.asDriver(), stickers: stickersRelay.asDriver(), isLoadingMore: isLoadingMoreRelay.asDriver(), From beb6675e63ed6d33bee93f66af2922039e651ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Mon, 20 Oct 2025 22:24:53 +0900 Subject: [PATCH 04/15] Add dependency injection and unit tests for CardInfo --- Sahara/Common/Manager/OCRManager.swift | 6 +- .../CardDetail/CardDetailViewController.swift | 2 +- .../CardInfo/CardInfoCoordinator.swift | 2 +- .../CardInfo/CardInfoViewController.swift | 9 +- .../Feature/CardInfo/CardInfoViewModel.swift | 16 +- .../MediaEditor/MediaEditorCoordinator.swift | 2 +- .../MediaEditorViewController.swift | 2 +- .../Gallery/GalleryViewController.swift | 4 +- .../Tab/Calendar/CalendarViewController.swift | 8 +- .../CardInfoCoordinatorProtocol.swift | 35 ++++ .../MediaEditorCoordinatorProtocol.swift | 22 +++ SaharaTests/CardInfoViewModelTests.swift | 167 ++++++++++++++++++ .../Mocks/MockCardInfoCoordinator.swift | 103 +++++++++++ .../Mocks/MockMediaEditorCoordinator.swift | 57 ++++++ SaharaTests/Mocks/MockOCRManager.swift | 32 ++++ SaharaTests/Mocks/MockRealmManager.swift | 126 +++++++++++++ 16 files changed, 573 insertions(+), 20 deletions(-) create mode 100644 Sahara/Protocol/CardInfoCoordinatorProtocol.swift create mode 100644 Sahara/Protocol/MediaEditorCoordinatorProtocol.swift create mode 100644 SaharaTests/CardInfoViewModelTests.swift create mode 100644 SaharaTests/Mocks/MockCardInfoCoordinator.swift create mode 100644 SaharaTests/Mocks/MockMediaEditorCoordinator.swift create mode 100644 SaharaTests/Mocks/MockOCRManager.swift create mode 100644 SaharaTests/Mocks/MockRealmManager.swift diff --git a/Sahara/Common/Manager/OCRManager.swift b/Sahara/Common/Manager/OCRManager.swift index b6cb87e..cb42e8c 100644 --- a/Sahara/Common/Manager/OCRManager.swift +++ b/Sahara/Common/Manager/OCRManager.swift @@ -9,7 +9,11 @@ import UIKit import Vision import RxSwift -final class OCRManager { +protocol OCRManagerProtocol { + func recognizeText(from image: UIImage) -> Observable +} + +final class OCRManager: OCRManagerProtocol { static let shared = OCRManager() private init() {} diff --git a/Sahara/Feature/CardDetail/CardDetailViewController.swift b/Sahara/Feature/CardDetail/CardDetailViewController.swift index 2dfa449..4329cbd 100644 --- a/Sahara/Feature/CardDetail/CardDetailViewController.swift +++ b/Sahara/Feature/CardDetail/CardDetailViewController.swift @@ -189,7 +189,7 @@ final class CardDetailViewController: UIViewController { let realm = try! Realm() guard let card = realm.object(ofType: Card.self, forPrimaryKey: viewModel.cardId) else { return } let editViewModel = CardInfoViewModel(cardToEdit: card.id, sourceType: sourceType) - let editVC = CardInfoViewController(viewModel: editViewModel) + let editVC = CardInfoViewController(viewModel: editViewModel, coordinator: CardInfoCoordinator(parentViewController: self)) editVC.modalPresentationStyle = .fullScreen present(editVC, animated: true) } diff --git a/Sahara/Feature/CardInfo/CardInfoCoordinator.swift b/Sahara/Feature/CardInfo/CardInfoCoordinator.swift index a4b900f..e426b9e 100644 --- a/Sahara/Feature/CardInfo/CardInfoCoordinator.swift +++ b/Sahara/Feature/CardInfo/CardInfoCoordinator.swift @@ -14,7 +14,7 @@ protocol CardInfoCoordinatorDelegate: AnyObject { func didCancel() } -final class CardInfoCoordinator: Coordinator { +final class CardInfoCoordinator: Coordinator, CardInfoCoordinatorProtocol { var navigationController: UINavigationController? weak var delegate: CardInfoCoordinatorDelegate? weak var parentViewController: UIViewController? diff --git a/Sahara/Feature/CardInfo/CardInfoViewController.swift b/Sahara/Feature/CardInfo/CardInfoViewController.swift index 3ce0b60..0fd8951 100644 --- a/Sahara/Feature/CardInfo/CardInfoViewController.swift +++ b/Sahara/Feature/CardInfo/CardInfoViewController.swift @@ -35,7 +35,7 @@ final class CardInfoViewController: UIViewController { return button }() - private let cancelButton: UIButton = { + let cancelButton: UIButton = { let button = UIButton() var config = UIButton.Configuration.filled() config.image = UIImage(named: "xmark") @@ -47,17 +47,16 @@ final class CardInfoViewController: UIViewController { }() let contentView = CardInfoView() - let coordinator: CardInfoCoordinator + let coordinator: CardInfoCoordinatorProtocol let viewModel: CardInfoViewModel let disposeBag = DisposeBag() let selectedDateRelay = BehaviorRelay(value: Date()) let deleteConfirmedRelay = PublishRelay() - init(viewModel: CardInfoViewModel) { + init(viewModel: CardInfoViewModel, coordinator: CardInfoCoordinatorProtocol) { self.viewModel = viewModel - self.coordinator = CardInfoCoordinator(parentViewController: UIViewController()) + self.coordinator = coordinator super.init(nibName: nil, bundle: nil) - self.coordinator.parentViewController = self } required init?(coder: NSCoder) { diff --git a/Sahara/Feature/CardInfo/CardInfoViewModel.swift b/Sahara/Feature/CardInfo/CardInfoViewModel.swift index 16b910c..4ea6cbf 100644 --- a/Sahara/Feature/CardInfo/CardInfoViewModel.swift +++ b/Sahara/Feature/CardInfo/CardInfoViewModel.swift @@ -22,6 +22,7 @@ enum EditSourceType { final class CardInfoViewModel: BaseViewModelProtocol { private let disposeBag = DisposeBag() private let realmManager: RealmManagerProtocol + private let ocrManager: OCRManagerProtocol private var editedImage: UIImage? private var cardToEditId: ObjectId? private var originalDate: Date? @@ -62,23 +63,26 @@ final class CardInfoViewModel: BaseViewModelProtocol { let shouldPopToListOnDelete: Driver } - init(editedImage: UIImage?, realmManager: RealmManagerProtocol = RealmManager.shared) { + init(editedImage: UIImage?, realmManager: RealmManagerProtocol = RealmManager.shared, ocrManager: OCRManagerProtocol = OCRManager.shared) { self.realmManager = realmManager + self.ocrManager = ocrManager self.editedImage = editedImage self.cardToEditId = nil self.sourceType = nil } - init(initialDate: Date, sourceType: EditSourceType, realmManager: RealmManagerProtocol = RealmManager.shared) { + init(initialDate: Date, sourceType: EditSourceType, realmManager: RealmManagerProtocol = RealmManager.shared, ocrManager: OCRManagerProtocol = OCRManager.shared) { self.realmManager = realmManager + self.ocrManager = ocrManager self.editedImage = nil self.cardToEditId = nil self.originalDate = initialDate self.sourceType = sourceType } - init(cardToEdit cardId: ObjectId, sourceType: EditSourceType, realmManager: RealmManagerProtocol = RealmManager.shared) { + init(cardToEdit cardId: ObjectId, sourceType: EditSourceType, realmManager: RealmManagerProtocol = RealmManager.shared, ocrManager: OCRManagerProtocol = OCRManager.shared) { self.realmManager = realmManager + self.ocrManager = ocrManager self.cardToEditId = cardId self.sourceType = sourceType @@ -259,7 +263,7 @@ final class CardInfoViewModel: BaseViewModelProtocol { let allCards = realmManager.fetch(Card.self) let hadLocationBefore = allCards.contains { $0.latitude != nil && $0.longitude != nil } - return OCRManager.shared.recognizeText(from: editedImage) + return ocrManager.recognizeText(from: editedImage) .flatMap { [weak self] ocrText -> Observable in guard let self = self else { return .empty() } @@ -329,7 +333,7 @@ final class CardInfoViewModel: BaseViewModelProtocol { editTypes.append("lock") } - let ocrObservable = imageChanged ? OCRManager.shared.recognizeText(from: editedImage) : Observable.just(oldOcrText) + let ocrObservable = imageChanged ? ocrManager.recognizeText(from: editedImage) : Observable.just(oldOcrText) return ocrObservable .flatMap { [weak self] ocrText -> Observable in @@ -398,7 +402,7 @@ final class CardInfoViewModel: BaseViewModelProtocol { editTypes.append("lock") } - let ocrObservable = imageChanged ? OCRManager.shared.recognizeText(from: editedImage) : Observable.just(oldOcrText) + let ocrObservable = imageChanged ? ocrManager.recognizeText(from: editedImage) : Observable.just(oldOcrText) return ocrObservable .flatMap { [weak self] ocrText -> Observable in diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorCoordinator.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorCoordinator.swift index f97e7ae..2ddd1fc 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorCoordinator.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorCoordinator.swift @@ -12,7 +12,7 @@ protocol MediaEditorCoordinatorDelegate: AnyObject { func didCancelEditing() } -final class MediaEditorCoordinator: Coordinator { +final class MediaEditorCoordinator: Coordinator, MediaEditorCoordinatorProtocol { var navigationController: UINavigationController? weak var delegate: MediaEditorCoordinatorDelegate? diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift index 6b7066f..7385a80 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift @@ -244,7 +244,7 @@ final class MediaEditorViewController: UIViewController { }() let viewModel: MediaEditorViewModel - weak var coordinator: MediaEditorCoordinator? + weak var coordinator: MediaEditorCoordinatorProtocol? private let disposeBag = DisposeBag() let currentMode = BehaviorRelay(value: nil) diff --git a/Sahara/Feature/Gallery/GalleryViewController.swift b/Sahara/Feature/Gallery/GalleryViewController.swift index db0d1f6..acf2f26 100644 --- a/Sahara/Feature/Gallery/GalleryViewController.swift +++ b/Sahara/Feature/Gallery/GalleryViewController.swift @@ -152,7 +152,9 @@ final class GalleryViewController: UIViewController { @objc private func addButtonTapped() { let viewModel = CardInfoViewModel(editedImage: nil) - let cardInfoVC = CardInfoViewController(viewModel: viewModel) + let coordinator = CardInfoCoordinator(parentViewController: self) + let cardInfoVC = CardInfoViewController(viewModel: viewModel, coordinator: coordinator) + coordinator.parentViewController = cardInfoVC let navController = UINavigationController(rootViewController: cardInfoVC) navController.modalPresentationStyle = .fullScreen present(navController, animated: true) diff --git a/Sahara/Feature/Gallery/Tab/Calendar/CalendarViewController.swift b/Sahara/Feature/Gallery/Tab/Calendar/CalendarViewController.swift index 96950a5..db2a30a 100644 --- a/Sahara/Feature/Gallery/Tab/Calendar/CalendarViewController.swift +++ b/Sahara/Feature/Gallery/Tab/Calendar/CalendarViewController.swift @@ -149,10 +149,12 @@ final class CalendarViewController: UIViewController { } } else { let viewModel = CardInfoViewModel(initialDate: date, sourceType: .dateView) - let cardInfoVC = CardInfoViewController(viewModel: viewModel) - let navController = UINavigationController(rootViewController: cardInfoVC) - navController.modalPresentationStyle = .fullScreen if let galleryVC = owner.parent as? GalleryViewController { + let coordinator = CardInfoCoordinator(parentViewController: galleryVC) + let cardInfoVC = CardInfoViewController(viewModel: viewModel, coordinator: coordinator) + coordinator.parentViewController = cardInfoVC + let navController = UINavigationController(rootViewController: cardInfoVC) + navController.modalPresentationStyle = .fullScreen galleryVC.present(navController, animated: true) } } diff --git a/Sahara/Protocol/CardInfoCoordinatorProtocol.swift b/Sahara/Protocol/CardInfoCoordinatorProtocol.swift new file mode 100644 index 0000000..2001c08 --- /dev/null +++ b/Sahara/Protocol/CardInfoCoordinatorProtocol.swift @@ -0,0 +1,35 @@ +// +// CardInfoCoordinatorProtocol.swift +// Sahara +// +// Created by 금가경 on 10/20/25. +// + +import CoreLocation +import RxSwift +import UIKit + +protocol CardInfoCoordinatorProtocol: AnyObject { + func presentMediaSelection( + selectedImageSubject: BehaviorSubject, + completion: @escaping (UIImage, CLLocation?, Date?) -> Void + ) + + func presentMediaEditor( + image: UIImage, + selectedImageSubject: BehaviorSubject, + onEditingComplete: @escaping (UIImage) -> Void + ) + + func presentDatePicker( + initialDate: Date, + onDateSelected: @escaping (Date) -> Void + ) + + func presentLocationSearch( + onLocationSelected: @escaping (CLLocationCoordinate2D, String) -> Void + ) + + func dismiss() + func popToList(isEditMode: Bool) +} diff --git a/Sahara/Protocol/MediaEditorCoordinatorProtocol.swift b/Sahara/Protocol/MediaEditorCoordinatorProtocol.swift new file mode 100644 index 0000000..7df4493 --- /dev/null +++ b/Sahara/Protocol/MediaEditorCoordinatorProtocol.swift @@ -0,0 +1,22 @@ +// +// MediaEditorCoordinatorProtocol.swift +// Sahara +// +// Created by 금가경 on 10/20/25. +// + +import UIKit + +protocol MediaEditorCoordinatorProtocol: AnyObject { + func presentStickerModal( + viewModel: MediaEditorViewModel, + onStickerSelected: @escaping (KlipySticker) -> Void + ) + + func presentPhotoSelection( + onPhotoSelected: @escaping (UIImage) -> Void + ) + + func finishEditing(with image: UIImage) + func cancelEditing() +} diff --git a/SaharaTests/CardInfoViewModelTests.swift b/SaharaTests/CardInfoViewModelTests.swift new file mode 100644 index 0000000..42995e6 --- /dev/null +++ b/SaharaTests/CardInfoViewModelTests.swift @@ -0,0 +1,167 @@ +// +// CardInfoViewModelTests.swift +// SaharaTests +// +// Created by 금가경 on 10/20/25. +// + +import CoreLocation +import RxSwift +import XCTest +@testable import Sahara + +final class CardInfoViewModelTests: XCTestCase { + var sut: CardInfoViewModel! + var mockRealmManager: MockRealmManager! + var mockOCRManager: MockOCRManager! + var disposeBag: DisposeBag! + + override func setUp() { + super.setUp() + disposeBag = DisposeBag() + mockRealmManager = MockRealmManager() + mockOCRManager = MockOCRManager() + } + + override func tearDown() { + sut = nil + mockRealmManager = nil + mockOCRManager = nil + disposeBag = nil + super.tearDown() + } + + private func createTestImage() -> UIImage { + let size = CGSize(width: 1, height: 1) + UIGraphicsBeginImageContext(size) + defer { UIGraphicsEndImageContext() } + + let context = UIGraphicsGetCurrentContext() + context?.setFillColor(UIColor.red.cgColor) + context?.fill(CGRect(origin: .zero, size: size)) + + return UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() + } + + func test_saveWithImage_shouldCallRealmManagerAdd() { + let testImage = createTestImage() + mockOCRManager.mockOCRText = "Test OCR text" + sut = CardInfoViewModel(editedImage: testImage, realmManager: mockRealmManager, ocrManager: mockOCRManager) + + let saveButtonTapped = PublishSubject() + + let input = CardInfoViewModel.Input( + selectedImage: .just(testImage), + date: .just(Date()), + memo: .just("Test memo"), + customFolder: .empty(), + location: .empty(), + isLocked: .just(false), + saveButtonTapped: saveButtonTapped.asObservable(), + cancelButtonTapped: .empty(), + deleteButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + + let expectation = XCTestExpectation(description: "Save completed") + + output.saved + .drive(onNext: { success in + if success { + expectation.fulfill() + } + }) + .disposed(by: disposeBag) + + saveButtonTapped.onNext(()) + + wait(for: [expectation], timeout: 2.0) + XCTAssertTrue(mockRealmManager.addCalled) + } + + func test_cancelButton_shouldEmitDismiss() { + sut = CardInfoViewModel(editedImage: nil, realmManager: mockRealmManager, ocrManager: mockOCRManager) + + let cancelButtonTapped = PublishSubject() + + let input = CardInfoViewModel.Input( + selectedImage: .empty(), + date: .empty(), + memo: .empty(), + customFolder: .empty(), + location: .empty(), + isLocked: .empty(), + saveButtonTapped: .empty(), + cancelButtonTapped: cancelButtonTapped.asObservable(), + deleteButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + + let expectation = XCTestExpectation(description: "Dismiss emitted") + + output.dismiss + .drive(onNext: { _ in + expectation.fulfill() + }) + .disposed(by: disposeBag) + + cancelButtonTapped.onNext(()) + + wait(for: [expectation], timeout: 1.0) + } + + func test_selectedImage_shouldUpdateHasImage() { + sut = CardInfoViewModel(editedImage: nil, realmManager: mockRealmManager, ocrManager: mockOCRManager) + + let selectedImage = BehaviorSubject(value: nil) + + let input = CardInfoViewModel.Input( + selectedImage: selectedImage.asObservable(), + date: .empty(), + memo: .empty(), + customFolder: .empty(), + location: .empty(), + isLocked: .empty(), + saveButtonTapped: .empty(), + cancelButtonTapped: .empty(), + deleteButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + + let expectation = XCTestExpectation(description: "Has image updated") + + output.hasImage + .skip(1) + .drive(onNext: { hasImage in + if hasImage { + expectation.fulfill() + } + }) + .disposed(by: disposeBag) + + selectedImage.onNext(createTestImage()) + + wait(for: [expectation], timeout: 1.0) + } + + func test_isEditMode_shouldBeFalseForNewCard() { + sut = CardInfoViewModel(editedImage: nil, realmManager: mockRealmManager, ocrManager: mockOCRManager) + + let output = sut.transform(input: CardInfoViewModel.Input( + selectedImage: .empty(), + date: .empty(), + memo: .empty(), + customFolder: .empty(), + location: .empty(), + isLocked: .empty(), + saveButtonTapped: .empty(), + cancelButtonTapped: .empty(), + deleteButtonTapped: .empty() + )) + + XCTAssertFalse(output.isEditMode) + } +} diff --git a/SaharaTests/Mocks/MockCardInfoCoordinator.swift b/SaharaTests/Mocks/MockCardInfoCoordinator.swift new file mode 100644 index 0000000..67bf3f8 --- /dev/null +++ b/SaharaTests/Mocks/MockCardInfoCoordinator.swift @@ -0,0 +1,103 @@ +// +// MockCardInfoCoordinator.swift +// SaharaTests +// +// Created by 금가경 on 10/20/25. +// + +import CoreLocation +import RxSwift +import UIKit +@testable import Sahara + +final class MockCardInfoCoordinator: CardInfoCoordinatorProtocol { + var presentMediaSelectionCalled = false + var presentMediaEditorCalled = false + var presentDatePickerCalled = false + var presentLocationSearchCalled = false + var dismissCalled = false + var popToListCalled = false + + var lastPresentedDate: Date? + var lastPresentedImage: UIImage? + var lastPopToListIsEditMode: Bool? + var lastMediaSelectionCompletion: ((UIImage, CLLocation?, Date?) -> Void)? + var lastMediaEditorCompletion: ((UIImage) -> Void)? + + var onPresentMediaSelection: (() -> Void)? + var onPresentMediaEditor: (() -> Void)? + var onPresentDatePicker: (() -> Void)? + var onPresentLocationSearch: (() -> Void)? + var onDismiss: (() -> Void)? + var onPopToList: (() -> Void)? + + func presentMediaSelection( + selectedImageSubject: BehaviorSubject, + completion: @escaping (UIImage, CLLocation?, Date?) -> Void + ) { + presentMediaSelectionCalled = true + lastMediaSelectionCompletion = completion + onPresentMediaSelection?() + } + + func presentMediaEditor( + image: UIImage, + selectedImageSubject: BehaviorSubject, + onEditingComplete: @escaping (UIImage) -> Void + ) { + presentMediaEditorCalled = true + lastPresentedImage = image + lastMediaEditorCompletion = onEditingComplete + onPresentMediaEditor?() + } + + func presentDatePicker( + initialDate: Date, + onDateSelected: @escaping (Date) -> Void + ) { + presentDatePickerCalled = true + lastPresentedDate = initialDate + onDateSelected(Date()) + onPresentDatePicker?() + } + + func presentLocationSearch( + onLocationSelected: @escaping (CLLocationCoordinate2D, String) -> Void + ) { + presentLocationSearchCalled = true + let coordinate = CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780) + onLocationSelected(coordinate, "서울") + onPresentLocationSearch?() + } + + func dismiss() { + dismissCalled = true + onDismiss?() + } + + func popToList(isEditMode: Bool) { + popToListCalled = true + lastPopToListIsEditMode = isEditMode + onPopToList?() + } + + func reset() { + presentMediaSelectionCalled = false + presentMediaEditorCalled = false + presentDatePickerCalled = false + presentLocationSearchCalled = false + dismissCalled = false + popToListCalled = false + lastPresentedDate = nil + lastPresentedImage = nil + lastPopToListIsEditMode = nil + lastMediaSelectionCompletion = nil + lastMediaEditorCompletion = nil + onPresentMediaSelection = nil + onPresentMediaEditor = nil + onPresentDatePicker = nil + onPresentLocationSearch = nil + onDismiss = nil + onPopToList = nil + } +} diff --git a/SaharaTests/Mocks/MockMediaEditorCoordinator.swift b/SaharaTests/Mocks/MockMediaEditorCoordinator.swift new file mode 100644 index 0000000..125b80e --- /dev/null +++ b/SaharaTests/Mocks/MockMediaEditorCoordinator.swift @@ -0,0 +1,57 @@ +// +// MockMediaEditorCoordinator.swift +// SaharaTests +// +// Created by 금가경 on 10/20/25. +// + +import UIKit +@testable import Sahara + +final class MockMediaEditorCoordinator: MediaEditorCoordinatorProtocol { + var presentStickerModalCalled = false + var presentPhotoSelectionCalled = false + var finishEditingCalled = false + var cancelEditingCalled = false + + var lastPresentedViewModel: MediaEditorViewModel? + var lastFinishedImage: UIImage? + var lastStickerSelectedCompletion: ((KlipySticker) -> Void)? + var lastPhotoSelectedCompletion: ((UIImage) -> Void)? + + func presentStickerModal( + viewModel: MediaEditorViewModel, + onStickerSelected: @escaping (KlipySticker) -> Void + ) { + presentStickerModalCalled = true + lastPresentedViewModel = viewModel + lastStickerSelectedCompletion = onStickerSelected + } + + func presentPhotoSelection( + onPhotoSelected: @escaping (UIImage) -> Void + ) { + presentPhotoSelectionCalled = true + lastPhotoSelectedCompletion = onPhotoSelected + } + + func finishEditing(with image: UIImage) { + finishEditingCalled = true + lastFinishedImage = image + } + + func cancelEditing() { + cancelEditingCalled = true + } + + func reset() { + presentStickerModalCalled = false + presentPhotoSelectionCalled = false + finishEditingCalled = false + cancelEditingCalled = false + lastPresentedViewModel = nil + lastFinishedImage = nil + lastStickerSelectedCompletion = nil + lastPhotoSelectedCompletion = nil + } +} diff --git a/SaharaTests/Mocks/MockOCRManager.swift b/SaharaTests/Mocks/MockOCRManager.swift new file mode 100644 index 0000000..d8f95f6 --- /dev/null +++ b/SaharaTests/Mocks/MockOCRManager.swift @@ -0,0 +1,32 @@ +// +// MockOCRManager.swift +// SaharaTests +// +// Created by 금가경 on 10/20/25. +// + +import RxSwift +import UIKit +@testable import Sahara + +final class MockOCRManager: OCRManagerProtocol { + var recognizeTextCalled = false + var mockOCRText: String? + var shouldFail = false + + func recognizeText(from image: UIImage) -> Observable { + recognizeTextCalled = true + + if shouldFail { + return Observable.error(NSError(domain: "MockOCRError", code: -1)) + } + + return Observable.just(mockOCRText) + } + + func reset() { + recognizeTextCalled = false + mockOCRText = nil + shouldFail = false + } +} diff --git a/SaharaTests/Mocks/MockRealmManager.swift b/SaharaTests/Mocks/MockRealmManager.swift new file mode 100644 index 0000000..170d1e8 --- /dev/null +++ b/SaharaTests/Mocks/MockRealmManager.swift @@ -0,0 +1,126 @@ +// +// MockRealmManager.swift +// SaharaTests +// +// Created by 금가경 on 10/20/25. +// + +import Foundation +import RealmSwift +import RxSwift +@testable import Sahara + +final class MockRealmManager: RealmManagerProtocol { + var addCalled = false + var fetchCalled = false + var deleteCalled = false + var updateCalled = false + var isEmptyCalled = false + + var mockCards: [Card] = [] + var mockIsEmpty = true + var shouldFailAdd = false + var shouldFailDelete = false + var shouldFailUpdate = false + + func add(_ object: T) -> Observable { + addCalled = true + + if shouldFailAdd { + return Observable.error(NSError(domain: "MockError", code: -1)) + } + + if let card = object as? Card { + mockCards.append(card) + mockIsEmpty = false + } + + return Observable.just(()) + } + + func fetch(_ type: T.Type, filter: String?, sortKey: String?, ascending: Bool) -> [T] { + fetchCalled = true + return mockCards as? [T] ?? [] + } + + func fetchObject(_ type: T.Type, forPrimaryKey key: Any) -> T? { + if type == Card.self, let objectId = key as? ObjectId { + return mockCards.first(where: { $0.id == objectId }) as? T + } + return nil + } + + func delete(_ object: T) -> Observable { + deleteCalled = true + + if shouldFailDelete { + return Observable.error(NSError(domain: "MockError", code: -1)) + } + + if let card = object as? Card { + mockCards.removeAll { $0.id == card.id } + mockIsEmpty = mockCards.isEmpty + } + + return Observable.just(()) + } + + func delete(_ type: T.Type, forPrimaryKey key: Any) -> Observable { + deleteCalled = true + + if shouldFailDelete { + return Observable.error(NSError(domain: "MockError", code: -1)) + } + + if type == Card.self, let objectId = key as? ObjectId { + mockCards.removeAll { $0.id == objectId } + mockIsEmpty = mockCards.isEmpty + } + + return Observable.just(()) + } + + func update(_ block: @escaping (Realm) -> Void) -> Observable { + updateCalled = true + + if shouldFailUpdate { + return Observable.error(NSError(domain: "MockError", code: -1)) + } + + return Observable.just(()) + } + + func isEmpty(_ type: T.Type) -> Bool { + isEmptyCalled = true + return mockIsEmpty + } + + func fetchCards(for period: DatePeriod) -> [Card] { + return mockCards + } + + func observeIsEmpty(_ type: T.Type) -> Observable { + return Observable.just(mockIsEmpty) + } + + func observeCards(for period: DatePeriod) -> Observable<[CardCalendarItemDTO]> { + return Observable.just([]) + } + + func observeAllCards() -> Observable<[Card]> { + return Observable.just(mockCards) + } + + func reset() { + addCalled = false + fetchCalled = false + deleteCalled = false + updateCalled = false + isEmptyCalled = false + mockCards = [] + mockIsEmpty = true + shouldFailAdd = false + shouldFailDelete = false + shouldFailUpdate = false + } +} From be6a965a81d6f5268c32a3803a695702fac799be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Tue, 21 Oct 2025 00:08:51 +0900 Subject: [PATCH 05/15] Configure Kingfisher image cache limits --- Sahara/AppDelegate.swift | 9 +++++++++ .../CardInfo/Component/MediaEditor/StickerCell.swift | 8 +++++++- .../MediaEditor/View/DraggableStickerView.swift | 8 +++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sahara/AppDelegate.swift b/Sahara/AppDelegate.swift index b0563ce..95449b1 100644 --- a/Sahara/AppDelegate.swift +++ b/Sahara/AppDelegate.swift @@ -9,6 +9,7 @@ import FirebaseCore import UIKit import FirebaseMessaging import RealmSwift +import Kingfisher @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -16,6 +17,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { FirebaseApp.configure() configureRealm() + configureKingfisher() UNUserNotificationCenter.current().delegate = self Messaging.messaging().delegate = self @@ -47,6 +49,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Realm.Configuration.defaultConfiguration = config } + private func configureKingfisher() { + let cache = ImageCache.default + cache.memoryStorage.config.totalCostLimit = 100 * 1024 * 1024 + cache.memoryStorage.config.countLimit = 100 + cache.diskStorage.config.sizeLimit = 500 * 1024 * 1024 + } + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/StickerCell.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/StickerCell.swift index 056db99..4edab6b 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/StickerCell.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/StickerCell.swift @@ -54,7 +54,13 @@ final class StickerCell: UICollectionViewCell, IsIdentifiable { } if let urlString = urlString, let url = URL(string: urlString) { - imageView.kf.setImage(with: url) + let options: KingfisherOptionsInfo = [ + .processor(DownsamplingImageProcessor(size: CGSize(width: 200, height: 200))), + .scaleFactor(UIScreen.main.scale), + .memoryCacheExpiration(.seconds(600)), + .diskCacheExpiration(.days(7)) + ] + imageView.kf.setImage(with: url, options: options) } } } diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/View/DraggableStickerView.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/View/DraggableStickerView.swift index 333554b..511e989 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/View/DraggableStickerView.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/View/DraggableStickerView.swift @@ -44,7 +44,13 @@ final class DraggableStickerView: BaseGestureView { } if let urlString = urlString, let url = URL(string: urlString) { - imageView.kf.setImage(with: url) + let options: KingfisherOptionsInfo = [ + .processor(DownsamplingImageProcessor(size: bounds.size)), + .scaleFactor(UIScreen.main.scale), + .memoryCacheExpiration(.seconds(600)), + .diskCacheExpiration(.days(7)) + ] + imageView.kf.setImage(with: url, options: options) } } } \ No newline at end of file From fe74e1608ef2f2fe0301f98de249f1eb1c0bc8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Tue, 21 Oct 2025 00:34:21 +0900 Subject: [PATCH 06/15] Refactor NetworkManager and Add MediaEditorViewModel tests --- .../MediaEditor/MediaEditorViewModel.swift | 14 +- Sahara/Network/NetworkManager.swift | 2 +- Sahara/Protocol/NetworkManagerProtocol.swift | 13 + SaharaTests/MediaEditorViewModelTests.swift | 255 ++++++++++++++++++ SaharaTests/Mocks/MockNetworkManager.swift | 33 +++ 5 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 Sahara/Protocol/NetworkManagerProtocol.swift create mode 100644 SaharaTests/MediaEditorViewModelTests.swift create mode 100644 SaharaTests/Mocks/MockNetworkManager.swift diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift index 7ae04b5..0dd1823 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift @@ -13,6 +13,7 @@ import UIKit final class MediaEditorViewModel: BaseViewModelProtocol { private let disposeBag = DisposeBag() private let imageStateHandler: MediaEditorImageStateHandler + private let networkManager: NetworkManagerProtocol private let context = CIContext() private let currentPageRelay = BehaviorRelay(value: 1) private let hasNextRelay = BehaviorRelay(value: true) @@ -45,8 +46,9 @@ final class MediaEditorViewModel: BaseViewModelProtocol { let dismiss: Driver } - init(originalImage: UIImage) { + init(originalImage: UIImage, networkManager: NetworkManagerProtocol = NetworkManager.shared) { self.imageStateHandler = MediaEditorImageStateHandler(originalImage: originalImage) + self.networkManager = networkManager } func transform(input: Input) -> Output { @@ -70,7 +72,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { owner.hasNextRelay.accept(true) }) .flatMapLatest { owner, _ in - NetworkManager.shared.callRequest( + owner.networkManager.callRequest( api: .trendingStickers( page: 1, perPage: 20, @@ -94,7 +96,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { .withUnretained(self) .flatMapLatest { owner, query -> Observable in if query.isEmpty { - return NetworkManager.shared.callRequest( + return owner.networkManager.callRequest( api: .trendingStickers( page: 1, perPage: 20, @@ -104,7 +106,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { type: StickerResponse.self ) } else { - return NetworkManager.shared.callRequest( + return owner.networkManager.callRequest( api: .searchStickers( query: query, page: 1, @@ -136,7 +138,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { let page = owner.currentPageRelay.value if query.isEmpty { - return NetworkManager.shared.callRequest( + return owner.networkManager.callRequest( api: .trendingStickers( page: page, perPage: 20, @@ -146,7 +148,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { type: StickerResponse.self ) } else { - return NetworkManager.shared.callRequest( + return owner.networkManager.callRequest( api: .searchStickers( query: query, page: page, diff --git a/Sahara/Network/NetworkManager.swift b/Sahara/Network/NetworkManager.swift index f39128f..f207f67 100644 --- a/Sahara/Network/NetworkManager.swift +++ b/Sahara/Network/NetworkManager.swift @@ -9,7 +9,7 @@ import Alamofire import Foundation import RxSwift -final class NetworkManager { +final class NetworkManager: NetworkManagerProtocol { static let shared = NetworkManager() private init() {} diff --git a/Sahara/Protocol/NetworkManagerProtocol.swift b/Sahara/Protocol/NetworkManagerProtocol.swift new file mode 100644 index 0000000..2c96d50 --- /dev/null +++ b/Sahara/Protocol/NetworkManagerProtocol.swift @@ -0,0 +1,13 @@ +// +// NetworkManagerProtocol.swift +// Sahara +// +// Created by 금가경 on 10/21/25. +// + +import Foundation +import RxSwift + +protocol NetworkManagerProtocol { + func callRequest(api: APIRouter, type: T.Type) -> Observable +} diff --git a/SaharaTests/MediaEditorViewModelTests.swift b/SaharaTests/MediaEditorViewModelTests.swift new file mode 100644 index 0000000..3c56da3 --- /dev/null +++ b/SaharaTests/MediaEditorViewModelTests.swift @@ -0,0 +1,255 @@ +// +// MediaEditorViewModelTests.swift +// SaharaTests +// +// Created by 금가경 on 10/21/25. +// + +import XCTest +import RxSwift +import RxCocoa +@testable import Sahara + +final class MediaEditorViewModelTests: XCTestCase { + private var sut: MediaEditorViewModel! + private var mockNetworkManager: MockNetworkManager! + private var disposeBag: DisposeBag! + private var testImage: UIImage! + + override func setUp() { + super.setUp() + mockNetworkManager = MockNetworkManager() + testImage = UIImage(systemName: "photo")! + sut = MediaEditorViewModel(originalImage: testImage, networkManager: mockNetworkManager) + disposeBag = DisposeBag() + } + + override func tearDown() { + sut = nil + mockNetworkManager = nil + disposeBag = nil + testImage = nil + super.tearDown() + } + + func test_viewWillAppear_shouldLoadTrendingStickers() { + let mockStickers = createMockStickers(count: 3) + let mockResponse = StickerResponse( + result: true, + data: StickerData( + data: mockStickers, + currentPage: 1, + perPage: 20, + hasNext: true + ) + ) + mockNetworkManager.mockResponse = mockResponse + + let viewWillAppear = PublishSubject() + let input = MediaEditorViewModel.Input( + viewWillAppear: viewWillAppear.asObservable(), + searchQuery: .empty(), + loadMoreTrigger: .empty(), + stickerSelected: .empty(), + filterSelected: .empty(), + cropApplied: .empty(), + drawingChanged: .empty(), + photoSelected: .empty(), + doneButtonTapped: .empty(), + cancelButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + let expectation = XCTestExpectation(description: "Load trending stickers") + + output.stickers + .skip(1) + .drive(onNext: { stickers in + XCTAssertEqual(stickers.count, 3) + XCTAssertEqual(self.mockNetworkManager.callCount, 1) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + viewWillAppear.onNext(()) + + wait(for: [expectation], timeout: 2.0) + } + + func test_searchQuery_shouldCallSearchAPI() { + let mockStickers = createMockStickers(count: 2) + let mockResponse = StickerResponse( + result: true, + data: StickerData( + data: mockStickers, + currentPage: 1, + perPage: 20, + hasNext: false + ) + ) + mockNetworkManager.mockResponse = mockResponse + + let searchQuery = PublishSubject() + let input = MediaEditorViewModel.Input( + viewWillAppear: .empty(), + searchQuery: searchQuery.asObservable(), + loadMoreTrigger: .empty(), + stickerSelected: .empty(), + filterSelected: .empty(), + cropApplied: .empty(), + drawingChanged: .empty(), + photoSelected: .empty(), + doneButtonTapped: .empty(), + cancelButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + let expectation = XCTestExpectation(description: "Search stickers") + + output.stickers + .skip(1) + .drive(onNext: { stickers in + XCTAssertEqual(stickers.count, 2) + XCTAssertEqual(self.mockNetworkManager.callCount, 1) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + searchQuery.onNext("") + searchQuery.onNext("cat") + + wait(for: [expectation], timeout: 2.0) + } + + func test_emptySearchQuery_shouldLoadTrendingStickers() { + let mockStickers = createMockStickers(count: 5) + let mockResponse = StickerResponse( + result: true, + data: StickerData( + data: mockStickers, + currentPage: 1, + perPage: 20, + hasNext: true + ) + ) + mockNetworkManager.mockResponse = mockResponse + + let searchQuery = PublishSubject() + let input = MediaEditorViewModel.Input( + viewWillAppear: .empty(), + searchQuery: searchQuery.asObservable(), + loadMoreTrigger: .empty(), + stickerSelected: .empty(), + filterSelected: .empty(), + cropApplied: .empty(), + drawingChanged: .empty(), + photoSelected: .empty(), + doneButtonTapped: .empty(), + cancelButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + let expectation = XCTestExpectation(description: "Load trending on empty query") + + output.stickers + .skip(1) + .drive(onNext: { stickers in + XCTAssertEqual(stickers.count, 5) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + searchQuery.onNext("cat") + searchQuery.onNext("") + + wait(for: [expectation], timeout: 2.0) + } + + func test_loadMore_shouldAppendStickers() { + let firstPageStickers = createMockStickers(count: 3) + let secondPageStickers = createMockStickers(count: 2, startId: 4) + + let firstResponse = StickerResponse( + result: true, + data: StickerData( + data: firstPageStickers, + currentPage: 1, + perPage: 20, + hasNext: true + ) + ) + + let secondResponse = StickerResponse( + result: true, + data: StickerData( + data: secondPageStickers, + currentPage: 2, + perPage: 20, + hasNext: false + ) + ) + + let viewWillAppear = PublishSubject() + let loadMore = PublishSubject() + let input = MediaEditorViewModel.Input( + viewWillAppear: viewWillAppear.asObservable(), + searchQuery: .empty(), + loadMoreTrigger: loadMore.asObservable(), + stickerSelected: .empty(), + filterSelected: .empty(), + cropApplied: .empty(), + drawingChanged: .empty(), + photoSelected: .empty(), + doneButtonTapped: .empty(), + cancelButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + var resultCount = 0 + let expectation = XCTestExpectation(description: "Load more stickers") + + output.stickers + .skip(1) + .drive(onNext: { stickers in + resultCount += 1 + if resultCount == 1 { + XCTAssertEqual(stickers.count, 3) + self.mockNetworkManager.mockResponse = secondResponse + loadMore.onNext(()) + } else if resultCount == 2 { + XCTAssertEqual(stickers.count, 5) + XCTAssertEqual(self.mockNetworkManager.callCount, 2) + expectation.fulfill() + } + }) + .disposed(by: disposeBag) + + mockNetworkManager.mockResponse = firstResponse + viewWillAppear.onNext(()) + + wait(for: [expectation], timeout: 3.0) + } + + private func createMockStickers(count: Int, startId: Int = 1) -> [KlipySticker] { + return (startId..(api: APIRouter, type: T.Type) -> Observable { + callCount += 1 + lastAPIRouter = api + + if shouldReturnError { + return Observable.error(errorToReturn) + } + + guard let response = mockResponse as? T else { + return Observable.error(NetworkError.decodingError) + } + + return Observable.just(response) + } +} From 142f494befe65457c8278ca25e0157f311ef56d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Tue, 21 Oct 2025 00:55:55 +0900 Subject: [PATCH 07/15] Add Network Test --- SaharaTests/APIRouterTests.swift | 153 ++++++++++++++++++++++++++ SaharaTests/NetworkManagerTests.swift | 55 +++++++++ 2 files changed, 208 insertions(+) create mode 100644 SaharaTests/APIRouterTests.swift create mode 100644 SaharaTests/NetworkManagerTests.swift diff --git a/SaharaTests/APIRouterTests.swift b/SaharaTests/APIRouterTests.swift new file mode 100644 index 0000000..bdc54fc --- /dev/null +++ b/SaharaTests/APIRouterTests.swift @@ -0,0 +1,153 @@ +// +// APIRouterTests.swift +// SaharaTests +// +// Created by 금가경 on 10/21/25. +// + +import XCTest +@testable import Sahara + +final class APIRouterTests: XCTestCase { + + func test_trendingStickers_shouldGenerateCorrectURL() { + let router = APIRouter.trendingStickers( + page: 1, + perPage: 20, + customerId: "test-customer", + locale: "en" + ) + + let url = router.endPoint + + XCTAssertNotNil(url) + XCTAssertTrue(url?.absoluteString.contains("stickers/trending") ?? false) + XCTAssertTrue(url?.absoluteString.contains("page=1") ?? false) + XCTAssertTrue(url?.absoluteString.contains("per_page=20") ?? false) + XCTAssertTrue(url?.absoluteString.contains("customer_id=test-customer") ?? false) + XCTAssertTrue(url?.absoluteString.contains("locale=en") ?? false) + } + + func test_searchStickers_shouldGenerateCorrectURL() { + let router = APIRouter.searchStickers( + query: "cat", + page: 2, + perPage: 10, + customerId: "test-customer", + locale: "ko" + ) + + let url = router.endPoint + + XCTAssertNotNil(url) + XCTAssertTrue(url?.absoluteString.contains("stickers/search") ?? false) + XCTAssertTrue(url?.absoluteString.contains("q=cat") ?? false) + XCTAssertTrue(url?.absoluteString.contains("page=2") ?? false) + XCTAssertTrue(url?.absoluteString.contains("per_page=10") ?? false) + XCTAssertTrue(url?.absoluteString.contains("customer_id=test-customer") ?? false) + XCTAssertTrue(url?.absoluteString.contains("locale=ko") ?? false) + } + + func test_trendingStickers_shouldHaveGetMethod() { + let router = APIRouter.trendingStickers( + page: 1, + perPage: 20, + customerId: "test", + locale: "en" + ) + + XCTAssertEqual(router.method.rawValue, "GET") + } + + func test_searchStickers_shouldHaveGetMethod() { + let router = APIRouter.searchStickers( + query: "test", + page: 1, + perPage: 20, + customerId: "test", + locale: "en" + ) + + XCTAssertEqual(router.method.rawValue, "GET") + } + + func test_trendingStickers_shouldHaveCorrectPath() { + let router = APIRouter.trendingStickers( + page: 1, + perPage: 20, + customerId: "test", + locale: "en" + ) + + XCTAssertTrue(router.path.contains("stickers/trending")) + XCTAssertTrue(router.path.contains(router.appKey)) + } + + func test_searchStickers_shouldHaveCorrectPath() { + let router = APIRouter.searchStickers( + query: "test", + page: 1, + perPage: 20, + customerId: "test", + locale: "en" + ) + + XCTAssertTrue(router.path.contains("stickers/search")) + XCTAssertTrue(router.path.contains(router.appKey)) + } + + func test_searchStickers_withSpecialCharacters_shouldEncodeQuery() { + let router = APIRouter.searchStickers( + query: "hello world", + page: 1, + perPage: 20, + customerId: "test", + locale: "en" + ) + + let url = router.endPoint + + XCTAssertNotNil(url) + XCTAssertTrue(url?.absoluteString.contains("hello%20world") ?? false) + } + + func test_trendingStickers_shouldHaveAllParameters() { + let router = APIRouter.trendingStickers( + page: 5, + perPage: 50, + customerId: "custom-123", + locale: "ja" + ) + + guard let parameters = router.parameters else { + XCTFail("Parameters should not be nil") + return + } + + XCTAssertEqual(parameters["page"], "5") + XCTAssertEqual(parameters["per_page"], "50") + XCTAssertEqual(parameters["customer_id"], "custom-123") + XCTAssertEqual(parameters["locale"], "ja") + } + + func test_searchStickers_shouldHaveAllParameters() { + let router = APIRouter.searchStickers( + query: "dog", + page: 3, + perPage: 15, + customerId: "custom-456", + locale: "fr" + ) + + guard let parameters = router.parameters else { + XCTFail("Parameters should not be nil") + return + } + + XCTAssertEqual(parameters["q"], "dog") + XCTAssertEqual(parameters["page"], "3") + XCTAssertEqual(parameters["per_page"], "15") + XCTAssertEqual(parameters["customer_id"], "custom-456") + XCTAssertEqual(parameters["locale"], "fr") + } +} diff --git a/SaharaTests/NetworkManagerTests.swift b/SaharaTests/NetworkManagerTests.swift new file mode 100644 index 0000000..dcd4142 --- /dev/null +++ b/SaharaTests/NetworkManagerTests.swift @@ -0,0 +1,55 @@ +// +// NetworkManagerTests.swift +// SaharaTests +// +// Created by 금가경 on 10/21/25. +// + +import XCTest +import RxSwift +@testable import Sahara + +final class NetworkManagerTests: XCTestCase { + private var sut: NetworkManager! + private var disposeBag: DisposeBag! + + override func setUp() { + super.setUp() + sut = NetworkManager.shared + disposeBag = DisposeBag() + } + + override func tearDown() { + sut = nil + disposeBag = nil + super.tearDown() + } + + func test_networkManager_shouldBeSharedInstance() { + let instance1 = NetworkManager.shared + let instance2 = NetworkManager.shared + + XCTAssertTrue(instance1 === instance2) + } + + func test_networkError_invalidURL_shouldHaveCorrectDescription() { + let error = NetworkError.invalidURL + + XCTAssertEqual(error.errorDescription, "잘못된 URL입니다.") + } + + func test_networkError_notConnectedToInternet_shouldHaveCorrectDescription() { + let error = NetworkError.notConnectedToInternet + + XCTAssertEqual( + error.errorDescription, + "네트워크 연결이 일시적으로 원활하지 않습니다. 데이터 또는 Wi-fi 연결 상태를 확인해 주세요." + ) + } + + func test_networkError_decodingError_shouldHaveCorrectDescription() { + let error = NetworkError.decodingError + + XCTAssertEqual(error.errorDescription, "데이터 처리 중 오류가 발생했습니다.") + } +} From c75194931a5726a62ab5559c98075b5a8c25f37e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Tue, 21 Oct 2025 01:14:40 +0900 Subject: [PATCH 08/15] Add error handling when network is not stable --- .../MediaEditorViewController.swift | 28 +++++- .../MediaEditor/MediaEditorViewModel.swift | 60 +++++++++++-- .../StickerModalViewController.swift | 1 + Sahara/Resources/en.lproj/Localizable.strings | 1 + Sahara/Resources/ja.lproj/Localizable.strings | 1 + Sahara/Resources/ko.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + SaharaTests/MediaEditorViewModelTests.swift | 87 +++++++++++++++++++ 8 files changed, 173 insertions(+), 7 deletions(-) diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift index 7385a80..2f4b0ee 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewController.swift @@ -268,6 +268,7 @@ final class MediaEditorViewController: UIViewController { private let viewWillAppearRelay = PublishRelay() private let filterSelectedRelay = PublishRelay<(Int, UIImage?)>() let photoSelectedRelay = PublishRelay() + private let stickerButtonTappedRelay = PublishRelay() private var usedTools: Set = [] @@ -381,8 +382,7 @@ final class MediaEditorViewController: UIViewController { if owner.currentMode.value == .sticker { owner.currentMode.accept(nil) } else { - owner.currentMode.accept(.sticker) - owner.presentStickerModal() + owner.stickerButtonTappedRelay.accept(()) } } .disposed(by: disposeBag) @@ -483,6 +483,7 @@ final class MediaEditorViewController: UIViewController { let input = MediaEditorViewModel.Input( viewWillAppear: viewWillAppearRelay.asObservable(), + stickerButtonTapped: stickerButtonTappedRelay.asObservable(), searchQuery: .empty(), loadMoreTrigger: .empty(), stickerSelected: .empty(), @@ -540,6 +541,29 @@ final class MediaEditorViewController: UIViewController { } .disposed(by: disposeBag) + output.errorMessage + .drive(with: self) { owner, message in + if !message.isEmpty { + owner.showToast(message: message) + } + } + .disposed(by: disposeBag) + + output.networkErrorMessage + .drive(with: self) { owner, message in + if !message.isEmpty { + owner.showToast(message: message) + } + } + .disposed(by: disposeBag) + + output.shouldShowStickerModal + .drive(with: self) { owner, _ in + owner.currentMode.accept(.sticker) + owner.presentStickerModal() + } + .disposed(by: disposeBag) + filterCollectionView.rx.itemSelected .withUnretained(self) .withLatestFrom(output.croppedImage) { ($0.0, $0.1, $1) } diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift index 0dd1823..c699bb5 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/MediaEditorViewModel.swift @@ -5,6 +5,7 @@ // Created by 금가경 on 9/26/25. // +import Alamofire import Foundation import RxCocoa import RxSwift @@ -21,6 +22,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { struct Input { let viewWillAppear: Observable + let stickerButtonTapped: Observable let searchQuery: Observable let loadMoreTrigger: Observable let stickerSelected: Observable @@ -44,6 +46,9 @@ final class MediaEditorViewModel: BaseViewModelProtocol { let selectedPhoto: Driver let navigateToMetadata: Driver let dismiss: Driver + let errorMessage: Driver + let networkErrorMessage: Driver + let shouldShowStickerModal: Driver } init(originalImage: UIImage, networkManager: NetworkManagerProtocol = NetworkManager.shared) { @@ -55,6 +60,21 @@ final class MediaEditorViewModel: BaseViewModelProtocol { let stickersRelay = BehaviorRelay<[KlipySticker]>(value: []) let filteredImageRelay = BehaviorRelay(value: nil) let isLoadingMoreRelay = BehaviorRelay(value: false) + let errorRelay = PublishRelay() + let networkErrorRelay = PublishRelay() + let shouldShowStickerModalRelay = PublishRelay() + + input.stickerButtonTapped + .withUnretained(self) + .bind { owner, _ in + let isConnected = NetworkReachabilityManager()?.isReachable ?? false + if isConnected { + shouldShowStickerModalRelay.accept(()) + } else { + networkErrorRelay.accept(NSLocalizedString("media_editor.network_error", comment: "")) + } + } + .disposed(by: disposeBag) input.searchQuery .withUnretained(self) @@ -81,6 +101,14 @@ final class MediaEditorViewModel: BaseViewModelProtocol { ), type: StickerResponse.self ) + .catch { error in + if let networkError = error as? NetworkError { + errorRelay.accept(networkError.errorDescription) + } else { + errorRelay.accept(error.localizedDescription) + } + return Observable.empty() + } } .withUnretained(self) .do(onNext: { owner, response in @@ -95,8 +123,9 @@ final class MediaEditorViewModel: BaseViewModelProtocol { .distinctUntilChanged() .withUnretained(self) .flatMapLatest { owner, query -> Observable in + let request: Observable if query.isEmpty { - return owner.networkManager.callRequest( + request = owner.networkManager.callRequest( api: .trendingStickers( page: 1, perPage: 20, @@ -106,7 +135,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { type: StickerResponse.self ) } else { - return owner.networkManager.callRequest( + request = owner.networkManager.callRequest( api: .searchStickers( query: query, page: 1, @@ -117,6 +146,14 @@ final class MediaEditorViewModel: BaseViewModelProtocol { type: StickerResponse.self ) } + return request.catch { error in + if let networkError = error as? NetworkError { + errorRelay.accept(networkError.errorDescription) + } else { + errorRelay.accept(error.localizedDescription) + } + return Observable.empty() + } } .withUnretained(self) .do(onNext: { owner, response in @@ -137,8 +174,9 @@ final class MediaEditorViewModel: BaseViewModelProtocol { let query = owner.currentQueryRelay.value let page = owner.currentPageRelay.value + let request: Observable if query.isEmpty { - return owner.networkManager.callRequest( + request = owner.networkManager.callRequest( api: .trendingStickers( page: page, perPage: 20, @@ -148,7 +186,7 @@ final class MediaEditorViewModel: BaseViewModelProtocol { type: StickerResponse.self ) } else { - return owner.networkManager.callRequest( + request = owner.networkManager.callRequest( api: .searchStickers( query: query, page: page, @@ -159,6 +197,15 @@ final class MediaEditorViewModel: BaseViewModelProtocol { type: StickerResponse.self ) } + return request.catch { error in + isLoadingMoreRelay.accept(false) + if let networkError = error as? NetworkError { + errorRelay.accept(networkError.errorDescription) + } else { + errorRelay.accept(error.localizedDescription) + } + return Observable.empty() + } } .withUnretained(self) .do(onNext: { owner, response in @@ -231,7 +278,10 @@ final class MediaEditorViewModel: BaseViewModelProtocol { selectedSticker: selectedSticker, selectedPhoto: selectedPhoto, navigateToMetadata: navigateToMetadata, - dismiss: dismiss + dismiss: dismiss, + errorMessage: errorRelay.asDriver(onErrorJustReturn: ""), + networkErrorMessage: networkErrorRelay.asDriver(onErrorJustReturn: ""), + shouldShowStickerModal: shouldShowStickerModalRelay.asDriver(onErrorDriveWith: .empty()) ) } diff --git a/Sahara/Feature/CardInfo/Component/MediaEditor/StickerModalViewController.swift b/Sahara/Feature/CardInfo/Component/MediaEditor/StickerModalViewController.swift index 6a1d07b..1ef0172 100644 --- a/Sahara/Feature/CardInfo/Component/MediaEditor/StickerModalViewController.swift +++ b/Sahara/Feature/CardInfo/Component/MediaEditor/StickerModalViewController.swift @@ -89,6 +89,7 @@ final class StickerModalViewController: UIViewController { let input = MediaEditorViewModel.Input( viewWillAppear: viewWillAppearRelay.asObservable(), + stickerButtonTapped: .empty(), searchQuery: searchQuery, loadMoreTrigger: loadMoreRelay.asObservable(), stickerSelected: stickerCollectionView.rx.modelSelected(KlipySticker.self).asObservable(), diff --git a/Sahara/Resources/en.lproj/Localizable.strings b/Sahara/Resources/en.lproj/Localizable.strings index baba091..e03835d 100644 --- a/Sahara/Resources/en.lproj/Localizable.strings +++ b/Sahara/Resources/en.lproj/Localizable.strings @@ -105,6 +105,7 @@ "media_editor.sticker_modal_title" = "Stickers"; "media_editor.undo" = "Undo"; "media_editor.redo" = "Redo"; +"media_editor.network_error" = "Unable to connect to network"; /* Filters */ "filter.original" = "Original"; diff --git a/Sahara/Resources/ja.lproj/Localizable.strings b/Sahara/Resources/ja.lproj/Localizable.strings index e1f92df..226994a 100644 --- a/Sahara/Resources/ja.lproj/Localizable.strings +++ b/Sahara/Resources/ja.lproj/Localizable.strings @@ -105,6 +105,7 @@ "media_editor.sticker_modal_title" = "ステッカー"; "media_editor.undo" = "元に戻す"; "media_editor.redo" = "やり直し"; +"media_editor.network_error" = "ネットワークに接続できません"; /* Filters */ "filter.original" = "オリジナル"; diff --git a/Sahara/Resources/ko.lproj/Localizable.strings b/Sahara/Resources/ko.lproj/Localizable.strings index b9e1052..0dccf11 100644 --- a/Sahara/Resources/ko.lproj/Localizable.strings +++ b/Sahara/Resources/ko.lproj/Localizable.strings @@ -105,6 +105,7 @@ "media_editor.sticker_modal_title" = "스티커"; "media_editor.undo" = "실행 취소"; "media_editor.redo" = "다시 실행"; +"media_editor.network_error" = "네트워크에 연결할 수 없어요"; /* Filters */ "filter.original" = "원본"; diff --git a/Sahara/Resources/zh-Hans.lproj/Localizable.strings b/Sahara/Resources/zh-Hans.lproj/Localizable.strings index 638aec8..330fdc0 100644 --- a/Sahara/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sahara/Resources/zh-Hans.lproj/Localizable.strings @@ -105,6 +105,7 @@ "media_editor.sticker_modal_title" = "贴纸"; "media_editor.undo" = "撤销"; "media_editor.redo" = "重做"; +"media_editor.network_error" = "无法连接到网络"; /* Filters */ "filter.original" = "原图"; diff --git a/SaharaTests/MediaEditorViewModelTests.swift b/SaharaTests/MediaEditorViewModelTests.swift index 3c56da3..69e3823 100644 --- a/SaharaTests/MediaEditorViewModelTests.swift +++ b/SaharaTests/MediaEditorViewModelTests.swift @@ -48,6 +48,7 @@ final class MediaEditorViewModelTests: XCTestCase { let viewWillAppear = PublishSubject() let input = MediaEditorViewModel.Input( viewWillAppear: viewWillAppear.asObservable(), + stickerButtonTapped: .empty(), searchQuery: .empty(), loadMoreTrigger: .empty(), stickerSelected: .empty(), @@ -92,6 +93,7 @@ final class MediaEditorViewModelTests: XCTestCase { let searchQuery = PublishSubject() let input = MediaEditorViewModel.Input( viewWillAppear: .empty(), + stickerButtonTapped: .empty(), searchQuery: searchQuery.asObservable(), loadMoreTrigger: .empty(), stickerSelected: .empty(), @@ -137,6 +139,7 @@ final class MediaEditorViewModelTests: XCTestCase { let searchQuery = PublishSubject() let input = MediaEditorViewModel.Input( viewWillAppear: .empty(), + stickerButtonTapped: .empty(), searchQuery: searchQuery.asObservable(), loadMoreTrigger: .empty(), stickerSelected: .empty(), @@ -193,6 +196,7 @@ final class MediaEditorViewModelTests: XCTestCase { let loadMore = PublishSubject() let input = MediaEditorViewModel.Input( viewWillAppear: viewWillAppear.asObservable(), + stickerButtonTapped: .empty(), searchQuery: .empty(), loadMoreTrigger: loadMore.asObservable(), stickerSelected: .empty(), @@ -230,6 +234,89 @@ final class MediaEditorViewModelTests: XCTestCase { wait(for: [expectation], timeout: 3.0) } + func test_networkError_shouldEmitErrorMessage() { + mockNetworkManager.shouldReturnError = true + mockNetworkManager.errorToReturn = NetworkError.notConnectedToInternet + + let viewWillAppear = PublishSubject() + let input = MediaEditorViewModel.Input( + viewWillAppear: viewWillAppear.asObservable(), + stickerButtonTapped: .empty(), + searchQuery: .empty(), + loadMoreTrigger: .empty(), + stickerSelected: .empty(), + filterSelected: .empty(), + cropApplied: .empty(), + drawingChanged: .empty(), + photoSelected: .empty(), + doneButtonTapped: .empty(), + cancelButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + let expectation = XCTestExpectation(description: "Error message emitted") + + output.errorMessage + .drive(onNext: { errorMessage in + XCTAssertFalse(errorMessage.isEmpty) + XCTAssertEqual(errorMessage, NetworkError.notConnectedToInternet.errorDescription) + expectation.fulfill() + }) + .disposed(by: disposeBag) + + viewWillAppear.onNext(()) + + wait(for: [expectation], timeout: 2.0) + } + + func test_searchError_shouldEmitErrorMessage() { + let mockStickers = createMockStickers(count: 1) + let mockResponse = StickerResponse( + result: true, + data: StickerData( + data: mockStickers, + currentPage: 1, + perPage: 20, + hasNext: false + ) + ) + mockNetworkManager.mockResponse = mockResponse + + let searchQuery = PublishSubject() + let input = MediaEditorViewModel.Input( + viewWillAppear: .empty(), + stickerButtonTapped: .empty(), + searchQuery: searchQuery.asObservable(), + loadMoreTrigger: .empty(), + stickerSelected: .empty(), + filterSelected: .empty(), + cropApplied: .empty(), + drawingChanged: .empty(), + photoSelected: .empty(), + doneButtonTapped: .empty(), + cancelButtonTapped: .empty() + ) + + let output = sut.transform(input: input) + let expectation = XCTestExpectation(description: "Search error") + + output.errorMessage + .drive(onNext: { errorMessage in + if !errorMessage.isEmpty { + XCTAssertEqual(errorMessage, NetworkError.invalidURL.errorDescription) + expectation.fulfill() + } + }) + .disposed(by: disposeBag) + + searchQuery.onNext("") + mockNetworkManager.shouldReturnError = true + mockNetworkManager.errorToReturn = NetworkError.invalidURL + searchQuery.onNext("test") + + wait(for: [expectation], timeout: 2.0) + } + private func createMockStickers(count: Int, startId: Int = 1) -> [KlipySticker] { return (startId.. Date: Tue, 21 Oct 2025 01:17:08 +0900 Subject: [PATCH 09/15] Remove useless code --- .../Mocks/MockCardInfoCoordinator.swift | 103 ------------------ .../Mocks/MockMediaEditorCoordinator.swift | 57 ---------- 2 files changed, 160 deletions(-) delete mode 100644 SaharaTests/Mocks/MockCardInfoCoordinator.swift delete mode 100644 SaharaTests/Mocks/MockMediaEditorCoordinator.swift diff --git a/SaharaTests/Mocks/MockCardInfoCoordinator.swift b/SaharaTests/Mocks/MockCardInfoCoordinator.swift deleted file mode 100644 index 67bf3f8..0000000 --- a/SaharaTests/Mocks/MockCardInfoCoordinator.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// MockCardInfoCoordinator.swift -// SaharaTests -// -// Created by 금가경 on 10/20/25. -// - -import CoreLocation -import RxSwift -import UIKit -@testable import Sahara - -final class MockCardInfoCoordinator: CardInfoCoordinatorProtocol { - var presentMediaSelectionCalled = false - var presentMediaEditorCalled = false - var presentDatePickerCalled = false - var presentLocationSearchCalled = false - var dismissCalled = false - var popToListCalled = false - - var lastPresentedDate: Date? - var lastPresentedImage: UIImage? - var lastPopToListIsEditMode: Bool? - var lastMediaSelectionCompletion: ((UIImage, CLLocation?, Date?) -> Void)? - var lastMediaEditorCompletion: ((UIImage) -> Void)? - - var onPresentMediaSelection: (() -> Void)? - var onPresentMediaEditor: (() -> Void)? - var onPresentDatePicker: (() -> Void)? - var onPresentLocationSearch: (() -> Void)? - var onDismiss: (() -> Void)? - var onPopToList: (() -> Void)? - - func presentMediaSelection( - selectedImageSubject: BehaviorSubject, - completion: @escaping (UIImage, CLLocation?, Date?) -> Void - ) { - presentMediaSelectionCalled = true - lastMediaSelectionCompletion = completion - onPresentMediaSelection?() - } - - func presentMediaEditor( - image: UIImage, - selectedImageSubject: BehaviorSubject, - onEditingComplete: @escaping (UIImage) -> Void - ) { - presentMediaEditorCalled = true - lastPresentedImage = image - lastMediaEditorCompletion = onEditingComplete - onPresentMediaEditor?() - } - - func presentDatePicker( - initialDate: Date, - onDateSelected: @escaping (Date) -> Void - ) { - presentDatePickerCalled = true - lastPresentedDate = initialDate - onDateSelected(Date()) - onPresentDatePicker?() - } - - func presentLocationSearch( - onLocationSelected: @escaping (CLLocationCoordinate2D, String) -> Void - ) { - presentLocationSearchCalled = true - let coordinate = CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780) - onLocationSelected(coordinate, "서울") - onPresentLocationSearch?() - } - - func dismiss() { - dismissCalled = true - onDismiss?() - } - - func popToList(isEditMode: Bool) { - popToListCalled = true - lastPopToListIsEditMode = isEditMode - onPopToList?() - } - - func reset() { - presentMediaSelectionCalled = false - presentMediaEditorCalled = false - presentDatePickerCalled = false - presentLocationSearchCalled = false - dismissCalled = false - popToListCalled = false - lastPresentedDate = nil - lastPresentedImage = nil - lastPopToListIsEditMode = nil - lastMediaSelectionCompletion = nil - lastMediaEditorCompletion = nil - onPresentMediaSelection = nil - onPresentMediaEditor = nil - onPresentDatePicker = nil - onPresentLocationSearch = nil - onDismiss = nil - onPopToList = nil - } -} diff --git a/SaharaTests/Mocks/MockMediaEditorCoordinator.swift b/SaharaTests/Mocks/MockMediaEditorCoordinator.swift deleted file mode 100644 index 125b80e..0000000 --- a/SaharaTests/Mocks/MockMediaEditorCoordinator.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// MockMediaEditorCoordinator.swift -// SaharaTests -// -// Created by 금가경 on 10/20/25. -// - -import UIKit -@testable import Sahara - -final class MockMediaEditorCoordinator: MediaEditorCoordinatorProtocol { - var presentStickerModalCalled = false - var presentPhotoSelectionCalled = false - var finishEditingCalled = false - var cancelEditingCalled = false - - var lastPresentedViewModel: MediaEditorViewModel? - var lastFinishedImage: UIImage? - var lastStickerSelectedCompletion: ((KlipySticker) -> Void)? - var lastPhotoSelectedCompletion: ((UIImage) -> Void)? - - func presentStickerModal( - viewModel: MediaEditorViewModel, - onStickerSelected: @escaping (KlipySticker) -> Void - ) { - presentStickerModalCalled = true - lastPresentedViewModel = viewModel - lastStickerSelectedCompletion = onStickerSelected - } - - func presentPhotoSelection( - onPhotoSelected: @escaping (UIImage) -> Void - ) { - presentPhotoSelectionCalled = true - lastPhotoSelectedCompletion = onPhotoSelected - } - - func finishEditing(with image: UIImage) { - finishEditingCalled = true - lastFinishedImage = image - } - - func cancelEditing() { - cancelEditingCalled = true - } - - func reset() { - presentStickerModalCalled = false - presentPhotoSelectionCalled = false - finishEditingCalled = false - cancelEditingCalled = false - lastPresentedViewModel = nil - lastFinishedImage = nil - lastStickerSelectedCompletion = nil - lastPhotoSelectedCompletion = nil - } -} From b9bd7a34ab9d0875b4f9fb0cf4bd2a3589e4ca8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Tue, 21 Oct 2025 16:39:09 +0900 Subject: [PATCH 10/15] Add index --- Sahara/Common/Model/Card.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sahara/Common/Model/Card.swift b/Sahara/Common/Model/Card.swift index ce2c318..c5bae30 100644 --- a/Sahara/Common/Model/Card.swift +++ b/Sahara/Common/Model/Card.swift @@ -94,7 +94,7 @@ enum WeatherCondition: String, PersistableEnum { final class Card: Object { @Persisted(primaryKey: true) var id: ObjectId - @Persisted var date: Date + @Persisted(indexed: true) var date: Date @Persisted var createdDate: Date @Persisted var modifiedDate: Date? From 7814ba6f283f191e7e7f26a861ffda0b63749cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Tue, 28 Oct 2025 11:58:00 +0900 Subject: [PATCH 11/15] Collect device information in inquiry email --- .../Settings/SettingsViewController.swift | 58 ++++++++++++++++++- Sahara/Resources/en.lproj/Localizable.strings | 6 ++ Sahara/Resources/ja.lproj/Localizable.strings | 6 ++ Sahara/Resources/ko.lproj/Localizable.strings | 6 ++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/Sahara/Feature/Settings/SettingsViewController.swift b/Sahara/Feature/Settings/SettingsViewController.swift index 6a9115a..f9d4ff2 100644 --- a/Sahara/Feature/Settings/SettingsViewController.swift +++ b/Sahara/Feature/Settings/SettingsViewController.swift @@ -162,12 +162,66 @@ final class SettingsViewController: UIViewController { let mailComposer = MFMailComposeViewController() mailComposer.mailComposeDelegate = self mailComposer.setToRecipients([email]) - mailComposer.setSubject("") - mailComposer.setMessageBody("", isHTML: false) + mailComposer.setSubject(NSLocalizedString("settings.inquiry_subject", comment: "")) + mailComposer.setMessageBody(generateEmailBody(), isHTML: false) present(mailComposer, animated: true) } + private func generateEmailBody() -> String { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + let iosVersion = UIDevice.current.systemVersion + let deviceModel = getDeviceModel() + + return """ + \(NSLocalizedString("settings.inquiry_message_placeholder", comment: "")) + + --- + \(NSLocalizedString("settings.device_info_title", comment: "")) + - \(NSLocalizedString("settings.app_version", comment: "")): \(appVersion) + - \(NSLocalizedString("settings.ios_version", comment: "")): \(iosVersion) + - \(NSLocalizedString("settings.device_model", comment: "")): \(deviceModel) + """ + } + + private func getDeviceModel() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let modelCode = withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(validatingUTF8: $0) + } + } + + guard let code = modelCode else { + return UIDevice.current.model + } + + let deviceModelMap: [String: String] = [ + "iPhone14,2": "iPhone 13 Pro", + "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", + "iPhone14,6": "iPhone SE (3rd generation)", + "iPhone14,7": "iPhone 14", + "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", + "iPhone15,3": "iPhone 14 Pro Max", + "iPhone15,4": "iPhone 15", + "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", + "iPhone16,2": "iPhone 15 Pro Max", + "iPhone17,1": "iPhone 16 Pro", + "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", + "iPhone17,4": "iPhone 16 Plus", + "arm64": "Simulator", + "x86_64": "Simulator" + ] + + return deviceModelMap[code] ?? code + } + private func showMailError() { let alert = UIAlertController( title: NSLocalizedString("settings.mail_error_title", comment: ""), diff --git a/Sahara/Resources/en.lproj/Localizable.strings b/Sahara/Resources/en.lproj/Localizable.strings index e03835d..a3e5451 100644 --- a/Sahara/Resources/en.lproj/Localizable.strings +++ b/Sahara/Resources/en.lproj/Localizable.strings @@ -192,6 +192,12 @@ "settings.mail_error_message" = "Mail app is not available"; "settings.notification_denied_title" = "Notification Settings Required"; "settings.notification_denied_message" = "Please enable notifications in Settings"; +"settings.inquiry_subject" = "[Sahara] Inquiry"; +"settings.inquiry_message_placeholder" = "Please enter your inquiry here."; +"settings.device_info_title" = "Device Information"; +"settings.app_version" = "App Version"; +"settings.ios_version" = "iOS Version"; +"settings.device_model" = "Device Model"; /* Release Notes */ "release_notes.title" = "Release Notes"; diff --git a/Sahara/Resources/ja.lproj/Localizable.strings b/Sahara/Resources/ja.lproj/Localizable.strings index 226994a..cb8202f 100644 --- a/Sahara/Resources/ja.lproj/Localizable.strings +++ b/Sahara/Resources/ja.lproj/Localizable.strings @@ -192,6 +192,12 @@ "settings.mail_error_message" = "メールアプリを使用できません"; "settings.notification_denied_title" = "通知設定が必要です"; "settings.notification_denied_message" = "設定で通知を許可してください"; +"settings.inquiry_subject" = "[サハラ] お問い合わせ"; +"settings.inquiry_message_placeholder" = "お問い合わせ内容を入力してください。"; +"settings.device_info_title" = "デバイス情報"; +"settings.app_version" = "アプリバージョン"; +"settings.ios_version" = "iOSバージョン"; +"settings.device_model" = "デバイスモデル"; /* Release Notes */ "release_notes.title" = "バージョン履歴"; diff --git a/Sahara/Resources/ko.lproj/Localizable.strings b/Sahara/Resources/ko.lproj/Localizable.strings index 0dccf11..9e064bc 100644 --- a/Sahara/Resources/ko.lproj/Localizable.strings +++ b/Sahara/Resources/ko.lproj/Localizable.strings @@ -192,6 +192,12 @@ "settings.mail_error_message" = "메일 앱을 사용할 수 없어요"; "settings.notification_denied_title" = "알림 설정이 필요해요"; "settings.notification_denied_message" = "설정에서 알림을 허용해주세요"; +"settings.inquiry_subject" = "[사하라] 문의"; +"settings.inquiry_message_placeholder" = "문의 내용을 입력해주세요."; +"settings.device_info_title" = "기기 정보"; +"settings.app_version" = "앱 버전"; +"settings.ios_version" = "iOS 버전"; +"settings.device_model" = "기기 모델"; /* Release Notes */ "release_notes.title" = "버전 기록"; From 88c809abaf322d9e7e751a7458f120e32dc2b210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Fri, 31 Oct 2025 15:26:08 +0900 Subject: [PATCH 12/15] Fix current location bug --- .../Component/LocationSearch/LocationSearchViewModel.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sahara/Feature/CardInfo/Component/LocationSearch/LocationSearchViewModel.swift b/Sahara/Feature/CardInfo/Component/LocationSearch/LocationSearchViewModel.swift index b968716..40d5457 100644 --- a/Sahara/Feature/CardInfo/Component/LocationSearch/LocationSearchViewModel.swift +++ b/Sahara/Feature/CardInfo/Component/LocationSearch/LocationSearchViewModel.swift @@ -20,6 +20,7 @@ final class LocationSearchViewModel: NSObject, BaseViewModelProtocol, MKLocalSea private let locationUpdateSubject = PublishSubject() private var isLoadingRelay: BehaviorRelay? private var selectedLocationRelay: PublishRelay<(CLLocationCoordinate2D, String)>? + private var isWaitingForLocationPermission = false struct Input { let viewDidLoad: Observable @@ -80,6 +81,7 @@ final class LocationSearchViewModel: NSObject, BaseViewModelProtocol, MKLocalSea switch authStatus { case .notDetermined: + owner.isWaitingForLocationPermission = true owner.locationManager.requestWhenInUseAuthorization() case .authorizedWhenInUse, .authorizedAlways: isLoadingRelay.accept(true) @@ -166,7 +168,10 @@ final class LocationSearchViewModel: NSObject, BaseViewModelProtocol, MKLocalSea } func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + guard isWaitingForLocationPermission else { return } + if status == .authorizedWhenInUse || status == .authorizedAlways { + isWaitingForLocationPermission = false isLoadingRelay?.accept(true) if let cachedLocation = manager.location { handleLocation(cachedLocation, selectedRelay: selectedLocationRelay!, loadingRelay: isLoadingRelay!) From 1c574e37cdc15057ce49e1ac5377d024aaceab8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Fri, 31 Oct 2025 16:04:07 +0900 Subject: [PATCH 13/15] Bump version to 1.4.1 --- Sahara.xcodeproj/project.pbxproj | 8 ++++---- Sahara/Feature/Settings/Model/ReleaseNote.swift | 8 ++++++++ Sahara/Resources/en.lproj/Localizable.strings | 2 ++ Sahara/Resources/ja.lproj/Localizable.strings | 2 ++ Sahara/Resources/ko.lproj/Localizable.strings | 2 ++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Sahara.xcodeproj/project.pbxproj b/Sahara.xcodeproj/project.pbxproj index aacfff8..bb780c1 100644 --- a/Sahara.xcodeproj/project.pbxproj +++ b/Sahara.xcodeproj/project.pbxproj @@ -382,7 +382,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Sahara/Sahara.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = RFZY2ACQ74; GENERATE_INFOPLIST_FILE = YES; @@ -405,7 +405,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.miya.Sahara.dev; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -425,7 +425,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Sahara/Sahara.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = RFZY2ACQ74; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Sahara/Info.plist; @@ -447,7 +447,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.miya.Sahara; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/Sahara/Feature/Settings/Model/ReleaseNote.swift b/Sahara/Feature/Settings/Model/ReleaseNote.swift index 82fcf17..bb8a1e5 100644 --- a/Sahara/Feature/Settings/Model/ReleaseNote.swift +++ b/Sahara/Feature/Settings/Model/ReleaseNote.swift @@ -17,6 +17,14 @@ struct ReleaseNote { } static let allVersions: [ReleaseNote] = [ + ReleaseNote( + version: "1.4.1", + date: DateComponents(calendar: .current, year: 2025, month: 10, day: 31).date!, + changes: [ + NSLocalizedString("release_note.1.4.1.1", comment: ""), + NSLocalizedString("release_note.1.4.1.2", comment: "") + ] + ), ReleaseNote( version: "1.4.0", date: DateComponents(calendar: .current, year: 2025, month: 10, day: 17).date!, diff --git a/Sahara/Resources/en.lproj/Localizable.strings b/Sahara/Resources/en.lproj/Localizable.strings index a3e5451..35fd74b 100644 --- a/Sahara/Resources/en.lproj/Localizable.strings +++ b/Sahara/Resources/en.lproj/Localizable.strings @@ -217,6 +217,8 @@ "language.chinese" = "简体中文"; /* Release Note Details */ +"release_note.1.4.1.1" = "Device information is now automatically included when contacting support for more accurate assistance"; +"release_note.1.4.1.2" = "The app now works more stably"; "release_note.1.4.0.1" = "Undo/Redo feature available in drawing mode"; "release_note.1.4.0.2" = "Organize cards by folders"; "release_note.1.4.0.3" = "Receive service news notifications"; diff --git a/Sahara/Resources/ja.lproj/Localizable.strings b/Sahara/Resources/ja.lproj/Localizable.strings index cb8202f..833b2d7 100644 --- a/Sahara/Resources/ja.lproj/Localizable.strings +++ b/Sahara/Resources/ja.lproj/Localizable.strings @@ -217,6 +217,8 @@ "language.chinese" = "简体中文"; /* Release Note Details */ +"release_note.1.4.1.1" = "お問い合わせ時にデバイス情報が自動的に含まれ、より正確なサポートを受けられます"; +"release_note.1.4.1.2" = "アプリがより安定して動作します"; "release_note.1.4.0.1" = "描画モードで取り消し/やり直し機能を使用できます"; "release_note.1.4.0.2" = "フォルダ別にカードを整理できます"; "release_note.1.4.0.3" = "サービスニュースの通知を受け取れます"; diff --git a/Sahara/Resources/ko.lproj/Localizable.strings b/Sahara/Resources/ko.lproj/Localizable.strings index 9e064bc..ec06b10 100644 --- a/Sahara/Resources/ko.lproj/Localizable.strings +++ b/Sahara/Resources/ko.lproj/Localizable.strings @@ -217,6 +217,8 @@ "language.chinese" = "简体中文"; /* Release Notes */ +"release_note.1.4.1.1" = "문의하기 시 기기 정보가 자동으로 포함되어 더 정확한 지원을 받을 수 있어요"; +"release_note.1.4.1.2" = "앱이 더욱 안정적으로 작동해요"; "release_note.1.4.0.1" = "그리기 모드에서 실행 취소/다시 실행 기능을 사용할 수 있어요"; "release_note.1.4.0.2" = "폴더별로 카드를 정리할 수 있어요"; "release_note.1.4.0.3" = "업데이트 소식 알림을 받을 수 있어요"; From 61974a0211192d9d97ee5ada32a195c6bcf1a7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD?= Date: Fri, 31 Oct 2025 20:31:27 +0900 Subject: [PATCH 14/15] Add Chinese localization for 1.4.1 release notes --- Sahara/Resources/zh-Hans.lproj/Localizable.strings | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sahara/Resources/zh-Hans.lproj/Localizable.strings b/Sahara/Resources/zh-Hans.lproj/Localizable.strings index 330fdc0..19afbab 100644 --- a/Sahara/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sahara/Resources/zh-Hans.lproj/Localizable.strings @@ -211,6 +211,8 @@ "language.chinese" = "简体中文"; /* Release Note Details */ +"release_note.1.4.1.1" = "联系我们时会自动包含设备信息,以获得更准确的支持"; +"release_note.1.4.1.2" = "应用更加稳定"; "release_note.1.4.0.1" = "绘画模式中可以使用撤销/重做功能"; "release_note.1.4.0.2" = "可以按文件夹整理卡片"; "release_note.1.4.0.3" = "可以接收服务动态通知"; From 0a3131f27a01831a3097147171e66e127f3db3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B8=88=EA=B0=80=EA=B2=BD=20Miya?= Date: Mon, 1 Dec 2025 12:08:25 +0900 Subject: [PATCH 15/15] Update README.md --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 63a1266..b460fd6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [AppStore Link](https://apps.apple.com) -image 6 | image 7 | IMG_2300 | IMG_2492 | +image 6 | image 7 | IMG_2300 | IMG_2492 | |:--:|:--:|:--:|:--:| |구분|내용| @@ -70,7 +70,7 @@ ### 갤러리 -1| 2 | 3 | 4 +1| 2 | 3 | 4 |:--:|:--:|:--:|:--:| #### 날짜별 보기 @@ -101,9 +101,9 @@ ### 편집 -| 5 | 6 | 7 | +| 5 | 6 | 7 | |:--:|:--:|:--:| -| 8 | 9 | 10 | +| 8 | 9 | 10 | #### 스티커 @@ -134,9 +134,9 @@ ### 카드작성 -| 11 | 12 | 13 | +| 11 | 12 | 13 | |:--:|:--:|:--:| -| 14 | 15 | +| 14 | 15 | #### 미디어 선택 @@ -158,7 +158,7 @@ ### 검색 / 통계 / 설정 -| 16 | 17 | 18 | 19 | +| 16 | 17 | 18 | 19 | |:--:|:--:|:--:|:--:| ### 실시간 검색