diff --git a/iOS/Features/Home/Sources/Home/Presentation/HomeViewController.swift b/iOS/Features/Home/Sources/Home/Presentation/HomeViewController.swift index f3af31f..ac0183a 100644 --- a/iOS/Features/Home/Sources/Home/Presentation/HomeViewController.swift +++ b/iOS/Features/Home/Sources/Home/Presentation/HomeViewController.swift @@ -147,8 +147,23 @@ public final class HomeViewController: HomeBottomSheetViewController { self.viewModel.state.isStartButtonLoading .receive(on: DispatchQueue.main) - .sink { isStartButtonLoading in - self.startButton.configuration?.showsActivityIndicator = isStartButtonLoading + .sink { [weak self] isStartButtonLoading in + self?.startButton.configuration?.showsActivityIndicator = isStartButtonLoading + } + .store(in: &self.cancellables) + + self.viewModel.state.isRefreshButtonHidden + .removeDuplicates(by: { $0 == $1 }) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + self?.refreshButton.isHidden = isHidden + } + .store(in: &self.cancellables) + + self.viewModel.state.overlaysShouldBeCleared + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.contentViewController.clearOverlays() } .store(in: &self.cancellables) } @@ -156,7 +171,6 @@ public final class HomeViewController: HomeBottomSheetViewController { // MARK: - Functions private func updateButtonMode(isRecording: Bool) { - self.refreshButton.isHidden = isRecording UIView.transition(with: startButton, duration: 0.5, options: .transitionCrossDissolve, animations: { @@ -197,7 +211,6 @@ extension HomeViewController: RecordJourneyButtonViewDelegate { guard let coordinates = self?.contentViewController.visibleCoordinates else { return } self?.viewModel.trigger(.refreshButtonDidTap(visibleCoordinates: coordinates)) - self?.refreshButton.isHidden = true } self.refreshButton.addAction(refreshButtonAction, for: .touchUpInside) } @@ -205,10 +218,8 @@ extension HomeViewController: RecordJourneyButtonViewDelegate { public func backButtonDidTap(_ button: MSRectButton) { guard self.viewModel.state.isRecording.value == true else { return } - self.contentViewController.clearOverlays() self.viewModel.trigger(.backButtonDidTap) self.contentViewController.journeyDidCancelled() - // TODO: 여정 취소 } public func spotButtonDidTap(_ button: MSRectButton) { @@ -233,6 +244,16 @@ extension HomeViewController: RecordJourneyButtonViewDelegate { } +// MARK: - MapViewController + +extension HomeViewController: MapViewControllerDelegate { + + public func mapViewControllerDidChangeVisibleRegion(_ mapViewController: MapViewController) { + self.viewModel.trigger(.mapViewDidChange) + } + +} + // MARK: - UI Configuration private extension HomeViewController { diff --git a/iOS/Features/Home/Sources/Home/Presentation/HomeViewModel.swift b/iOS/Features/Home/Sources/Home/Presentation/HomeViewModel.swift index 3fa70b7..12d5d6a 100644 --- a/iOS/Features/Home/Sources/Home/Presentation/HomeViewModel.swift +++ b/iOS/Features/Home/Sources/Home/Presentation/HomeViewModel.swift @@ -25,15 +25,18 @@ public final class HomeViewModel { case startButtonDidTap(Coordinate) case refreshButtonDidTap(visibleCoordinates: (minCoordinate: Coordinate, maxCoordinate: Coordinate)) case backButtonDidTap + case mapViewDidChange } public struct State { // Passthrough public var startedJourney = PassthroughSubject() public var visibleJourneys = PassthroughSubject<[Journey], Never>() + public var overlaysShouldBeCleared = PassthroughSubject() // CurrentValue public var isRecording = CurrentValueSubject(false) + public var isRefreshButtonHidden = CurrentValueSubject(false) public var isStartButtonLoading = CurrentValueSubject(false) } @@ -80,9 +83,13 @@ public final class HomeViewModel { #endif self.startJourney(at: coordinate) case .refreshButtonDidTap(visibleCoordinates: (let minCoordinate, let maxCoordinate)): + self.state.isRefreshButtonHidden.send(true) self.fetchJourneys(minCoordinate: minCoordinate, maxCoordinate: maxCoordinate) case .backButtonDidTap: self.state.isRecording.send(false) + self.state.overlaysShouldBeCleared.send(true) + case .mapViewDidChange: + self.state.isRefreshButtonHidden.send(false) } } @@ -122,6 +129,7 @@ private extension HomeViewModel { switch result { case .success(let recordingJourney): self.state.startedJourney.send(recordingJourney) + self.journeyRepository self.state.isRecording.send(true) case .failure(let error): MSLogger.make(category: .home).error("\(error)") diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController+EventListener.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController+EventListener.swift index ecea656..ff6ad14 100644 --- a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController+EventListener.swift +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController+EventListener.swift @@ -27,13 +27,21 @@ extension MapViewController { extension MapViewController { public func journeyDidStarted(_ startedJourney: RecordingJourney) { + let userRepository = UserRepositoryImplementation() let journeyRepository = JourneyRepositoryImplementation() - let viewModel = RecordJourneyViewModel(startedJourney: startedJourney, journeyRepository: journeyRepository) + let viewModel = RecordJourneyViewModel(startedJourney: startedJourney, + userRepository: userRepository, + journeyRepository: journeyRepository) self.swapViewModel(to: viewModel) } public func journeyDidCancelled() { + guard let viewModel = self.viewModel as? RecordJourneyViewModel else { return } + viewModel.trigger(.recordingDidCancelled) + let journeyRepository = JourneyRepositoryImplementation() + let navigateMapViewModel = NavigateMapViewModel(repository: journeyRepository) + self.swapViewModel(to: navigateMapViewModel) } } diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController.swift index 40db265..22409d5 100644 --- a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController.swift +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewController.swift @@ -62,6 +62,7 @@ public final class MapViewController: UIViewController { // MARK: - Properties + public weak var delegate: MapViewControllerDelegate? var viewModel: (any MapViewModel)? private let locationManager = CLLocationManager() @@ -115,7 +116,6 @@ public final class MapViewController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - guard let viewModel = self.viewModel else { return } self.configureLayout() self.configureCoreLocation() self.bind(viewModel) @@ -130,7 +130,7 @@ public final class MapViewController: UIViewController { self.bind(viewModel) } - private func bind(_ viewModel: any MapViewModel) { + private func bind(_ viewModel: (any MapViewModel)?) { self.viewModel = viewModel if let navigateMapViewModel = viewModel as? NavigateMapViewModel { @@ -171,10 +171,9 @@ public final class MapViewController: UIViewController { // MARK: - Functions: Annotation - // 식별자를 갖고 Annotation view 생성 + /// 식별자를 갖고 Annotation view 생성 func addAnnotationView(using annotation: CustomAnnotation, on mapView: MKMapView) -> MKAnnotationView { - // dequeueReusableAnnotationView: 식별자를 확인하여 사용가능한 뷰가 있으면 해당 뷰를 반환 return mapView.dequeueReusableAnnotationView(withIdentifier: CustomAnnotationView.identifier, for: annotation) } @@ -294,7 +293,6 @@ extension MapViewController: CLLocationManagerDelegate { recordJourneyViewModel.trigger(.locationDidUpdated(coordinate2D)) } - /// iOS 버젼에 따른 분기 처리 후 'iOS 위치 서비스 사용 중 여부' 확인 private func handleAuthorizationChange(_ manager: CLLocationManager) { switch manager.authorizationStatus { case .notDetermined: @@ -373,26 +371,27 @@ extension MapViewController: MKMapViewDelegate { return annotationView } + public func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) { + self.delegate?.mapViewControllerDidChangeVisibleRegion(self) + } + } // MARK: - ButtonView extension MapViewController: ButtonStackViewDelegate { - /// 현재 지도에서 보이는 범위 내의 모든 Spot들을 보여줌. public func mapButtonDidTap() { MSLogger.make(category: .navigateMap).debug("현재 지도에서 보이는 범위 내의 모든 Spot들을 보여줍니다.") } - /// 현재 내 위치를 중앙에 위치. public func userLocationButtonDidTap() { switch self.mapView.userTrackingMode { case .none, .followWithHeading: self.mapView.setUserTrackingMode(.follow, animated: true) case .follow: self.mapView.setUserTrackingMode(.followWithHeading, animated: true) - @unknown default: - break + @unknown default: break } } diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewControllerDelegate.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewControllerDelegate.swift new file mode 100644 index 0000000..54df534 --- /dev/null +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/Common/MapViewControllerDelegate.swift @@ -0,0 +1,14 @@ +// +// MapViewControllerDelegate.swift +// NavigateMap +// +// Created by 이창준 on 2023.12.10. +// + +import Foundation + +public protocol MapViewControllerDelegate: AnyObject { + + func mapViewControllerDidChangeVisibleRegion(_ mapViewController: MapViewController) + +} diff --git a/iOS/Features/Home/Sources/NavigateMap/Presentation/RecordJourney/RecordJourneyViewModel.swift b/iOS/Features/Home/Sources/NavigateMap/Presentation/RecordJourney/RecordJourneyViewModel.swift index 5ba9c2f..54d5ebc 100644 --- a/iOS/Features/Home/Sources/NavigateMap/Presentation/RecordJourney/RecordJourneyViewModel.swift +++ b/iOS/Features/Home/Sources/NavigateMap/Presentation/RecordJourney/RecordJourneyViewModel.swift @@ -19,6 +19,7 @@ public final class RecordJourneyViewModel: MapViewModel { case viewNeedsLoaded case locationDidUpdated(CLLocationCoordinate2D) case locationsShouldRecorded([CLLocationCoordinate2D]) + case recordingDidCancelled } public struct State { @@ -30,14 +31,17 @@ public final class RecordJourneyViewModel: MapViewModel { // MARK: - Properties - private let journeyRepository: JourneyRepository + private let userRepository: UserRepository + private var journeyRepository: JourneyRepository public var state: State // MARK: - Initializer public init(startedJourney: RecordingJourney, + userRepository: UserRepository, journeyRepository: JourneyRepository) { + self.userRepository = userRepository self.journeyRepository = journeyRepository self.state = State(recordingJourney: CurrentValueSubject(startedJourney)) } @@ -67,6 +71,20 @@ public final class RecordJourneyViewModel: MapViewModel { MSLogger.make(category: .home).error("\(error)") } } + case .recordingDidCancelled: + Task { + let userID = try self.userRepository.fetchUUID() + let recordingJourney = self.state.recordingJourney.value + let result = await self.journeyRepository.deleteJourney(recordingJourney, userID: userID) + switch result { + case .success(let cancelledJourney): + #if DEBUG + MSLogger.make(category: .home).debug("여정이 취소 되었습니다: \(cancelledJourney)") + #endif + case .failure(let error): + MSLogger.make(category: .home).error("\(error)") + } + } } } diff --git a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController.swift b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController.swift index 267cff2..f9d720d 100644 --- a/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController.swift +++ b/iOS/Features/RewindJourney/Sources/RewindJourney/Presentation/RewindJourneyViewController.swift @@ -44,6 +44,7 @@ public final class RewindJourneyViewController: UIViewController { // MARK: - Properties + public weak var navigationDelegate: RewindJourneyNavigationDelegate? private let viewModel: RewindJourneyViewModel private var cancellables: Set = [] @@ -56,7 +57,7 @@ public final class RewindJourneyViewController: UIViewController { private var timerSubscriber: Set = [] // MARK: - Properties: Gesture - public weak var navigationDelegate: RewindJourneyNavigationDelegate? + var initialTouchPoint = CGPoint(x: 0, y: 0) // MARK: - UI Components diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewController.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewController.swift index a267a72..968884f 100644 --- a/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewController.swift +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewController.swift @@ -249,7 +249,7 @@ public final class SpotSaveViewController: UIViewController { MSLogger.make(category: .spot).debug("현재 이미지를 Data로 변환할 수 없습니다.") return } - self.viewModel.trigger(.startUploadSpot, using: jpegData) + self.viewModel.trigger(.startUploadSpot(jpegData)) self.navigationDelegate?.popToHome() } diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewModel.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewModel.swift index 8247934..8abdb21 100644 --- a/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewModel.swift +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotSaveViewModel.swift @@ -15,7 +15,7 @@ import MSLogger public final class SpotSaveViewModel { public enum Action { - case startUploadSpot + case startUploadSpot(Data) } // MARK: - Properties @@ -43,14 +43,14 @@ public final class SpotSaveViewModel { internal extension SpotSaveViewModel { - func trigger(_ action: Action, using data: Data) { + func trigger(_ action: Action) { switch action { - case .startUploadSpot: + case .startUploadSpot(let data): Task { guard let recordingJourneyID = self.journeyRepository.fetchRecordingJourneyID() else { + MSLogger.make(category: .spot).error("recoding 중인 journeyID를 찾지 못하였습니다.") return } - let spot = CreateSpotRequestDTO(journeyId: recordingJourneyID, coordinate: CoordinateDTO(self.coordinate), timestamp: .now, diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController+Gesture.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController+Gesture.swift new file mode 100644 index 0000000..35aabbd --- /dev/null +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController+Gesture.swift @@ -0,0 +1,63 @@ +// +// SpotViewController+Gesture.swift +// Spot +// +// Created by 전민건 on 12/10/23. +// + +import Foundation +import UIKit + +// MARK: - Constants + +private enum Metric { + + static let movedXPositionToBackScene: CGFloat = 50.0 + static let animationDuration: Double = 0.3 + +} + +// MARK: - Gesture + +internal extension SpotViewController { + + func configureLeftToRightSwipeGesture() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureDismiss(_:))) + self.view.addGestureRecognizer(panGesture) + } + + @objc + private func panGestureDismiss(_ sender: UIPanGestureRecognizer) { + let touchPoint = sender.location(in: self.view.window) + let width = self.view.frame.width + let height = self.view.frame.height + switch sender.state { + case .began: + self.initialTouchPoint = touchPoint + case .changed: + if touchPoint.x - self.initialTouchPoint.x > .zero { + self.view.frame = CGRect(x: touchPoint.x - self.initialTouchPoint.x, + y: .zero, + width: width, + height: height) + } + case .ended, .cancelled: + if touchPoint.x - self.initialTouchPoint.x > Metric.movedXPositionToBackScene { + self.navigationDelegate?.popToHome() + } else { + self.view.frame = CGRect(x: .zero, + y: .zero, + width: width, + height: height) + } + default: + UIView.animate(withDuration: Metric.animationDuration) { + self.view.frame = CGRect(x: .zero, + y: .zero, + width: width, + height: height) + } + } + } + +} diff --git a/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController.swift b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController.swift index 031d692..b7ee563 100644 --- a/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController.swift +++ b/iOS/Features/Spot/Sources/Spot/Presentation/SpotViewController.swift @@ -68,6 +68,10 @@ public final class SpotViewController: UIViewController, UINavigationControllerD var initialTouchPoint = CGPoint(x: 0, y: 0) + // MARK: - Properties: Haptic + + private let haptic = UIImpactFeedbackGenerator(style: .medium) + // MARK: - UI Components private let backButton = UIButton() @@ -81,6 +85,7 @@ public final class SpotViewController: UIViewController, UINavigationControllerD public override func viewDidLoad() { super.viewDidLoad() self.configure() + self.haptic.prepare() } public override func viewDidAppear(_ animated: Bool) { @@ -248,7 +253,7 @@ private extension SpotViewController { self.configureBackButtonAction() self.configureGalleryButtonAction() - self.configureUpToDownSwipeGesture() + self.configureLeftToRightSwipeGesture() } func configureCameraSetting() { @@ -283,39 +288,6 @@ private extension SpotViewController { self.backButton.addAction(backButtonAction, for: .touchUpInside) } - func configureUpToDownSwipeGesture() { - let panGesture = UIPanGestureRecognizer(target: self, action: #selector(self.panGestureDismiss(_:))) - self.view.addGestureRecognizer(panGesture) - } - - @objc - func panGestureDismiss(_ sender: UIPanGestureRecognizer) { - let touchPoint = sender.location(in: self.view.window) - - switch sender.state { - case .began: - self.initialTouchPoint = touchPoint - case .changed: - if touchPoint.y - self.initialTouchPoint.y > .zero { - self.view.frame = CGRect(x: .zero, - y: touchPoint.y - self.initialTouchPoint.y, - width: self.view.frame.width, - height: self.view.frame.height) - } - case .ended, .cancelled: - if touchPoint.y - self.initialTouchPoint.y > 200 { - self.navigationDelegate?.popToHome() - } - default: - UIView.animate(withDuration: 0.3) { - self.view.frame = CGRect(x: .zero, - y: .zero, - width: self.view.frame.width, - height: self.view.frame.height) - } - } - } - } // MARK: - Button Actions @@ -324,6 +296,7 @@ private extension SpotViewController { func shotButtonTapped() { self.viewModel.shot() + self.haptic.impactOccurred() } func swapButtonTapped() { diff --git a/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage.swift b/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage.swift index e1e9150..06115d6 100644 --- a/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage.swift +++ b/iOS/MSCoreKit/Sources/MSPersistentStorage/FileManager/FileManagerStorage.swift @@ -23,7 +23,8 @@ public final class FileManagerStorage: NSObject, MSPersistentStorage { dateFormatter.formatOptions = [.withFractionalSeconds, .withTimeZone, .withInternetDateTime] encoder.dateEncodingStrategy = .custom({ date, encoder in var container = encoder.singleValueContainer() - try container.encode(date) + let dateString = dateFormatter.string(from: date) + try container.encode(dateString) }) return encoder }() @@ -31,7 +32,7 @@ public final class FileManagerStorage: NSObject, MSPersistentStorage { private let decoder: JSONDecoder = { let decoder = JSONDecoder() let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withFractionalSeconds, .withTimeZone, .withInternetDateTime] + dateFormatter.formatOptions.insert(.withFractionalSeconds) decoder.dateDecodingStrategy = .custom({ decoder in let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) @@ -41,6 +42,7 @@ public final class FileManagerStorage: NSObject, MSPersistentStorage { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Date 디코딩 실패: \(dateString)") }) + decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder }() @@ -74,9 +76,20 @@ public final class FileManagerStorage: NSObject, MSPersistentStorage { if let path = self.storageURL()?.path, let contents = try? self.fileManager.contentsOfDirectory(atPath: path) { let allDecodedData: [T] = contents.compactMap { content in - guard let dataPath = URL(string: (path as NSString).appendingPathComponent(content)), - let data = try? Data(contentsOf: dataPath), - let decodedData = try? self.decoder.decode(T.self, from: data) else { return nil } + let key = String(content.dropLast(".json".count)) + guard let dataPath = + fileURL(forKey: key) else { + MSLogger.make(category: .fileManager).error("경로의 Data를 가져오지 못하였습니다.") + return nil + } + MSLogger.make(category: .fileManager).error("경로의 Data를 성공적으로 가져왔습니다.") + + print(dataPath.description) + guard let data = try? Data(contentsOf: dataPath), + let decodedData = try? self.decoder.decode(T.self, from: data) else { + MSLogger.make(category: .fileManager).error("decode에 실패하였습니다.") + return nil + } return decodedData } return allDecodedData diff --git a/iOS/MSCoreKit/Tests/MSPersistentStorageTests/FileManagerStorageTests.swift b/iOS/MSCoreKit/Tests/MSPersistentStorageTests/FileManagerStorageTests.swift index 401ecf9..b66d53d 100644 --- a/iOS/MSCoreKit/Tests/MSPersistentStorageTests/FileManagerStorageTests.swift +++ b/iOS/MSCoreKit/Tests/MSPersistentStorageTests/FileManagerStorageTests.swift @@ -101,21 +101,54 @@ final class MSPersistentStorageTests: XCTestCase { } func test_FileManagerStorage에서_모든데이터저장불러오기_성공() { - let sut = MockCodableData(title: "boostcamp", content: "wm8") - let key = "S045" - self.fileStorage.set(value: sut, forKey: key) - self.fileStorage.set(value: sut, forKey: key) - self.fileStorage.set(value: sut, forKey: key) + let sut1 = MockCodableData(title: "boostcamp", content: "wm8") + let sut2 = MockCodableData(title: "boostcamp", content: "wm8") + let key1 = "S045" + let key2 = "S034" + + self.fileStorage.set(value: sut1, forKey: key1) + self.fileStorage.set(value: sut2, forKey: key2) + + guard let allStoredData = self.fileStorage.getAllOf(MockCodableData.self) else { + XCTFail("데이터 읽기에 실패했습니다.") + return + } + + XCTAssertEqual(allStoredData.count, 2, "데이터 저장에 실패하였습니다.") + XCTAssertTrue(allStoredData.allSatisfy { $0 == sut1 || $0 == sut2 }) + } + + func test_FileManagerStorage에서_모든데이터저장불러올때_폴더하위항목까지_읽을수있는지_실패() { + let sut1 = MockCodableData(title: "boostcamp", content: "wm8") + let sut2 = MockCodableData(title: "boostcamp", content: "wm8") + let key1 = "S045" + let key2 = "/handsome/jeonmingun/S034" + + self.fileStorage.set(value: sut1, forKey: key1) + self.fileStorage.set(value: sut2, forKey: key2) guard let allStoredData = self.fileStorage.getAllOf(MockCodableData.self) else { XCTFail("데이터 읽기에 실패했습니다.") return } + + XCTAssertEqual(allStoredData.count, 2, "데이터 저장에 실패하였습니다.") + XCTAssertFalse(allStoredData.allSatisfy { $0 == sut1 || $0 == sut2 }) + } + + func test_Date형식_저장할_수_있는지_성공() { + let sut = Date.now + let key = "S034" - allStoredData.forEach { storedData in - XCTAssertEqual(sut, storedData, - "목표 데이터와 불러온 값이 다릅니다.") + self.fileStorage.set(value: sut, forKey: key) + + guard let storedData = self.fileStorage.get(Date.self, forKey: key) else { + XCTFail("데이터 읽기에 실패했습니다.") + return } + + XCTAssertEqual(sut.description, storedData.description, + "목표 데이터와 불러온 값이 다릅니다.") } } diff --git a/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSData.xcscheme b/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSData.xcscheme index fdac714..d164747 100644 --- a/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSData.xcscheme +++ b/iOS/MSData/.swiftpm/xcode/xcshareddata/xcschemes/MSData.xcscheme @@ -39,6 +39,16 @@ ReferencedContainer = "container:"> + + + + Bool + func loadJourneyFromLocal() -> RecordingJourney? + +} + +// MARK: - Interface + +extension JourneyRepositoryImplementation: Persistable { + + private struct KeyStorage { + + static var id: String? = nil + static var startTimestamp: String? = nil + static var spots = [String]() + static var coordinates = [String]() + + } + + @discardableResult + public func saveToLocal(value: Codable) -> Bool { + let key = UUID().uuidString + self.storage.set(value: value, forKey: key) + + switch value { + case is String: + if KeyStorage.id == nil { + KeyStorage.id = key + } else { + MSLogger.make(category: .persistable).debug("journey ID는 하나의 값만 저장할 수 있습니다.") + return false + } + case is Date: + if KeyStorage.startTimestamp == nil { + KeyStorage.startTimestamp = key + } else { + MSLogger.make(category: .persistable).debug("start tamp는 하나의 값만 저장할 수 있습니다.") + return false + } + case is SpotDTO: + KeyStorage.spots.append(key) + case is CoordinateDTO: + KeyStorage.coordinates.append(key) + default: + MSLogger.make(category: .persistable).debug("RecordingJourney 타입의 요소들만 넣을 수 있습니다.") + return false + } + return true + } + + public func loadJourneyFromLocal() -> RecordingJourney? { + guard let id = self.loadID(), + let startTimestamp = self.loadStartTimeStamp() else { + return nil + } + return RecordingJourney(id: id, + startTimestamp: startTimestamp, + spots: self.loadSpots(), + coordinates: self.loadCoordinates()) + } + +} + +// MARK: - load Functions + +private extension JourneyRepositoryImplementation { + + func loadStartTimeStamp() -> Date? { + guard let startTimestampKey = KeyStorage.startTimestamp, + let startTimestamp = self.storage.get(Date.self, forKey: startTimestampKey) + else { + MSLogger.make(category: .persistable).debug("id 또는 startTimestamp가 저장되지 않았습니다.") + return nil + } + return startTimestamp + } + + func loadID() -> String? { + guard let idKey = KeyStorage.id, + let id = self.storage.get(String.self, forKey: idKey) else { + MSLogger.make(category: .persistable).debug("id 또는 startTimestamp가 저장되지 않았습니다.") + return nil + } + return id + } + + func loadSpots() -> [Spot] { + return KeyStorage.spots.compactMap { spotKey in + let spotDTO = self.storage.get(SpotDTO.self, forKey: spotKey) + return spotDTO?.toDomain() + } + } + + func loadCoordinates() -> [Coordinate] { + return KeyStorage.coordinates.compactMap { coordinateKey in + let coordinateDTO = self.storage.get(CoordinateDTO.self, forKey: coordinateKey) + return coordinateDTO?.toDomain() + } + } + +} diff --git a/iOS/MSData/Sources/MSData/Repository/JourneyRepository.swift b/iOS/MSData/Sources/MSData/Repository/JourneyRepository.swift index 1b275f9..9f530c8 100644 --- a/iOS/MSData/Sources/MSData/Repository/JourneyRepository.swift +++ b/iOS/MSData/Sources/MSData/Repository/JourneyRepository.swift @@ -10,6 +10,7 @@ import Foundation import MSConstants import MSDomain +import MSLogger import MSNetworking import MSPersistentStorage import MSUserDefaults @@ -24,6 +25,7 @@ public protocol JourneyRepository { mutating func startJourney(at coordinate: Coordinate, userID: UUID) async -> Result mutating func endJourney(_ journey: Journey) async -> Result func recordJourney(journeyID: String, at coordinates: [Coordinate]) async -> Result + mutating func deleteJourney(_ journey: RecordingJourney, userID: UUID) async -> Result } @@ -37,6 +39,10 @@ public struct JourneyRepositoryImplementation: JourneyRepository { @UserDefaultsWrapped(UserDefaultsKey.recordingJourneyID, defaultValue: nil) private var recordingJourneyID: String? + // MARK: - Properties: Persistable + + internal var storage = FileManagerStorage() + // MARK: - Initializer public init(session: URLSession = URLSession(configuration: .default), @@ -48,7 +54,11 @@ public struct JourneyRepositoryImplementation: JourneyRepository { // MARK: - Functions public func fetchRecordingJourneyID() -> String? { - return self.recordingJourneyID + guard let recordingJourneyID = self.recordingJourneyID else { + MSLogger.make(category: .userDefaults).error("기록 중인 여정 정보를 가져오는데 실패했습니다.") + return nil + } + return recordingJourneyID } public func fetchRecordingJourney(forID id: String) -> RecordingJourney? { @@ -110,6 +120,13 @@ public struct JourneyRepositoryImplementation: JourneyRepository { spots: [], coordinates: [responseDTO.coordinate.toDomain()]) self.recordingJourneyID = recordingJourney.id + #if DEBUG + if let recordingJourneyID = self.recordingJourneyID { + MSLogger.make(category: .userDefaults).debug("기록중인 여정 정보가 저장되었습니다: \(recordingJourneyID)") + } else { + MSLogger.make(category: .userDefaults).error("기록중인 여정 정보 저장에 실패했습니다.") + } + #endif return .success(recordingJourney) case .failure(let error): return .failure(error) @@ -153,4 +170,18 @@ public struct JourneyRepositoryImplementation: JourneyRepository { } } + public mutating func deleteJourney(_ recordingJourney: RecordingJourney, + userID: UUID) async -> Result { + let requestDTO = DeleteJourneyRequestDTO(userID: userID, journeyID: recordingJourney.id) + let router = JourneyRouter.deleteJourney(dto: requestDTO) + let result = await self.networking.request(DeleteJourneyResponseDTO.self, router: router) + switch result { + case .success(let responseDTO): + self.recordingJourneyID = nil + return .success(responseDTO.id) + case .failure(let error): + return .failure(error) + } + } + } diff --git a/iOS/MSData/Sources/MSData/Repository/LocalRepository.swift b/iOS/MSData/Sources/MSData/Repository/LocalRepository.swift deleted file mode 100644 index ab5dd4b..0000000 --- a/iOS/MSData/Sources/MSData/Repository/LocalRepository.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// LocalRepository.swift -// MSData -// -// Created by 전민건 on 12/10/23. -// - -import Foundation - -import MSDomain -import MSPersistentStorage - -public protocol LocalRepository { - - var key: String { get } - func save(coordinateDTO: CoordinateDTO) - func loadCoordinates() -> [Coordinate]? - -} - -public struct LocalRepositoryImplementation: LocalRepository { - - // MARK: - Properties - - private var storage = FileManagerStorage() - private var coordinateURL: URL? - public let key: String - - // MARK: - Initializer - - public init() { - self.key = UUID().uuidString - } - - // MARK: - Functions - - public func save(coordinateDTO: CoordinateDTO) { - self.storage.set(value: coordinateDTO, forKey: self.key) - } - - public func loadCoordinates() -> [Coordinate]? { - let coordinates = self.storage.getAllOf(CoordinateDTO.self) - return coordinates?.compactMap { $0.toDomain() } - } - - public func deleteAll() { - try? self.storage.deleteAll() - } - -} diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Body.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Body.swift index 5ae5f86..4e4d796 100644 --- a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Body.swift +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Body.swift @@ -17,6 +17,8 @@ extension JourneyRouter { return HTTPBody(content: dto) case let .recordCoordinate(dto): return HTTPBody(content: dto) + case let .deleteJourney(dto): + return HTTPBody(content: dto) default: return nil } } diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Method.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Method.swift index 52dadf1..a717151 100644 --- a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Method.swift +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+Method.swift @@ -16,6 +16,7 @@ extension JourneyRouter { case .recordCoordinate: return .post case .checkJourney: return .get case .loadLastJourney: return .get + case .deleteJourney: return .delete } } diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+URL.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+URL.swift index 11e4bed..e0e18a2 100644 --- a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+URL.swift +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter+URL.swift @@ -26,6 +26,7 @@ extension JourneyRouter { case .recordCoordinate: return "record" case .checkJourney: return "check" case .loadLastJourney: return "loadLastData" + case .deleteJourney: return nil } } diff --git a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter.swift b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter.swift index 788a5f7..683ec90 100644 --- a/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter.swift +++ b/iOS/MSData/Sources/MSData/Router/Journey/JourneyRouter.swift @@ -21,5 +21,7 @@ public enum JourneyRouter: Router { case checkJourney(userID: UUID, minCoordinate: CoordinateDTO, maxCoordinate: CoordinateDTO) /// 진행 중인 여정이 있는 지 확인하고, 있다면 여정 정보를 반환합니다. case loadLastJourney(userID: UUID) + /// 여정 ID에 따른 여정 삭제 + case deleteJourney(dto: DeleteJourneyRequestDTO) } diff --git a/iOS/MSData/Tests/RepositoryTests/PersistableRepositoryTests.swift b/iOS/MSData/Tests/RepositoryTests/PersistableRepositoryTests.swift new file mode 100644 index 0000000..6b296fa --- /dev/null +++ b/iOS/MSData/Tests/RepositoryTests/PersistableRepositoryTests.swift @@ -0,0 +1,57 @@ +// +// PersistableRepositoryTests.swift +// +// +// Created by 전민건 on 12/11/23. +// + +import XCTest +@testable import MSData +@testable import MSDomain + +final class PersistableRepositoryTests: XCTestCase { + + // MARK: - Properties + + private let journeyRepository = JourneyRepositoryImplementation() + + // MARK: - Tests + + func test_Spot저장_성공() { + let coordinate = Coordinate(latitude: 10, longitude: 10) + let url = URL(string: "/../")! + + let spot = Spot(coordinate: coordinate, timestamp: .now, photoURL: url) + + XCTAssertTrue(self.journeyRepository.saveToLocal(value: SpotDTO(spot))) + } + + func test_RecordingJourney_하위요소가_아닌_것들_저장_실패() { + XCTAssertFalse(self.journeyRepository.saveToLocal(value: Int())) + } + + func test_RecordingJourney_반환_성공() { + let url = URL(string: "/../")! + + let id = "id" + let startTimestamp = Date.now + let coordinate = Coordinate(latitude: 5, longitude: 5) + let spot = Spot(coordinate: coordinate, timestamp: .now, photoURL: url) + + self.journeyRepository.saveToLocal(value: id) + self.journeyRepository.saveToLocal(value: Date.now) + self.journeyRepository.saveToLocal(value: SpotDTO(spot)) + self.journeyRepository.saveToLocal(value: CoordinateDTO(coordinate)) + + guard let loadedJourney = self.journeyRepository.loadJourneyFromLocal() else { + XCTFail("load 실패") + return + } + + XCTAssertEqual(loadedJourney.id, id) + XCTAssertEqual(loadedJourney.startTimestamp.description, startTimestamp.description) + XCTAssertEqual(loadedJourney.spots.description, [spot].description) + XCTAssertEqual(loadedJourney.coordinates, [coordinate]) + } + +} diff --git a/iOS/MSDomain/Sources/MSDomain/Model/Journey.swift b/iOS/MSDomain/Sources/MSDomain/Model/Journey.swift index 1d05eb7..06027e4 100644 --- a/iOS/MSDomain/Sources/MSDomain/Model/Journey.swift +++ b/iOS/MSDomain/Sources/MSDomain/Model/Journey.swift @@ -49,3 +49,19 @@ extension Journey: Hashable { } } + +// MARK: - String Convertible + +extension Journey: CustomStringConvertible { + + public var description: String { + return """ + Journey + - title: \(self.title) + - date: + - start: \(self.date.start) + - end: \(self.date.end) + """ + } + +} diff --git a/iOS/MSFoundation/Sources/MSLogger/MSLogCategory.swift b/iOS/MSFoundation/Sources/MSLogger/MSLogCategory.swift index 16c8284..7d391d3 100644 --- a/iOS/MSFoundation/Sources/MSLogger/MSLogCategory.swift +++ b/iOS/MSFoundation/Sources/MSLogger/MSLogCategory.swift @@ -15,6 +15,7 @@ public enum MSLogCategory: String { case userDefaults case keychain = "Keychain" case fileManager = "FileManager" + case persistable case home case navigateMap diff --git a/iOS/MSFoundation/Sources/MSUserDefaults/UserDefaultsWrapped.swift b/iOS/MSFoundation/Sources/MSUserDefaults/UserDefaultsWrapped.swift index 8f6071a..6cacb0a 100644 --- a/iOS/MSFoundation/Sources/MSUserDefaults/UserDefaultsWrapped.swift +++ b/iOS/MSFoundation/Sources/MSUserDefaults/UserDefaultsWrapped.swift @@ -22,24 +22,22 @@ public struct UserDefaultsWrapped { self.key = key self.defaultValue = defaultValue self.userDefaults = userDefaults - - save(defaultValue) } public var wrappedValue: T { - get { load(forKey: key) ?? defaultValue } - set { save(newValue) } + get { self.load(forKey: self.key) ?? self.defaultValue } + set { self.save(newValue) } } private func save(_ newValue: T) { - if let encoded = try? encoder.encode(newValue) { - userDefaults.setValue(encoded, forKey: key) + if let encoded = try? self.encoder.encode(newValue) { + self.userDefaults.setValue(encoded, forKey: self.key) } } private func load(forKey key: String) -> T? { - guard let savedData = userDefaults.object(forKey: key) as? Data, - let loadedObject = try? decoder.decode(T.self, from: savedData) else { + guard let savedData = self.userDefaults.object(forKey: key) as? Data, + let loadedObject = try? self.decoder.decode(T.self, from: savedData) else { return nil } return loadedObject diff --git a/iOS/MusicSpot/MusicSpot/MSCoordinator/HomeCoordinator.swift b/iOS/MusicSpot/MusicSpot/MSCoordinator/HomeCoordinator.swift index 68fe626..ff137ff 100644 --- a/iOS/MusicSpot/MusicSpot/MSCoordinator/HomeCoordinator.swift +++ b/iOS/MusicSpot/MusicSpot/MSCoordinator/HomeCoordinator.swift @@ -62,6 +62,7 @@ final class HomeCoordinator: Coordinator { minimizedDimension: .absolute(100.0)) homeViewController.configuration = configuration homeViewController.navigationDelegate = self + navigateMapViewController.delegate = homeViewController self.navigationController.pushViewController(homeViewController, animated: true) }