diff --git a/iOS/Layover/Layover.xcodeproj/project.pbxproj b/iOS/Layover/Layover.xcodeproj/project.pbxproj index 8fb0922..cbe146d 100644 --- a/iOS/Layover/Layover.xcodeproj/project.pbxproj +++ b/iOS/Layover/Layover.xcodeproj/project.pbxproj @@ -157,6 +157,7 @@ 836C338B2B15D22C00ECAFB0 /* PlaybackConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836C338A2B15D22C00ECAFB0 /* PlaybackConfigurator.swift */; }; 836C33912B17629400ECAFB0 /* MapRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836C33902B17629400ECAFB0 /* MapRouter.swift */; }; 83957AA62B20F94C00B3BA8A /* MockPlaybackWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83957AA52B20F94C00B3BA8A /* MockPlaybackWorker.swift */; }; + 839F1DF82B62AEDA0071C622 /* LOTextLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 839F1DF72B62AEDA0071C622 /* LOTextLabel.swift */; }; 83C35E1B2B108C3500D8DD5C /* PlaybackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C35E1A2B108C3500D8DD5C /* PlaybackView.swift */; }; 83C35E1E2B10923C00D8DD5C /* PlaybackCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C35E1D2B10923C00D8DD5C /* PlaybackCell.swift */; }; FC0E80242B1A0BBB00EF56D6 /* UploadPostPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC0E801E2B1A0BBB00EF56D6 /* UploadPostPresenter.swift */; }; @@ -422,6 +423,7 @@ 836C33902B17629400ECAFB0 /* MapRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapRouter.swift; sourceTree = ""; }; 836C33972B1843BE00ECAFB0 /* SettingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingViewController.swift; sourceTree = ""; }; 83957AA52B20F94C00B3BA8A /* MockPlaybackWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPlaybackWorker.swift; sourceTree = ""; }; + 839F1DF72B62AEDA0071C622 /* LOTextLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LOTextLabel.swift; sourceTree = ""; }; 83C35E1A2B108C3500D8DD5C /* PlaybackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackView.swift; sourceTree = ""; }; 83C35E1D2B10923C00D8DD5C /* PlaybackCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCell.swift; sourceTree = ""; }; FC0E801E2B1A0BBB00EF56D6 /* UploadPostPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadPostPresenter.swift; sourceTree = ""; }; @@ -1191,6 +1193,7 @@ 8321A2F62B1E14A1000A12AF /* LOPopUpView.swift */, 8321A2F82B1E15F3000A12AF /* LOReportStackView.swift */, 8321A2FA2B1E1739000A12AF /* LOReportContentView.swift */, + 839F1DF72B62AEDA0071C622 /* LOTextLabel.swift */, ); path = DesignSystem; sourceTree = ""; @@ -1561,6 +1564,7 @@ 198167A42B20583D0032F563 /* SettingInteractor.swift in Sources */, FC4E0C1D2B28977000152596 /* CurrentLocationManager.swift in Sources */, 83C35E1B2B108C3500D8DD5C /* PlaybackView.swift in Sources */, + 839F1DF82B62AEDA0071C622 /* LOTextLabel.swift in Sources */, FC4E0C192B28955400152596 /* LocationFetcher.swift in Sources */, 835A61A02B068115002F22A5 /* PlaybackModels.swift in Sources */, FC0E80292B1A0BBB00EF56D6 /* UploadPostInteractor.swift in Sources */, diff --git a/iOS/Layover/Layover/DesignSystem/LOTextLabel.swift b/iOS/Layover/Layover/DesignSystem/LOTextLabel.swift new file mode 100644 index 0000000..132a1e3 --- /dev/null +++ b/iOS/Layover/Layover/DesignSystem/LOTextLabel.swift @@ -0,0 +1,39 @@ +// +// LOTextLabel.swift +// Layover +// +// Created by 황지웅 on 1/25/24. +// Copyright © 2024 CodeBomber. All rights reserved. +// + +import UIKit + +final class LOTextLabel: UILabel { + + private var padding = UIEdgeInsets(top: 16.0, left: 16.0, bottom: 16.0, right: 16.0) + + convenience init(padding: UIEdgeInsets) { + self.init() + self.padding = padding + setUI() + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: padding)) + } + + override var intrinsicContentSize: CGSize { + var contentSize = super.intrinsicContentSize + contentSize.height += padding.top + padding.bottom + contentSize.width += padding.left + padding.right + + return contentSize + } + + private func setUI() { + layer.cornerRadius = 8 + layer.borderWidth = 1 + layer.borderColor = UIColor.grey500.cgColor + backgroundColor = UIColor.clear + } +} diff --git a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostInteractor.swift b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostInteractor.swift index 72650eb..e28cb82 100644 --- a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostInteractor.swift +++ b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostInteractor.swift @@ -17,9 +17,13 @@ protocol UploadPostBusinessLogic { func fetchTags() func editTags(with request: UploadPostModels.EditTags.Request) func fetchThumbnailImage() async - func fetchCurrentAddress() async + func fetchCurrentAddress() async -> UploadPostModels.AddressInfo? func canUploadPost(request: UploadPostModels.CanUploadPost.Request) func uploadPost(request: UploadPostModels.UploadPost.Request) + func fetchVideoAddress() async -> UploadPostModels.AddressInfo? + func fetchAddresses() async + func showActionSheet() + func selectAddress(with request: UploadPostModels.SelectAddress.Request) } protocol UploadPostDataStore { @@ -45,6 +49,8 @@ final class UploadPostInteractor: NSObject, UploadPostBusinessLogic, UploadPostD var videoURL: URL? var isMuted: Bool? var tags: [String]? = [] + var videoAddress: Models.AddressInfo? + var currentAddress: Models.AddressInfo? // MARK: - Object LifeCycle @@ -80,8 +86,8 @@ final class UploadPostInteractor: NSObject, UploadPostBusinessLogic, UploadPostD } } - func fetchCurrentAddress() async { - guard let location = locationManager.getCurrentLocation() else { return } + func fetchCurrentAddress() async -> UploadPostModels.AddressInfo? { + guard let location = locationManager.getCurrentLocation() else { return nil } let localeIdentifier = Locale.preferredLanguages.first != nil ? Locale.preferredLanguages[0] : Locale.current.identifier let locale = Locale(identifier: localeIdentifier) do { @@ -89,14 +95,35 @@ final class UploadPostInteractor: NSObject, UploadPostBusinessLogic, UploadPostD let administrativeArea = address?.administrativeArea let locality = address?.locality let subLocality = address?.subLocality - let response = Models.FetchCurrentAddress.Response(administrativeArea: administrativeArea, - locality: locality, - subLocality: subLocality) - await MainActor.run { - presenter?.presentCurrentAddress(with: response) - } + return Models.AddressInfo( + administrativeArea: administrativeArea, + locality: locality, + subLocality: subLocality) } catch { os_log(.error, log: .data, "Failed to fetch Current Address with error: %@", error.localizedDescription) + return nil + } + } + + func fetchVideoAddress() async -> UploadPostModels.AddressInfo? { + guard let videoURL, + let videoLocation = await worker?.loadVideoLocation(videoURL: videoURL), + let location = locationManager.getVideoLocation(latitude: videoLocation.latitude, longitude: videoLocation.longitude) + else { return nil } + let localeIdentifier = Locale.preferredLanguages.first != nil ? Locale.preferredLanguages[0] : Locale.current.identifier + let locale = Locale(identifier: localeIdentifier) + do { + let address = try await CLGeocoder().reverseGeocodeLocation(location, preferredLocale: locale).last + let administrativeArea = address?.administrativeArea + let locality = address?.locality + let subLocality = address?.subLocality + return Models.AddressInfo( + administrativeArea: administrativeArea, + locality: locality, + subLocality: subLocality) + } catch { + os_log(.error, log: .data, "Failed to fetch Video Address with error: %@", error.localizedDescription) + return nil } } @@ -127,6 +154,37 @@ final class UploadPostInteractor: NSObject, UploadPostBusinessLogic, UploadPostD } } + func fetchAddresses() async { + async let currentAddressInfo = fetchCurrentAddress() + async let videoAddressInfo = fetchVideoAddress() + + videoAddress = await videoAddressInfo + currentAddress = await currentAddressInfo + + let response: Models.FetchCurrentAddress.Response = Models.FetchCurrentAddress.Response(addressInfo: [ videoAddress, currentAddress].compactMap { $0 }) + await MainActor.run { + presenter?.presentCurrentAddress(with: response) + } + } + + func selectAddress(with request: UploadPostModels.SelectAddress.Request) { + var response: Models.FetchCurrentAddress.Response + switch request.addressType { + case .video: + guard let videoAddress else { return } + response = Models.FetchCurrentAddress.Response(addressInfo: [videoAddress]) + case .current: + guard let currentAddress else { return } + response = Models.FetchCurrentAddress.Response(addressInfo: [currentAddress]) + } + presenter?.presentCurrentAddress(with: response) + } + + func showActionSheet() { + let response: Models.ShowActionSheet.Response = Models.ShowActionSheet.Response(videoAddress: videoAddress, currentAddress: currentAddress) + presenter?.presentShowActionSheet(with: response) + } + private func exportVideoWithoutAudio(at url: URL) async { let composition = AVMutableComposition() let sourceAsset = AVURLAsset(url: url) @@ -158,5 +216,4 @@ final class UploadPostInteractor: NSObject, UploadPostBusinessLogic, UploadPostD os_log(.error, log: .data, "Failed to extract Video Without Audio with error: %@", error.localizedDescription) } } - } diff --git a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostModels.swift b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostModels.swift index 74f2560..8c93182 100644 --- a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostModels.swift +++ b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostModels.swift @@ -13,6 +13,22 @@ enum UploadPostModels { static let titleMaxLength: Int = 15 static let contentMaxLength: Int = 50 + struct VideoAddress { + let latitude: Double + let longitude: Double + } + + struct AddressInfo { + let administrativeArea: String? + let locality: String? + let subLocality: String? + } + + enum AddressType { + case video + case current + } + enum CanUploadPost { struct Request { let title: String? @@ -61,9 +77,7 @@ enum UploadPostModels { } struct Response { - let administrativeArea: String? - let locality: String? - let subLocality: String? + let addressInfo: [AddressInfo] } struct ViewModel { let fullAddress: String @@ -79,7 +93,32 @@ enum UploadPostModels { struct Response { } - struct VideModel { + struct ViewModel { + + } + } + + enum ShowActionSheet { + struct Request { + + } + struct Response { + let videoAddress: AddressInfo? + let currentAddress: AddressInfo? + } + struct ViewModel { + let addressTypes: [AddressType] + } + } + + enum SelectAddress { + struct Request { + let addressType: AddressType + } + struct Response { + + } + struct ViewModel { } } diff --git a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostPresenter.swift b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostPresenter.swift index d8c249f..9c83199 100644 --- a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostPresenter.swift +++ b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostPresenter.swift @@ -14,6 +14,7 @@ protocol UploadPostPresentationLogic { func presentCurrentAddress(with response: UploadPostModels.FetchCurrentAddress.Response) func presentUploadButton(with response: UploadPostModels.CanUploadPost.Response) func presentUnsupportedFormatAlert() + func presentShowActionSheet(with response: UploadPostModels.ShowActionSheet.Response) } final class UploadPostPresenter: UploadPostPresentationLogic { @@ -34,11 +35,11 @@ final class UploadPostPresenter: UploadPostPresentationLogic { func presentCurrentAddress(with response: UploadPostModels.FetchCurrentAddress.Response) { let addresses: [String] = [ - response.administrativeArea, - response.locality, - response.subLocality] + response.addressInfo.first?.administrativeArea, + response.addressInfo.first?.locality, + response.addressInfo.first?.subLocality] .compactMap { $0 } - + var fullAddress: [String] = [] for address in addresses { @@ -46,7 +47,6 @@ final class UploadPostPresenter: UploadPostPresentationLogic { fullAddress.append(address) } } - let viewModel = Models.FetchCurrentAddress.ViewModel(fullAddress: fullAddress.joined(separator: " ")) viewController?.displayCurrentAddress(viewModel: viewModel) } @@ -59,4 +59,15 @@ final class UploadPostPresenter: UploadPostPresentationLogic { func presentUnsupportedFormatAlert() { viewController?.displayUnsupportedFormatAlert() } + + func presentShowActionSheet(with response: UploadPostModels.ShowActionSheet.Response) { + var addressTypes: [Models.AddressType] = [] + if response.videoAddress != nil { + addressTypes.append(.video) + } + if response.currentAddress != nil { + addressTypes.append(.current) + } + viewController?.displayActionSheet(viewModel: Models.ShowActionSheet.ViewModel(addressTypes: addressTypes)) + } } diff --git a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostViewController.swift b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostViewController.swift index 3836130..623d0d3 100644 --- a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostViewController.swift +++ b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostViewController.swift @@ -14,6 +14,7 @@ protocol UploadPostDisplayLogic: AnyObject { func displayCurrentAddress(viewModel: UploadPostModels.FetchCurrentAddress.ViewModel) func displayUploadButton(viewModel: UploadPostModels.CanUploadPost.ViewModel) func displayUnsupportedFormatAlert() + func displayActionSheet(viewModel: UploadPostModels.ShowActionSheet.ViewModel) } final class UploadPostViewController: BaseViewController { @@ -75,11 +76,12 @@ final class UploadPostViewController: BaseViewController { return imageLabel }() - private let currentAddressLabel: UILabel = { - let label = UILabel() + private let currentAddressLabel: LOTextLabel = { + let label = LOTextLabel(padding: UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) label.font = .loFont(type: .body2) label.numberOfLines = 1 label.adjustsFontSizeToFitWidth = true + label.isUserInteractionEnabled = true return label }() @@ -142,6 +144,7 @@ final class UploadPostViewController: BaseViewController { setConstraints() setDelegation() addTarget() + addLocationTarget() fetchPostInfo() } @@ -176,7 +179,7 @@ final class UploadPostViewController: BaseViewController { private func fetchPostInfo() { Task { - await interactor?.fetchCurrentAddress() + await interactor?.fetchAddresses() await interactor?.fetchThumbnailImage() } } @@ -247,6 +250,11 @@ final class UploadPostViewController: BaseViewController { scrollView.addGestureRecognizer(singleTapGestureRecognizer) } + private func addLocationTarget() { + let singleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(locationDidTap)) + currentAddressLabel.addGestureRecognizer(singleTapGestureRecognizer) + } + @objc private func titleTextChanged() { interactor?.canUploadPost(request: Models.CanUploadPost.Request(title: titleTextField.text)) } @@ -270,6 +278,10 @@ final class UploadPostViewController: BaseViewController { router?.routeToBack() } + @objc private func locationDidTap() { + interactor?.showActionSheet() + } + } extension UploadPostViewController: UITextFieldDelegate { @@ -321,4 +333,21 @@ extension UploadPostViewController: UploadPostDisplayLogic { Toast.shared.showToast(message: "지원하지 않는 파일 형식이에요 😢") } + func displayActionSheet(viewModel: UploadPostModels.ShowActionSheet.ViewModel) { + let actionSheet = UIAlertController(title: "주소 선택", message: "원하는 위치의 주소를 선택하세요.", preferredStyle: .actionSheet) + for type in viewModel.addressTypes { + switch type { + case .video: + actionSheet.addAction(UIAlertAction(title: "영상 위치", style: .default, handler: { _ in + self.interactor?.selectAddress(with: Models.SelectAddress.Request(addressType: type)) + })) + case .current: + actionSheet.addAction(UIAlertAction(title: "현재 위치", style: .default, handler: { _ in + self.interactor?.selectAddress(with: Models.SelectAddress.Request(addressType: type)) + })) + } + } + actionSheet.addAction(UIAlertAction(title: "취소", style: .cancel, handler: nil)) + present(actionSheet, animated: true, completion: nil) + } } diff --git a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostWorker.swift b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostWorker.swift index 02535f0..dfc2f23 100644 --- a/iOS/Layover/Layover/Scenes/UploadPost/UploadPostWorker.swift +++ b/iOS/Layover/Layover/Scenes/UploadPost/UploadPostWorker.swift @@ -7,12 +7,14 @@ // import UIKit +import AVFoundation import OSLog protocol UploadPostWorkerProtocol { func uploadPost(with request: UploadPost) async -> UploadPostDTO? func uploadVideo(with request: UploadVideoRequestDTO, videoURL: URL) async -> Bool + func loadVideoLocation(videoURL: URL) async -> UploadPostModels.VideoAddress? } final class UploadPostWorker: NSObject, UploadPostWorkerProtocol { @@ -64,6 +66,27 @@ final class UploadPostWorker: NSObject, UploadPostWorkerProtocol { } } + func loadVideoLocation(videoURL: URL) async -> UploadPostModels.VideoAddress? { + let asset = AVAsset(url: videoURL) + let metadata = try? await asset.load(.metadata) + guard let metadata else { return nil } + for meta in metadata { + if meta.commonKey == AVMetadataKey.commonKeyLocation { + let location = try? await meta.load(.stringValue)?.split(separator: "+") + if location?.count ?? 0 < 2 { + return nil + } + guard let latitudeString = location?[0], + let longitudeString = location?[1], + let latitude = Double(latitudeString), + let longitude = Double(longitudeString) + else { return nil } + return Models.VideoAddress(latitude: latitude, longitude: longitude) + } + } + return nil + } + } extension UploadPostWorker: URLSessionTaskDelegate { diff --git a/iOS/Layover/Layover/Services/Location/CurrentLocationManager.swift b/iOS/Layover/Layover/Services/Location/CurrentLocationManager.swift index c2e77cc..e6ba6bf 100644 --- a/iOS/Layover/Layover/Services/Location/CurrentLocationManager.swift +++ b/iOS/Layover/Layover/Services/Location/CurrentLocationManager.swift @@ -28,6 +28,12 @@ final class CurrentLocationManager: NSObject { return CLLocation(latitude: space.latitude, longitude: space.longitude) } + func getVideoLocation(latitude: Double?, longitude: Double?) -> CLLocation? { + guard let latitude, + let longitude else { return nil } + return CLLocation(latitude: latitude, longitude: longitude) + } + func getAuthorizationStatus() -> CLAuthorizationStatus { return locationFetcher.authorizationStatus } diff --git a/iOS/Layover/LayoverTests/Mocks/Workers/MockUploadPostWorker.swift b/iOS/Layover/LayoverTests/Mocks/Workers/MockUploadPostWorker.swift index d74df6c..c2fa7ed 100644 --- a/iOS/Layover/LayoverTests/Mocks/Workers/MockUploadPostWorker.swift +++ b/iOS/Layover/LayoverTests/Mocks/Workers/MockUploadPostWorker.swift @@ -53,4 +53,8 @@ final class MockUploadPostWorker: UploadPostWorkerProtocol { return true } + func loadVideoLocation(videoURL: URL) async -> Layover.UploadPostModels.VideoAddress? { + nil + } + } diff --git a/iOS/Layover/LayoverTests/Scenes/UploadPost/UploadPostInteractorTests.swift b/iOS/Layover/LayoverTests/Scenes/UploadPost/UploadPostInteractorTests.swift index f334d86..20d3282 100644 --- a/iOS/Layover/LayoverTests/Scenes/UploadPost/UploadPostInteractorTests.swift +++ b/iOS/Layover/LayoverTests/Scenes/UploadPost/UploadPostInteractorTests.swift @@ -73,6 +73,10 @@ class UploadPostInteractorTests: XCTestCase { } + func presentShowActionSheet(with response: Layover.UploadPostModels.ShowActionSheet.Response) { + // + } + } // MARK: - Tests @@ -107,10 +111,10 @@ class UploadPostInteractorTests: XCTestCase { sut.presenter = spy // when - await sut.fetchCurrentAddress() + await sut.fetchAddresses() // then - XCTAssertTrue(spy.presentCurrentAddressCalled, "fetchCurrentAddress 함수가 presentCurrentAddress을 호출하지 못함") + XCTAssertTrue(spy.presentCurrentAddressCalled, "fetchAddress 함수가 presentCurrentAddress을 호출하지 못함") } func test_fetchCurrentAddress를_호출하면_presenter에게_위치데이터를_전달한다() async throws { @@ -119,10 +123,10 @@ class UploadPostInteractorTests: XCTestCase { sut.presenter = spy // when - await sut.fetchCurrentAddress() + await sut.fetchAddresses() // then - XCTAssertNotNil(spy.presentCurrentAddressResponse, "fetchCurrentAddress 함수가 presenter에게 위치데이터를 전달하지 못함") + XCTAssertNotNil(spy.presentCurrentAddressResponse, "fetchAddress 함수가 presenter에게 위치데이터를 전달하지 못함") } func test_fetchThumbnailImage를_호출하면_presenter의_presentThumbnailImage가_호출된다() async { diff --git a/iOS/Layover/LayoverTests/Scenes/UploadPost/UploadPostPresenterTests.swift b/iOS/Layover/LayoverTests/Scenes/UploadPost/UploadPostPresenterTests.swift index f137e30..4e44de1 100644 --- a/iOS/Layover/LayoverTests/Scenes/UploadPost/UploadPostPresenterTests.swift +++ b/iOS/Layover/LayoverTests/Scenes/UploadPost/UploadPostPresenterTests.swift @@ -73,6 +73,11 @@ class UploadPostPresenterTests: XCTestCase { } + func displayActionSheet(viewModel: Layover.UploadPostModels.ShowActionSheet.ViewModel) { + // + } + + } // MARK: - Tests @@ -108,9 +113,10 @@ class UploadPostPresenterTests: XCTestCase { // given let spy = UploadPostDisplayLogicSpy() sut.viewController = spy - let response = Models.FetchCurrentAddress.Response(administrativeArea: nil, - locality: nil, - subLocality: nil) + let response = Models.FetchCurrentAddress.Response(addressInfo: [Models.AddressInfo( + administrativeArea: nil, + locality: nil, + subLocality: nil)]) // when sut.presentCurrentAddress(with: response)