diff --git a/iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/Environment.swift b/iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/Environment.swift index 1f3d867..5a5cb22 100644 --- a/iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/Environment.swift +++ b/iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/Environment.swift @@ -4,6 +4,6 @@ public extension Project { enum Environmnet { public static let workspace = "traveline" public static let deploymentTarget = DeploymentTarget.iOS(targetVersion: "16.0", devices: [.iphone]) - public static let bundleName = "kr.codesquad.boostcamp8.traveline" + public static let bundleName = "com.boostcamp8.traveline" } } diff --git a/iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift b/iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift index 6cdf241..3fef8af 100644 --- a/iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift +++ b/iOS/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift @@ -25,6 +25,7 @@ public extension Project { ] ] ], - "BaseURL": "$(BASE_URL)" + "ProdURL": "$(PROD_URL)", + "DevURL": "$(DEV_URL)" ] } diff --git a/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Contents.json b/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Contents.json new file mode 100644 index 0000000..b2a331d --- /dev/null +++ b/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Glyph_ undefined.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Glyph_ undefined 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Glyph_ undefined 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined 1.png b/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined 1.png new file mode 100644 index 0000000..75d5ea8 Binary files /dev/null and b/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined 1.png differ diff --git a/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined 2.png b/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined 2.png new file mode 100644 index 0000000..fe4d797 Binary files /dev/null and b/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined 2.png differ diff --git a/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined.png b/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined.png new file mode 100644 index 0000000..5f36c0d Binary files /dev/null and b/iOS/traveline/Resources/Images.xcassets/Common/empty.imageset/Glyph_ undefined.png differ diff --git a/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Contents.json b/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Contents.json new file mode 100644 index 0000000..b2a331d --- /dev/null +++ b/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Glyph_ undefined.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Glyph_ undefined 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Glyph_ undefined 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined 1.png b/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined 1.png new file mode 100644 index 0000000..c63e2fc Binary files /dev/null and b/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined 1.png differ diff --git a/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined 2.png b/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined 2.png new file mode 100644 index 0000000..489a23a Binary files /dev/null and b/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined 2.png differ diff --git a/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined.png b/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined.png new file mode 100644 index 0000000..e8eabc1 Binary files /dev/null and b/iOS/traveline/Resources/Images.xcassets/Common/errorCircle.imageset/Glyph_ undefined.png differ diff --git a/iOS/traveline/Sources/App/RootContainerVC.swift b/iOS/traveline/Sources/App/RootContainerVC.swift index 4592d20..0afadb7 100644 --- a/iOS/traveline/Sources/App/RootContainerVC.swift +++ b/iOS/traveline/Sources/App/RootContainerVC.swift @@ -175,6 +175,7 @@ extension RootContainerVC: SideMenuDelegate { switch menuItem { case .profileEdit: let profileEditingVC = VCFactory.makeProfileEditingVC() + profileEditingVC.delegate = self navigationVC?.pushViewController(profileEditingVC, animated: true) case .myPostList: let myPostListVC = VCFactory.makeMyPostListVC() @@ -210,3 +211,11 @@ extension RootContainerVC: UIGestureRecognizerDelegate { return false } } + +// MARK: - TimelineWriting Delegate + +extension RootContainerVC: ToastDelegate { + func viewControllerDidFinishAction(isSuccess: Bool, message: String) { + showToast(message: message, type: isSuccess ? .success : .failure) + } +} diff --git a/iOS/traveline/Sources/Core/Constant/Literal.swift b/iOS/traveline/Sources/Core/Constant/Literal.swift index d1761ee..610c9ff 100644 --- a/iOS/traveline/Sources/Core/Constant/Literal.swift +++ b/iOS/traveline/Sources/Core/Constant/Literal.swift @@ -13,7 +13,8 @@ enum Literal { static let boundary: String = "Boundary-\(UUID().uuidString)" enum InfoPlistKey { - static let baseURL: String = "BaseURL" + static let devURL: String = "DevURL" + static let prodURL: String = "ProdURL" } enum Tag { @@ -117,6 +118,7 @@ enum Literal { static let modify: String = "수정하기" static let delete: String = "삭제하기" static let report: String = "신고하기" + static let translate: String = "번역하기" } enum Query { @@ -200,4 +202,10 @@ enum Literal { static let drive: String = "자차" } } + + enum Setting { + static let termsOfServiceURL = "https://spiky-rat-16e.notion.site/b222abdf800e4a428a25582f2dc36290?pvs=4" + static let privacyPolicyURL = "https://spiky-rat-16e.notion.site/886a133ee0f9473a83d5f3ed8b877498?pvs=4" + static let openSourceLicenseURL = "https://spiky-rat-16e.notion.site/e8725bd989ea410390b6ef568324a439?pvs=4" + } } diff --git a/iOS/traveline/Sources/Core/Constant/UserDefaultsList.swift b/iOS/traveline/Sources/Core/Constant/UserDefaultsList.swift index 7cf7fa3..065a2de 100644 --- a/iOS/traveline/Sources/Core/Constant/UserDefaultsList.swift +++ b/iOS/traveline/Sources/Core/Constant/UserDefaultsList.swift @@ -11,4 +11,5 @@ import Foundation enum UserDefaultsList { @UserDefaultsWrapper(key: "userResponseDTO") static var userResponseDTO @UserDefaultsWrapper<[String]>(key: "recentSearchKeyword") static var recentSearchKeyword + @UserDefaultsWrapper(key: "isFirstEntry") static var isFirstEntry } diff --git a/iOS/traveline/Sources/Core/Delegate/ToastDelegate.swift b/iOS/traveline/Sources/Core/Delegate/ToastDelegate.swift new file mode 100644 index 0000000..978974d --- /dev/null +++ b/iOS/traveline/Sources/Core/Delegate/ToastDelegate.swift @@ -0,0 +1,13 @@ +// +// ToastDelegate.swift +// traveline +// +// Created by 김태현 on 1/24/24. +// Copyright © 2024 traveline. All rights reserved. +// + +import Foundation + +protocol ToastDelegate: AnyObject { + func viewControllerDidFinishAction(isSuccess: Bool, message: String) +} diff --git a/iOS/traveline/Sources/Core/Extension/Future+.swift b/iOS/traveline/Sources/Core/Extension/Future+.swift new file mode 100644 index 0000000..b51559e --- /dev/null +++ b/iOS/traveline/Sources/Core/Extension/Future+.swift @@ -0,0 +1,42 @@ +// +// Future+.swift +// traveline +// +// Created by 김태현 on 12/14/23. +// Copyright © 2023 traveline. All rights reserved. +// + +import Combine +import Foundation + +extension Future where Failure == Error { + + /// async 응답 결과를 Future publisher로 변환합니다. + /// - Parameter asyncFulfill: 변환할 async 응답 + convenience init(_ asyncFulfill: @escaping () async throws -> Output) { + self.init { promise in + Task { + do { + let result = try await asyncFulfill() + promise(.success(result)) + } catch { + promise(.failure(error)) + } + } + } + } +} + +extension Future where Failure == Never { + + /// async 응답 결과를 Future publisher로 변환합니다. + /// - Parameter asyncFulfill: 변환할 async 응답 + convenience init(_ asyncFulfill: @escaping () async -> Output) { + self.init { promise in + Task { + let result = await asyncFulfill() + promise(.success(result)) + } + } + } +} diff --git a/iOS/traveline/Sources/Core/Extension/UIViewController+.swift b/iOS/traveline/Sources/Core/Extension/UIViewController+.swift new file mode 100644 index 0000000..bc7c0af --- /dev/null +++ b/iOS/traveline/Sources/Core/Extension/UIViewController+.swift @@ -0,0 +1,24 @@ +// +// UIViewController+.swift +// traveline +// +// Created by 김태현 on 1/16/24. +// Copyright © 2024 traveline. All rights reserved. +// + +import UIKit + +extension UIViewController { + + /// 토스트 메세지를 노출합니다. + /// - Parameters: + /// - message: 노출할 메세지 + /// - type: 토스트 메세지 타입 (실패, 성공) + /// - followsUndockedKeyboard: 키보드 위치를 트래킹하려면 true로 설정 + func showToast(message: String, type: TLToastView.ToastType, followsUndockedKeyboard: Bool = false) { + let toast: TLToastView = .init(type: type, message: message, followsUndockedKeyboard: followsUndockedKeyboard) + view.keyboardLayoutGuide.followsUndockedKeyboard = followsUndockedKeyboard + toast.show(in: view) + } + +} diff --git a/iOS/traveline/Sources/Data/Network/API/TimelineDetailEndPoint.swift b/iOS/traveline/Sources/Data/Network/API/TimelineDetailEndPoint.swift index b7f234a..c475c8c 100644 --- a/iOS/traveline/Sources/Data/Network/API/TimelineDetailEndPoint.swift +++ b/iOS/traveline/Sources/Data/Network/API/TimelineDetailEndPoint.swift @@ -14,6 +14,7 @@ enum TimelineDetailEndPoint { case fetchPlaceList(String, Int) case putTimeline(String, TimelineDetailRequestDTO) case deleteTimeline(String) + case translateTimeline(String) } extension TimelineDetailEndPoint: EndPoint { @@ -34,6 +35,9 @@ extension TimelineDetailEndPoint: EndPoint { case .deleteTimeline(let id): return "\(curPath)/\(id)" + case .translateTimeline(let id): + return "\(curPath)/\(id)/translate" + default: return curPath } @@ -41,7 +45,7 @@ extension TimelineDetailEndPoint: EndPoint { var httpMethod: HTTPMethod { switch self { - case .specificTimeline, .fetchPlaceList: + case .specificTimeline, .fetchPlaceList, .translateTimeline: return .GET case .createTimeline: diff --git a/iOS/traveline/Sources/Data/Network/Base/EndPoint.swift b/iOS/traveline/Sources/Data/Network/Base/EndPoint.swift index e217d54..1f10d2d 100644 --- a/iOS/traveline/Sources/Data/Network/Base/EndPoint.swift +++ b/iOS/traveline/Sources/Data/Network/Base/EndPoint.swift @@ -19,7 +19,11 @@ protocol EndPoint { extension EndPoint { var baseURL: String? { - return Bundle.main.object(forInfoDictionaryKey: Literal.InfoPlistKey.baseURL) as? String + #if DEBUG + return Bundle.main.object(forInfoDictionaryKey: Literal.InfoPlistKey.devURL) as? String + #else + return Bundle.main.object(forInfoDictionaryKey: Literal.InfoPlistKey.prodURL) as? String + #endif } var body: Encodable? { diff --git a/iOS/traveline/Sources/Data/Network/DataMapping/TimelineDetailResponseDTO.swift b/iOS/traveline/Sources/Data/Network/DataMapping/TimelineDetailResponseDTO.swift index 98f01f1..7dbaa0d 100644 --- a/iOS/traveline/Sources/Data/Network/DataMapping/TimelineDetailResponseDTO.swift +++ b/iOS/traveline/Sources/Data/Network/DataMapping/TimelineDetailResponseDTO.swift @@ -18,7 +18,7 @@ struct TimelineDetailResponseDTO: Decodable { let coordX: Double? let coordY: Double? let date: String - let place: String + let place: String? let time: String let isOwner: Bool let posting: PostingID diff --git a/iOS/traveline/Sources/Data/Network/DataMapping/TimelineResponseDTO.swift b/iOS/traveline/Sources/Data/Network/DataMapping/TimelineResponseDTO.swift index aed7eca..bc0550e 100644 --- a/iOS/traveline/Sources/Data/Network/DataMapping/TimelineResponseDTO.swift +++ b/iOS/traveline/Sources/Data/Network/DataMapping/TimelineResponseDTO.swift @@ -22,7 +22,7 @@ struct TimelineResponseDTO: Decodable { let imagePath: String? let coordX: Double? let coordY: Double? - let place: String + let place: String? let time: String } diff --git a/iOS/traveline/Sources/Data/Network/DataMapping/TimelineTranslatedResponseDTO.swift b/iOS/traveline/Sources/Data/Network/DataMapping/TimelineTranslatedResponseDTO.swift new file mode 100644 index 0000000..240f3d6 --- /dev/null +++ b/iOS/traveline/Sources/Data/Network/DataMapping/TimelineTranslatedResponseDTO.swift @@ -0,0 +1,19 @@ +// +// TimelineTranslatedResponseDTO.swift +// traveline +// +// Created by 김태현 on 12/14/23. +// Copyright © 2023 traveline. All rights reserved. +// + +import Foundation + +struct TimelineTranslatedResponseDTO: Decodable { + let description: String +} + +extension TimelineTranslatedResponseDTO { + func toDomain() -> TimelineTranslatedInfo { + return .init(description: description) + } +} diff --git a/iOS/traveline/Sources/Data/Repository/Mock/TimelineDetailRepositoryMock.swift b/iOS/traveline/Sources/Data/Repository/Mock/TimelineDetailRepositoryMock.swift index 9d0ead7..049636f 100644 --- a/iOS/traveline/Sources/Data/Repository/Mock/TimelineDetailRepositoryMock.swift +++ b/iOS/traveline/Sources/Data/Repository/Mock/TimelineDetailRepositoryMock.swift @@ -39,4 +39,10 @@ final class TimelineDetailRepositoryMock: TimelineDetailRepository { return true } + + func fetchTimelineTranslatedInfo(id: String) async throws -> TimelineTranslatedInfo { + try await Task.sleep(nanoseconds: 1_000_000_000) + + return TimelineTranslatedInfo.empty + } } diff --git a/iOS/traveline/Sources/Data/Repository/TimelineDetailRepositoryImpl.swift b/iOS/traveline/Sources/Data/Repository/TimelineDetailRepositoryImpl.swift index 5db6111..4cb6222 100644 --- a/iOS/traveline/Sources/Data/Repository/TimelineDetailRepositoryImpl.swift +++ b/iOS/traveline/Sources/Data/Repository/TimelineDetailRepositoryImpl.swift @@ -55,4 +55,13 @@ final class TimelineDetailRepositoryImpl: TimelineDetailRepository { return deleteTimelineDTO } + func fetchTimelineTranslatedInfo(id: String) async throws -> TimelineTranslatedInfo { + let translateTimelineDTO = try await network.request( + endPoint: TimelineDetailEndPoint.translateTimeline(id), + type: TimelineTranslatedResponseDTO.self + ) + + return translateTimelineDTO.toDomain() + } + } diff --git a/iOS/traveline/Sources/DesignSystem/Common/TLImage.swift b/iOS/traveline/Sources/DesignSystem/Common/TLImage.swift index 5b52c10..3272b13 100644 --- a/iOS/traveline/Sources/DesignSystem/Common/TLImage.swift +++ b/iOS/traveline/Sources/DesignSystem/Common/TLImage.swift @@ -33,6 +33,8 @@ enum TLImage { static let close = TravelineAsset.Images.closeMedium.image static let logo = TravelineAsset.Images.travelineLogo.image static let`default` = TravelineAsset.Images.default.image + static let empty = TravelineAsset.Images.empty.image + static let errorCircle = TravelineAsset.Images.errorCircle.image } enum Travel { diff --git a/iOS/traveline/Sources/DesignSystem/View/TLEmptyView.swift b/iOS/traveline/Sources/DesignSystem/View/TLEmptyView.swift new file mode 100644 index 0000000..65dac97 --- /dev/null +++ b/iOS/traveline/Sources/DesignSystem/View/TLEmptyView.swift @@ -0,0 +1,142 @@ +// +// TLEmptyView.swift +// traveline +// +// Created by KiWoong Hong on 2024/01/12. +// Copyright © 2024 traveline. All rights reserved. +// + +import UIKit + +class TLEmptyView: UIView { + + enum EmptyViewType { + case search + case timeline + + var image: UIImage { + switch self { + case .search: + return TLImage.Common.errorCircle + case .timeline: + return TLImage.Common.empty + } + } + + var firstText: String { + switch self { + case .search: + return "검색 결과가 없어요!" + case .timeline: + return "아직 작성된 글이 없어요!" + } + } + + var secondText: String { + switch self { + case .search: + return "다른 키워드로 검색해보세요 :)" + case .timeline: + return "나만의 여행 경험을 공유해보세요 :)" + } + } + + var bottomConstants: CGFloat { + switch self { + case .search: + return UIScreen.main.bounds.width + case .timeline: + return UIScreen.main.bounds.width / 3 * 2 + } + } + } + + private enum Metric { + static let imageToLabelSpacing: CGFloat = 16 + static let labelToLabelSpacing: CGFloat = 12 + } + + // MARK: - UI Components + + private let stackView: UIStackView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.axis = .vertical + view.alignment = .center + view.distribution = .fill + + return view + }() + + private let imageView: UIImageView = { + let view = UIImageView() + view.translatesAutoresizingMaskIntoConstraints = false + + return view + }() + + private let firstLabel: TLLabel = { + let label = TLLabel(font: .subtitle2, color: TLColor.white) + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + private let secondLabel: TLLabel = { + let label = TLLabel(font: .body2, color: TLColor.white) + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + // MARK: - properties + + private let emptyViewType: EmptyViewType + + // MARK: - initialize + + init(type: EmptyViewType) { + self.emptyViewType = type + super.init(frame: .zero) + + setupAttributes() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Setup Functions + +private extension TLEmptyView { + + func setupAttributes() { + backgroundColor = TLColor.black + imageView.image = emptyViewType.image + firstLabel.setText(to: emptyViewType.firstText) + secondLabel.setText(to: emptyViewType.secondText) + } + + func setupLayout() { + addSubview(stackView) + stackView.addArrangedSubviews( + imageView, + firstLabel, + secondLabel + ) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: imageView.topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -emptyViewType.bottomConstants) + ]) + + stackView.setCustomSpacing(Metric.imageToLabelSpacing, after: imageView) + stackView.setCustomSpacing(Metric.labelToLabelSpacing, after: firstLabel) + } + +} diff --git a/iOS/traveline/Sources/DesignSystem/View/TLList/TLInfoView.swift b/iOS/traveline/Sources/DesignSystem/View/TLList/TLInfoView.swift index 320e7ba..57b97a4 100644 --- a/iOS/traveline/Sources/DesignSystem/View/TLList/TLInfoView.swift +++ b/iOS/traveline/Sources/DesignSystem/View/TLList/TLInfoView.swift @@ -39,6 +39,7 @@ final class TLInfoView: UIView { imageView.backgroundColor = TLColor.gray imageView.layer.cornerRadius = Metric.radius imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill return imageView }() @@ -62,6 +63,7 @@ final class TLInfoView: UIView { imageView.backgroundColor = TLColor.gray imageView.layer.cornerRadius = Metric.profileImageSize / 2 imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill return imageView }() diff --git a/iOS/traveline/Sources/DesignSystem/View/TLToastView.swift b/iOS/traveline/Sources/DesignSystem/View/TLToastView.swift index 4eca813..e3e30df 100644 --- a/iOS/traveline/Sources/DesignSystem/View/TLToastView.swift +++ b/iOS/traveline/Sources/DesignSystem/View/TLToastView.swift @@ -37,12 +37,14 @@ final class TLToastView: UIView { private let toastType: ToastType private var message: String + var followsUndockedKeyboard: Bool // MARK: - initialize - init(type: ToastType = .success, message: String = "") { + init(type: ToastType = .success, message: String = "", followsUndockedKeyboard: Bool = false) { self.toastType = type self.message = message + self.followsUndockedKeyboard = followsUndockedKeyboard super.init(frame: .zero) setupAttributes() @@ -65,7 +67,10 @@ final class TLToastView: UIView { translatesAutoresizingMaskIntoConstraints = false alpha = 1.0 NSLayoutConstraint.activate([ - bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -24), + bottomAnchor.constraint( + equalTo: followsUndockedKeyboard ? view.keyboardLayoutGuide.topAnchor : view.safeAreaLayoutGuide.bottomAnchor, + constant: -8 + ), leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metric.margin), trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metric.margin), heightAnchor.constraint(equalToConstant: Metric.toastHeight) @@ -99,9 +104,8 @@ private extension TLToastView { NSLayoutConstraint.activate([ contentLabel.centerXAnchor.constraint(equalTo: centerXAnchor), - contentLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + contentLabel.centerYAnchor.constraint(equalTo: centerYAnchor) ]) } } - diff --git a/iOS/traveline/Sources/Domain/Model/Timeline/TimelineCardInfo.swift b/iOS/traveline/Sources/Domain/Model/Timeline/TimelineCardInfo.swift index 62e55c8..ca11e65 100644 --- a/iOS/traveline/Sources/Domain/Model/Timeline/TimelineCardInfo.swift +++ b/iOS/traveline/Sources/Domain/Model/Timeline/TimelineCardInfo.swift @@ -15,7 +15,7 @@ struct TimelineCardInfo: Hashable { let thumbnailURL: String? let imagePath: String? let title: String - let place: String + let place: String? let content: String let time: String let latitude: Double? diff --git a/iOS/traveline/Sources/Domain/Model/Timeline/TimelineDetailInfo.swift b/iOS/traveline/Sources/Domain/Model/Timeline/TimelineDetailInfo.swift index 21140bb..23b8dc9 100644 --- a/iOS/traveline/Sources/Domain/Model/Timeline/TimelineDetailInfo.swift +++ b/iOS/traveline/Sources/Domain/Model/Timeline/TimelineDetailInfo.swift @@ -19,7 +19,7 @@ struct TimelineDetailInfo: Hashable { let coordX: Double? let coordY: Double? let date: String - let location: String + let location: String? let time: String let isOwner: Bool diff --git a/iOS/traveline/Sources/Domain/Model/Timeline/TimelineTranslatedInfo.swift b/iOS/traveline/Sources/Domain/Model/Timeline/TimelineTranslatedInfo.swift new file mode 100644 index 0000000..93c9acd --- /dev/null +++ b/iOS/traveline/Sources/Domain/Model/Timeline/TimelineTranslatedInfo.swift @@ -0,0 +1,15 @@ +// +// TimelineTranslatedInfo.swift +// traveline +// +// Created by 김태현 on 12/14/23. +// Copyright © 2023 traveline. All rights reserved. +// + +import Foundation + +struct TimelineTranslatedInfo { + let description: String + + static let empty: TimelineTranslatedInfo = .init(description: Literal.empty) +} diff --git a/iOS/traveline/Sources/Domain/RepositoryInterface/TimelineDetailRepository.swift b/iOS/traveline/Sources/Domain/RepositoryInterface/TimelineDetailRepository.swift index 0f479f1..3cdfc50 100644 --- a/iOS/traveline/Sources/Domain/RepositoryInterface/TimelineDetailRepository.swift +++ b/iOS/traveline/Sources/Domain/RepositoryInterface/TimelineDetailRepository.swift @@ -14,4 +14,5 @@ protocol TimelineDetailRepository { func fetchTimelinePlaces(keyword: String, offset: Int) async throws -> TimelinePlaceList func putTimeline(id: String, info: TimelineDetailRequest) async throws -> Bool func deleteTimeline(id: String) async throws -> Bool + func fetchTimelineTranslatedInfo(id: String) async throws -> TimelineTranslatedInfo } diff --git a/iOS/traveline/Sources/Domain/UseCase/AutoLoginUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/AutoLoginUseCase.swift index 9eb1dfa..96068f7 100644 --- a/iOS/traveline/Sources/Domain/UseCase/AutoLoginUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/AutoLoginUseCase.swift @@ -22,16 +22,18 @@ final class AutoLoginUseCaseImpl: AutoLoginUseCase { } func requestLogin() -> AnyPublisher { - return Future { promise in - Task { - do { - let accessToken = try await self.repository.refresh() - KeychainList.accessToken = accessToken - promise(.success(true)) - } catch { - promise(.failure(error)) - } - } + guard let isFirstEntry = UserDefaultsList.isFirstEntry, + !isFirstEntry else { + return Just(false) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return Future { + let accessToken = try await self.repository.refresh() + KeychainList.accessToken = accessToken + return true }.eraseToAnyPublisher() } + } diff --git a/iOS/traveline/Sources/Domain/UseCase/HomeUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/HomeUseCase.swift index 8c84de3..91beace 100644 --- a/iOS/traveline/Sources/Domain/UseCase/HomeUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/HomeUseCase.swift @@ -26,16 +26,9 @@ final class HomeUseCaseImpl: HomeUseCase { } func fetchSearchList(with query: SearchQuery) -> AnyPublisher { - return Future { promise in - Task { [weak self] in - guard let self else { return } - do { - let travelList = try await self.repository.fetchPostingList(with: query) - promise(.success(travelList)) - } catch { - promise(.failure(error)) - } - } + return Future { + let travelList = try await self.repository.fetchPostingList(with: query) + return travelList }.eraseToAnyPublisher() } @@ -68,16 +61,9 @@ final class HomeUseCaseImpl: HomeUseCase { } func fetchRelatedKeyword(_ keyword: String) -> AnyPublisher { - return Future { promise in - Task { [weak self] in - guard let self else { return } - do { - let relatedKeyword = try await self.repository.fetchPostingTitleList(keyword) - promise(.success(relatedKeyword)) - } catch { - promise(.failure(error)) - } - } + return Future { + let relatedKeyword = try await self.repository.fetchPostingTitleList(keyword) + return relatedKeyword }.eraseToAnyPublisher() } diff --git a/iOS/traveline/Sources/Domain/UseCase/LoginUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/LoginUseCase.swift index 3c5d351..5ec2d98 100644 --- a/iOS/traveline/Sources/Domain/UseCase/LoginUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/LoginUseCase.swift @@ -22,17 +22,14 @@ final class LoginUseCaseImpl: LoginUseCase { } func requestLogin(with info: AppleLoginRequest) -> AnyPublisher { - return Future { promise in - Task { - do { - let tlToken = try await self.repository.appleLogin(with: info) - KeychainList.accessToken = tlToken.accessToken - KeychainList.refreshToken = tlToken.refreshToken - promise(.success(true)) - } catch { - promise(.failure(error)) - } - } + return Future { + UserDefaultsList.userResponseDTO = nil + let tlToken = try await self.repository.appleLogin(with: info) + KeychainList.accessToken = tlToken.accessToken + KeychainList.refreshToken = tlToken.refreshToken + UserDefaultsList.isFirstEntry = false + return true }.eraseToAnyPublisher() } + } diff --git a/iOS/traveline/Sources/Domain/UseCase/MyPostingListUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/MyPostingListUseCase.swift index 8932708..bc3cd05 100644 --- a/iOS/traveline/Sources/Domain/UseCase/MyPostingListUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/MyPostingListUseCase.swift @@ -22,16 +22,9 @@ final class MyPostListUseCaseImpl: MyPostListUseCase { } func fetchMyPostList() -> AnyPublisher { - return Future { promise in - Task { [weak self] in - guard let self else { return } - do { - let travelList = try await self.repository.fetchMyPostingList() - promise(.success(travelList)) - } catch { - promise(.failure(error)) - } - } + return Future { + let travelList = try await self.repository.fetchMyPostingList() + return travelList }.eraseToAnyPublisher() } diff --git a/iOS/traveline/Sources/Domain/UseCase/ProfileEditingUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/ProfileEditingUseCase.swift index b1be020..a81932c 100644 --- a/iOS/traveline/Sources/Domain/UseCase/ProfileEditingUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/ProfileEditingUseCase.swift @@ -33,38 +33,20 @@ final class ProfileEditingUseCaseImpl: ProfileEditingUseCase { } func fetchProfile() -> AnyPublisher { - return Future { promise in - Task { [weak self] in - guard let self else { return } - do { - profile = try await self.repository.fetchUserInfo() - promise(.success(profile)) - } catch { - promise(.failure(error)) - } - } + return Future { + self.profile = try await self.repository.fetchUserInfo() + return self.profile }.eraseToAnyPublisher() } func validate(nickname: String) -> AnyPublisher { - return Future { promise in - Task { - guard self.isTooShort(nickname) == false else { - return promise(.success(.tooShort)) - } - guard self.isValidStringLength(nickname) else { - return promise(.success(.exceededStringLength)) - } - guard self.isChanged(nickname) else { - return promise(.success(.unchanged)) - } - do { - let isDuplicated = try await self.repository.checkDuplication(name: nickname) - promise(isDuplicated ? .success(.duplicated) : .success(.available)) - } catch { - promise(.failure(error)) - } - } + return Future { + guard self.isTooShort(nickname) == false else { return .tooShort } + guard self.isValidStringLength(nickname) else { return .exceededStringLength } + guard self.isChanged(nickname) else { return .unchanged } + + let isDuplicated = try await self.repository.checkDuplication(name: nickname) + return isDuplicated ? .duplicated : .available }.eraseToAnyPublisher() } @@ -80,16 +62,10 @@ final class ProfileEditingUseCaseImpl: ProfileEditingUseCase { } func update(name: String, imageData: Data?) -> AnyPublisher { - return Future { promise in - Task { [weak self] in - guard let self else { return } - do { - let profile = try await self.repository.updateUserInfo(name: name, imageData: imageData) - promise(.success(profile)) - } catch { - promise(.failure(error)) - } - } + return Future { + let profile = try await self.repository.updateUserInfo(name: name, imageData: imageData) + return profile }.eraseToAnyPublisher() } + } diff --git a/iOS/traveline/Sources/Domain/UseCase/SettingUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/SettingUseCase.swift index 6f36bd2..dbb03c5 100644 --- a/iOS/traveline/Sources/Domain/UseCase/SettingUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/SettingUseCase.swift @@ -31,17 +31,11 @@ final class SettingUseCaseImpl: SettingUseCase { return repository.requestAppleId() } - func requestWithdrawal(_ reqeust: WithdrawRequest) -> AnyPublisher { - return Future { promise in - Task { - do { - let result = try await self.repository.withdrawal(reqeust) - KeychainList.allClear() - promise(.success(result)) - } catch { - promise(.failure(error)) - } - } + func requestWithdrawal(_ request: WithdrawRequest) -> AnyPublisher { + return Future { + let result = try await self.repository.withdrawal(request) + KeychainList.allClear() + return result }.eraseToAnyPublisher() } diff --git a/iOS/traveline/Sources/Domain/UseCase/SideMenuUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/SideMenuUseCase.swift index 876d26b..d1744a8 100644 --- a/iOS/traveline/Sources/Domain/UseCase/SideMenuUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/SideMenuUseCase.swift @@ -22,15 +22,9 @@ final class SideMenuUseCaseImpl: SideMenuUseCase { } func fetchProfile() -> AnyPublisher { - return Future { promise in - Task { - do { - let profile = try await self.repository.fetchUserInfo() - promise(.success(profile)) - } catch { - promise(.failure(error)) - } - } + return Future { + let profile = try await self.repository.fetchUserInfo() + return profile }.eraseToAnyPublisher() } diff --git a/iOS/traveline/Sources/Domain/UseCase/TimelineDetailUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/TimelineDetailUseCase.swift index a7cdaed..817b567 100644 --- a/iOS/traveline/Sources/Domain/UseCase/TimelineDetailUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/TimelineDetailUseCase.swift @@ -12,6 +12,7 @@ import Foundation protocol TimelineDetailUseCase { func fetchTimelineDetail(with id: String) -> AnyPublisher func deleteTimeline(id: String) -> AnyPublisher + func fetchTranslateTimelineDetail(with id: String) -> AnyPublisher } final class TimelineDetailUseCaseImpl: TimelineDetailUseCase { @@ -23,29 +24,23 @@ final class TimelineDetailUseCaseImpl: TimelineDetailUseCase { } func fetchTimelineDetail(with id: String) -> AnyPublisher { - return Future { promise in - Task { [weak self] in - guard let self else { return } - do { - let detailInfo = try await self.repository.fetchTimelineDetailInfo(id: id) - promise(.success(detailInfo)) - } catch { - promise(.failure(error)) - } - } + return Future { + let detailInfo = try await self.repository.fetchTimelineDetailInfo(id: id) + return detailInfo }.eraseToAnyPublisher() } func deleteTimeline(id: String) -> AnyPublisher { - return Future { promise in - Task { - do { - let result = try await self.repository.deleteTimeline(id: id) - promise(.success(result)) - } catch { - promise(.failure(error)) - } - } + return Future { + let result = try await self.repository.deleteTimeline(id: id) + return result + }.eraseToAnyPublisher() + } + + func fetchTranslateTimelineDetail(with id: String) -> AnyPublisher { + return Future { + let translatedInfo = try await self.repository.fetchTimelineTranslatedInfo(id: id) + return translatedInfo }.eraseToAnyPublisher() } } diff --git a/iOS/traveline/Sources/Domain/UseCase/TimelineUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/TimelineUseCase.swift index 8f66de4..e18b0c4 100644 --- a/iOS/traveline/Sources/Domain/UseCase/TimelineUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/TimelineUseCase.swift @@ -29,30 +29,17 @@ final class TimelineUseCaseImpl: TimelineUseCase { } func fetchTimelineInfo(id: TravelID) -> AnyPublisher { - return Future { promise in - Task { - do { - let travelInfo = try await self.postingRepository.fetchTimelineInfo(id: id) - promise(.success(travelInfo)) - } catch { - promise(.failure(error)) - } - } + return Future { + let travelInfo = try await self.postingRepository.fetchTimelineInfo(id: id) + return travelInfo }.eraseToAnyPublisher() } func fetchTimelineList(id: TravelID, day: Int) -> AnyPublisher { - return Future { promise in - Task { - do { - let timelineList = try await self.timelineRepository.fetchTimelineList(id: id, day: day) - promise(.success(timelineList)) - } catch { - promise(.failure(error)) - } - } - } - .eraseToAnyPublisher() + return Future { + let timelineList = try await self.timelineRepository.fetchTimelineList(id: id, day: day) + return timelineList + }.eraseToAnyPublisher() } func calculateDate(from startDate: String, with day: Int) -> String? { @@ -64,41 +51,23 @@ final class TimelineUseCaseImpl: TimelineUseCase { } func deleteTravel(id: TravelID) -> AnyPublisher { - return Future { promise in - Task { - do { - let result = try await self.postingRepository.deletePosting(id: id) - promise(.success(result)) - } catch { - promise(.failure(error)) - } - } + return Future { + let result = try await self.postingRepository.deletePosting(id: id) + return result }.eraseToAnyPublisher() } func reportTravel(id: TravelID) -> AnyPublisher { - return Future { promise in - Task { - do { - let result = try await self.postingRepository.postReport(id: id) - promise(.success(result)) - } catch { - promise(.failure(error)) - } - } + return Future { + let result = try await self.postingRepository.postReport(id: id) + return result }.eraseToAnyPublisher() } func likeTravel(id: TravelID) -> AnyPublisher { - return Future { promise in - Task { - do { - let result = try await self.postingRepository.postLike(id: id) - promise(.success(result)) - } catch { - promise(.failure(error)) - } - } + return Future { + let result = try await self.postingRepository.postLike(id: id) + return result }.eraseToAnyPublisher() } diff --git a/iOS/traveline/Sources/Domain/UseCase/TimelineWritingUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/TimelineWritingUseCase.swift index 2a44dba..a99cee8 100644 --- a/iOS/traveline/Sources/Domain/UseCase/TimelineWritingUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/TimelineWritingUseCase.swift @@ -25,15 +25,8 @@ final class TimelineWritingUseCaseImpl: TimelineWritingUseCase { } func requestCreateTimeline(with info: TimelineDetailRequest) -> AnyPublisher { - return Future { promise in - Task { - do { - try await self.repository.createTimelineDetail(with: info) - promise(.success(Void())) - } catch { - promise(.failure(error)) - } - } + return Future { + try await self.repository.createTimelineDetail(with: info) }.eraseToAnyPublisher() } @@ -42,28 +35,16 @@ final class TimelineWritingUseCaseImpl: TimelineWritingUseCase { return .just([]).setFailureType(to: Error.self).eraseToAnyPublisher() } - return Future { promise in - Task { - do { - let placeList = try await self.repository.fetchTimelinePlaces(keyword: keyword, offset: offset) - promise(.success(placeList)) - } catch { - promise(.failure(error)) - } - } + return Future { + let placeList = try await self.repository.fetchTimelinePlaces(keyword: keyword, offset: offset) + return placeList }.eraseToAnyPublisher() } func putTimeline(id: String, info: TimelineDetailRequest) -> AnyPublisher { - return Future { promise in - Task { - do { - let result = try await self.repository.putTimeline(id: id, info: info) - promise(.success(result)) - } catch { - promise(.failure(error)) - } - } + return Future { + let result = try await self.repository.putTimeline(id: id, info: info) + return result }.eraseToAnyPublisher() } @@ -74,7 +55,7 @@ final class TimelineWritingUseCaseImpl: TimelineWritingUseCase { time: info.time, date: info.date, place: .init( - title: info.location, + title: info.location ?? Literal.empty, address: Literal.empty, latitude: info.coordY ?? 0.0, longitude: info.coordX ?? 0.0 diff --git a/iOS/traveline/Sources/Domain/UseCase/TravelUseCase.swift b/iOS/traveline/Sources/Domain/UseCase/TravelUseCase.swift index 170d32b..d7d6da4 100644 --- a/iOS/traveline/Sources/Domain/UseCase/TravelUseCase.swift +++ b/iOS/traveline/Sources/Domain/UseCase/TravelUseCase.swift @@ -44,31 +44,16 @@ final class TravelUseCaseImpl: TravelUseCase { } func createTravel(data: TravelRequest) -> AnyPublisher { - return Future { promise in - Task { - do { - let id = try await self.repository.postPosting(data: data) - promise(.success(id)) - } catch { - promise(.failure(error)) - } - } + return Future { + let id = try await self.repository.postPosting(data: data) + return id }.eraseToAnyPublisher() } func putTravel(id: TravelID, data: TravelRequest) -> AnyPublisher { - return Future { promise in - Task { - do { - let id = try await self.repository.putPosting( - id: id, - data: data - ) - promise(.success(id)) - } catch { - promise(.failure(error)) - } - } + return Future { + let id = try await self.repository.putPosting(id: id, data: data) + return id }.eraseToAnyPublisher() } diff --git a/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/VC/HomeVC.swift b/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/VC/HomeVC.swift index 6e9dcf9..6d30b03 100644 --- a/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/VC/HomeVC.swift +++ b/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/VC/HomeVC.swift @@ -113,6 +113,7 @@ private extension HomeVC { self.navigationItem.backBarButtonItem = backBarButtonItem homeSearchView.isHidden = true + homeListView.hideEmptyView() } func setupLayout() { @@ -156,7 +157,6 @@ private extension HomeVC { viewModel.state .map(\.travelList) - .dropFirst() .removeDuplicates() .withUnretained(self) .sink { owner, list in @@ -249,6 +249,18 @@ private extension HomeVC { owner.navigationController?.pushViewController(travelVC, animated: true) } .store(in: &cancellables) + + viewModel.state + .map(\.isEmptyResult) + .withUnretained(self) + .sink { owner, isEmpty in + if isEmpty { + owner.homeListView.showEmptyView() + } else { + owner.homeListView.hideEmptyView() + } + } + .store(in: &cancellables) } func bindListView() { @@ -257,6 +269,7 @@ private extension HomeVC { .sink { owner, idx in let id = owner.viewModel.currentState.travelList[idx].id let timelineVC = VCFactory.makeTimelineVC(id: TravelID(value: id)) + timelineVC.delegate = owner owner.navigationController?.pushViewController( timelineVC, animated: true @@ -342,6 +355,14 @@ extension HomeVC: TLBottomSheetDelegate { } } +// MARK: - Timeline Delegate + +extension HomeVC: ToastDelegate { + func viewControllerDidFinishAction(isSuccess: Bool, message: String) { + showToast(message: message, type: isSuccess ? .success : .failure) + } +} + @available(iOS 17, *) #Preview("HomeVC") { let homeVC = VCFactory.makeHomeVC() diff --git a/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/View/HomeListView.swift b/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/View/HomeListView.swift index 0cd7e35..849462f 100644 --- a/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/View/HomeListView.swift +++ b/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/View/HomeListView.swift @@ -66,6 +66,8 @@ final class HomeListView: UIView { return refresh }() + private let emptyView: TLEmptyView = .init(type: .search) + // MARK: - Properties private typealias DataSource = UICollectionViewDiffableDataSource @@ -94,6 +96,7 @@ final class HomeListView: UIView { setupLayout() setupDataSource() + setupAttributes() setupSnapshot() } @@ -224,6 +227,14 @@ final class HomeListView: UIView { } } + func hideEmptyView() { + homeCollectionView.backgroundView?.isHidden = true + } + + func showEmptyView() { + homeCollectionView.backgroundView?.isHidden = false + } + @objc private func refreshList() { didRefreshHomeList.send(Void()) } @@ -232,6 +243,11 @@ final class HomeListView: UIView { // MARK: - Setup Functions extension HomeListView { + private func setupAttributes() { + homeCollectionView.backgroundView = emptyView + homeCollectionView.backgroundView?.isHidden = true + } + private func setupLayout() { addSubviews(homeCollectionView) diff --git a/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/ViewModel/HomeState.swift b/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/ViewModel/HomeState.swift index f890b40..7f46da4 100644 --- a/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/ViewModel/HomeState.swift +++ b/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/ViewModel/HomeState.swift @@ -27,6 +27,7 @@ struct HomeState: BaseState { var resultFilters: FilterDictionary = .make() var curFilter: Filter? = .emtpy var moveToTravelWriting: Bool = false + var isEmptyResult: Bool = false var isSearching: Bool { homeViewType == .recent || homeViewType == .related diff --git a/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/ViewModel/HomeViewModel.swift b/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/ViewModel/HomeViewModel.swift index 6851543..853f57d 100644 --- a/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/ViewModel/HomeViewModel.swift +++ b/iOS/traveline/Sources/Feature/HomeFeature/HomeScene/ViewModel/HomeViewModel.swift @@ -90,6 +90,7 @@ final class HomeViewModel: BaseViewModel newState.travelList = searchResult.travelList newState.homeViewType = .result newState.resultFilters = .make() + newState.isEmptyResult = searchResult.travelList.isEmpty newState.searchQuery = .init( keyword: searchResult.keyword, offset: 2 @@ -99,10 +100,12 @@ final class HomeViewModel: BaseViewModel newState.travelList = travelList newState.searchQuery.offset = 2 newState.searchQuery.keyword = nil + newState.isEmptyResult = travelList.isEmpty case let .showNewList(travelList): newState.travelList = travelList newState.searchQuery.offset = 2 + newState.isEmptyResult = travelList.isEmpty case let .showFilter(type): newState.curFilter = (state.homeViewType == .home) ? state.homeFilters[type] : state.resultFilters[type] diff --git a/iOS/traveline/Sources/Feature/MyPageFeature/ProfileEditingScene/VC/ProfileEditingVC.swift b/iOS/traveline/Sources/Feature/MyPageFeature/ProfileEditingScene/VC/ProfileEditingVC.swift index b9a290e..daac12b 100644 --- a/iOS/traveline/Sources/Feature/MyPageFeature/ProfileEditingScene/VC/ProfileEditingVC.swift +++ b/iOS/traveline/Sources/Feature/MyPageFeature/ProfileEditingScene/VC/ProfileEditingVC.swift @@ -33,6 +33,8 @@ final class ProfileEditingVC: UIViewController { static let selectBaseImage: String = "기본 이미지" static let selectInAlbum: String = "앨범에서 선택" static let close: String = "닫기" + static let didFinishEditProfileWithSuccess: String = "프로필 수정을 완료했어요 !" + static let didFinishEditProfileWithFailure: String = "프로필 수정에 실패했어요." } // MARK: - UI Components @@ -45,6 +47,7 @@ final class ProfileEditingVC: UIViewController { view.layer.cornerRadius = Metric.imageWidth / 2 view.backgroundColor = TLColor.backgroundGray view.clipsToBounds = true + view.contentMode = .scaleAspectFill return view }() @@ -92,6 +95,7 @@ final class ProfileEditingVC: UIViewController { private var cancellables: Set = .init() private let viewModel: ProfileEditingViewModel + weak var delegate: ToastDelegate? // MARK: - Initialize @@ -245,6 +249,20 @@ extension ProfileEditingVC { owner.imageView.setImage(from: profile.imageURL, imagePath: profile.imagePath) } .store(in: &cancellables) + + viewModel.state + .map(\.isSuccessEditProfile) + .removeDuplicates() + .dropFirst() + .withUnretained(self) + .sink { owner, isSuccess in + owner.navigationController?.popViewController(animated: true) + owner.delegate?.viewControllerDidFinishAction( + isSuccess: isSuccess, + message: isSuccess ? Constants.didFinishEditProfileWithSuccess : Constants.didFinishEditProfileWithFailure + ) + } + .store(in: &cancellables) } } @@ -277,6 +295,5 @@ extension ProfileEditingVC: PHPickerViewControllerDelegate { extension ProfileEditingVC: TLNavigationBarDelegate { func rightButtonDidTapped() { viewModel.sendAction(.tapCompleteButton(imageView.image?.jpegData(compressionQuality: 1))) - self.navigationController?.popViewController(animated: true) } } diff --git a/iOS/traveline/Sources/Feature/MyPageFeature/ProfileEditingScene/ViewModel/ProfileEditingViewModel.swift b/iOS/traveline/Sources/Feature/MyPageFeature/ProfileEditingScene/ViewModel/ProfileEditingViewModel.swift index 9beaad6..f017fbb 100644 --- a/iOS/traveline/Sources/Feature/MyPageFeature/ProfileEditingScene/ViewModel/ProfileEditingViewModel.swift +++ b/iOS/traveline/Sources/Feature/MyPageFeature/ProfileEditingScene/ViewModel/ProfileEditingViewModel.swift @@ -26,10 +26,10 @@ enum ProfileEditingSideEffect: BaseSideEffect { } struct ProfileEditingState: BaseState { - var isCompletable: Bool = false var profile: Profile = .empty var caption: CaptionOptions = .init(validateType: .unchanged) + var isSuccessEditProfile: Bool = false } struct CaptionOptions { @@ -89,15 +89,15 @@ final class ProfileEditingViewModel: BaseViewModel = .init() private let viewModel: TimelineDetailViewModel + weak var delegate: ToastDelegate? // MARK: - Initialize @@ -98,7 +104,12 @@ final class TimelineDetailVC: UIViewController { titleLabel.setText(to: info.title) dateLabel.setText(to: info.date) timeLabel.setText(to: info.time) - locationLabel.setText(to: info.location) + if let location = info.location { + locationLabel.setText(to: location) + locationLabel.isHidden = false + } else { + locationLabel.isHidden = true + } contentView.setText(to: info.description) guard let url = info.imageURL else { imageView.isHidden = true @@ -113,6 +124,9 @@ final class TimelineDetailVC: UIViewController { if isOwner { menuItems = [ + .init(title: Literal.Action.translate, handler: { [weak self] _ in + self?.viewModel.sendAction(.translateTimeline) + }), .init(title: Literal.Action.modify, handler: { [weak self] _ in self?.viewModel.sendAction(.editTimeline) }), @@ -120,6 +134,12 @@ final class TimelineDetailVC: UIViewController { self?.viewModel.sendAction(.deleteTimeline) }) ] + } else { + menuItems = [ + .init(title: Literal.Action.translate, handler: { [weak self] _ in + self?.viewModel.sendAction(.translateTimeline) + }) + ] } tlNavigationBar.addRightButton( @@ -216,17 +236,21 @@ private extension TimelineDetailVC { viewModel.state .map(\.isDeleteCompleted) - .filter { $0 } + .removeDuplicates() + .dropFirst() .withUnretained(self) - .sink { owner, _ in + .sink { owner, isSuccess in owner.navigationController?.popViewController(animated: true) + owner.delegate?.viewControllerDidFinishAction( + isSuccess: isSuccess, + message: isSuccess ? Constants.didFinishDeleteWithSuccess : Constants.didFinishDeleteWithFailure + ) } .store(in: &cancellables) viewModel.state .map(\.isEdit) .filter { $0 } - .removeDuplicates() .withUnretained(self) .sink { owner, _ in let timelineDetailInfo = owner.viewModel.currentState.timelineDetailInfo @@ -236,8 +260,30 @@ private extension TimelineDetailVC { day: timelineDetailInfo.day, timelineDetailInfo: timelineDetailInfo ) + timelineEditVC.delegate = owner owner.navigationController?.pushViewController(timelineEditVC, animated: true) + owner.viewModel.sendAction(.movedToEdit) + } + .store(in: &cancellables) + + viewModel.state + .map(\.isTranslated) + .dropFirst() + .withUnretained(self) + .sink { owner, isTranslated in + let description = isTranslated + ? owner.viewModel.currentState.timelineTranslatedInfo.description + : owner.viewModel.currentState.timelineDetailInfo.description + owner.contentView.setText(to: description) } .store(in: &cancellables) } } + +// MARK: - TimelineWriting Delegate + +extension TimelineDetailVC: ToastDelegate { + func viewControllerDidFinishAction(isSuccess: Bool, message: String) { + showToast(message: message, type: isSuccess ? .success : .failure) + } +} diff --git a/iOS/traveline/Sources/Feature/TimelineDetailFeature/TimelineDetailScene/ViewModel/TimelineDetailViewModel.swift b/iOS/traveline/Sources/Feature/TimelineDetailFeature/TimelineDetailScene/ViewModel/TimelineDetailViewModel.swift index 108f85c..13ce77d 100644 --- a/iOS/traveline/Sources/Feature/TimelineDetailFeature/TimelineDetailScene/ViewModel/TimelineDetailViewModel.swift +++ b/iOS/traveline/Sources/Feature/TimelineDetailFeature/TimelineDetailScene/ViewModel/TimelineDetailViewModel.swift @@ -13,6 +13,8 @@ enum TimelineDetailAction: BaseAction { case viewWillAppear case editTimeline case deleteTimeline + case translateTimeline + case movedToEdit } enum TimelineDetailSideEffect: BaseSideEffect { @@ -32,13 +34,17 @@ enum TimelineDetailSideEffect: BaseSideEffect { case timelineDetailError(TimelineDetailError) case popToTimeline(Bool) case showTimelineDetailEditing + case loadTimelineTranslatedInfo(TimelineTranslatedInfo) + case resetIsEditStatus } struct TimelineDetailState: BaseState { var timelineDetailInfo: TimelineDetailInfo = .empty + var timelineTranslatedInfo: TimelineTranslatedInfo = .empty var isOwner: Bool = false var isDeleteCompleted: Bool = false var isEdit: Bool = false + var isTranslated: Bool = false } final class TimelineDetailViewModel: BaseViewModel { @@ -61,6 +67,12 @@ final class TimelineDetailViewModel: BaseViewModel SideEffectPublisher { + return timelineDetailUseCase.fetchTranslateTimelineDetail(with: id) + .map { translatedInfo in + return .loadTimelineTranslatedInfo(translatedInfo) + } + .catch { _ in + return Just(TimelineDetailSideEffect.timelineDetailError(.deleteFailed)) + } + .eraseToAnyPublisher() + } } diff --git a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/VC/TimelineVC.swift b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/VC/TimelineVC.swift index b77df95..6d15bda 100644 --- a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/VC/TimelineVC.swift +++ b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/VC/TimelineVC.swift @@ -22,6 +22,11 @@ final class TimelineVC: UIViewController { } } + private enum Constants { + static let didFinishDeleteWithSuccess: String = "여행 삭제를 완료했어요 !" + static let didFinishDeleteWithFailure: String = "여행 삭제에 실패했어요." + } + private enum TimelineSection: Int { case travelInfo case timeline @@ -52,6 +57,8 @@ final class TimelineVC: UIViewController { return collectionView }() + private let emptyView: TLEmptyView = .init(type: .timeline) + private let createPostingButton: TLFloatingButton = .init(style: .create) // MARK: - Properties @@ -63,6 +70,7 @@ final class TimelineVC: UIViewController { private var cancellables: Set = .init() private let viewModel: TimelineViewModel + weak var delegate: ToastDelegate? // MARK: - Initializer @@ -145,6 +153,8 @@ final class TimelineVC: UIViewController { private extension TimelineVC { func setupAttributes() { view.backgroundColor = TLColor.black + timelineCollectionView.backgroundView = emptyView + timelineCollectionView.backgroundView?.isHidden = true } func setupLayout() { @@ -215,12 +225,15 @@ private extension TimelineVC { .compactMap(\.timelineWritingInfo) .withUnretained(self) .sink { owner, info in - let vc = VCFactory.makeTimelineWritingVC( + let timelineWritingVC = VCFactory.makeTimelineWritingVC( id: info.id, date: info.date, day: info.day ) - owner.navigationController?.pushViewController(vc, animated: true) + + timelineWritingVC.delegate = owner + + owner.navigationController?.pushViewController(timelineWritingVC, animated: true) } .store(in: &cancellables) @@ -238,11 +251,24 @@ private extension TimelineVC { .store(in: &cancellables) viewModel.state - .map(\.deleteCompleted) - .filter { $0 } + .map(\.isDeleteCompleted) + .removeDuplicates() + .dropFirst() .withUnretained(self) - .sink { owner, _ in + .sink { owner, isSuccess in owner.navigationController?.popViewController(animated: true) + owner.delegate?.viewControllerDidFinishAction( + isSuccess: isSuccess, + message: isSuccess ? Constants.didFinishDeleteWithSuccess : Constants.didFinishDeleteWithFailure + ) + } + .store(in: &cancellables) + + viewModel.state + .map(\.isEmptyList) + .withUnretained(self) + .sink { owner, isEmptyList in + owner.timelineCollectionView.backgroundView?.isHidden = !isEmptyList } .store(in: &cancellables) } @@ -401,6 +427,7 @@ extension TimelineVC: UICollectionViewDelegate { if indexPath.section == 0 { return } let timelineDetailVC = VCFactory.makeTimelineDetailVC(with: viewModel.currentState.timelineCardList[indexPath.row].detailId) + timelineDetailVC.delegate = self navigationController?.pushViewController(timelineDetailVC, animated: true) } @@ -426,6 +453,14 @@ extension TimelineVC: TimelineDateHeaderDelegate { } } +// MARK: - TimelineWriting, TimelineDetail Delegate + +extension TimelineVC: ToastDelegate { + func viewControllerDidFinishAction(isSuccess: Bool, message: String) { + showToast(message: message, type: isSuccess ? .success : .failure) + } +} + @available(iOS 17, *) #Preview { UINavigationController(rootViewController: VCFactory.makeTimelineVC(id: .empty)) diff --git a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/View/TimelineCardView.swift b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/View/TimelineCardView.swift index 2807024..3d1b8c2 100644 --- a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/View/TimelineCardView.swift +++ b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/View/TimelineCardView.swift @@ -53,7 +53,9 @@ final class TimelineCardView: UIView { func setData(cardInfo: TimelineCardInfo) { titleLabel.setText(to: cardInfo.title) - subtitleLabel.setText(to: cardInfo.place) + if let place = cardInfo.place { + subtitleLabel.setText(to: place) + } contentLabel.setText(to: cardInfo.content) thumbnailImageView.setImage(from: cardInfo.thumbnailURL, imagePath: cardInfo.imagePath) thumbnailImageView.isHidden = cardInfo.thumbnailURL == nil @@ -76,6 +78,7 @@ private extension TimelineCardView { thumbnailImageView.backgroundColor = TLColor.disabledGray thumbnailImageView.layer.cornerRadius = 12.0 thumbnailImageView.clipsToBounds = true + thumbnailImageView.contentMode = .scaleAspectFill } func setupLayout() { diff --git a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/ViewModel/TimelineViewModel.swift b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/ViewModel/TimelineViewModel.swift index 1bab99b..255bdab 100644 --- a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/ViewModel/TimelineViewModel.swift +++ b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineScene/ViewModel/TimelineViewModel.swift @@ -66,8 +66,9 @@ struct TimelineState: BaseState { var date: String? var timelineWritingInfo: TimelineWritingInfo? var isEdit: Bool = false - var deleteCompleted: Bool = false + var isDeleteCompleted: Bool = false var errorMsg: String? + var isEmptyList: Bool = false } final class TimelineViewModel: BaseViewModel { @@ -129,7 +130,7 @@ final class TimelineViewModel: BaseViewModel = .init() private var viewModel: TimelineWritingViewModel + weak var delegate: ToastDelegate? + // MARK: - Initialize init(viewModel: TimelineWritingViewModel) { @@ -163,9 +167,7 @@ final class TimelineWritingVC: UIViewController { } @objc private func imageButtonCancelTapped() { - selectImageButton.setImage(nil) - viewModel.sendAction(.imageDidChange(nil)) - selectImageButton.updateView() + changeImage(to: nil) } @objc private func locationButtonCancelTapped() { @@ -173,6 +175,11 @@ final class TimelineWritingVC: UIViewController { viewModel.sendAction(.placeDidChange(.emtpy)) } + private func changeImage(to image: UIImage?) { + selectImageButton.setImage(image) + viewModel.sendAction(.imageDidChange) + } + private func actionKeyboardWillShow(_ keyboardFrame: CGRect) { self.scrollView.contentInset.bottom = keyboardFrame.size.height scrollView.scrollRectToVisible(textView.frame, animated: true) @@ -328,7 +335,7 @@ private extension TimelineWritingVC { owner.textView.textColor = TLColor.white owner.textView.text = detail.content } - if let place = detail.place { + if let place = detail.place, !place.title.isEmpty { owner.selectLocation.setText(to: place.title) } } @@ -336,6 +343,7 @@ private extension TimelineWritingVC { viewModel.state .map(\.imageURLString) + .removeDuplicates() .withUnretained(self) .sink { owner, imageURLString in owner.selectImageButton.setImage(urlString: imageURLString) @@ -354,16 +362,6 @@ private extension TimelineWritingVC { } .store(in: &cancellables) - viewModel.state - .map(\.popToTimeline) - .filter { $0 } - .removeDuplicates() - .withUnretained(self) - .sink { owner, _ in - owner.navigationController?.popViewController(animated: true) - } - .store(in: &cancellables) - viewModel.state .map(\.timelineDetailRequest.time) .removeDuplicates() @@ -375,11 +373,15 @@ private extension TimelineWritingVC { viewModel.state .map(\.isEditCompleted) - .filter { $0 } .removeDuplicates() + .dropFirst() .withUnretained(self) - .sink { owner, _ in + .sink { owner, isSuccess in owner.navigationController?.popViewController(animated: true) + owner.delegate?.viewControllerDidFinishAction( + isSuccess: isSuccess, + message: isSuccess ? Constants.didFinishWritingWithSuccess : Constants.didFinishWritingWithFailure + ) } .store(in: &cancellables) } @@ -439,7 +441,7 @@ extension TimelineWritingVC: PHPickerViewControllerDelegate { guard let self = self else { return } DispatchQueue.main.async { guard let selectedImage = image as? UIImage else { return } - self.selectImageButton.setImage(selectedImage) + self.changeImage(to: selectedImage) } } @@ -465,7 +467,7 @@ extension TimelineWritingVC: LocationSearchDelegate { extension TimelineWritingVC: TLNavigationBarDelegate { func rightButtonDidTapped() { if let selectedImage = selectImageButton.imageView.image { - let image = selectedImage.downSampling() + let image = viewModel.currentState.isOriginImage ? selectedImage : selectedImage.downSampling() let imageData = image?.jpegData(compressionQuality: 1) viewModel.sendAction(.tapCompleteButton(imageData)) } else { diff --git a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/View/SelectImageButton.swift b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/View/SelectImageButton.swift index 5b44f1d..547ade5 100644 --- a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/View/SelectImageButton.swift +++ b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/View/SelectImageButton.swift @@ -21,7 +21,7 @@ final class SelectImageButton: UIView { // MARK: - UI Components - let view: UIView = { + private let view: UIView = { let view = UIView() view.layer.cornerRadius = Metric.cornerRadius view.clipsToBounds = true @@ -41,7 +41,7 @@ final class SelectImageButton: UIView { let imageView: UIImageView = .init() - let selectView: UIStackView = { + private let selectView: UIStackView = { let view = UIStackView() view.axis = .vertical view.alignment = .center @@ -51,26 +51,19 @@ final class SelectImageButton: UIView { return view }() - let selectViewIcon: UIImageView = { + private let selectViewIcon: UIImageView = { let view = UIImageView(image: TLImage.Common.album) view.contentMode = .scaleAspectFit return view }() - let selectViewLabel: TLLabel = { + private let selectViewLabel: TLLabel = { let label = TLLabel(font: TLFont.body2, color: TLColor.white) label.textAlignment = .center return label }() - // MARK: - Properties - - private var hasImage: Bool { - imageView.image != nil - } - let width = Metric.viewWidth + Metric.buttonOffset - // MARK: - initialize init() { @@ -86,7 +79,7 @@ final class SelectImageButton: UIView { // MARK: - Functions - func updateView() { + private func updateView(hasImage: Bool) { selectView.isHidden = hasImage imageView.isHidden = !hasImage cancelButton.isHidden = !hasImage @@ -94,12 +87,12 @@ final class SelectImageButton: UIView { func setImage(_ image: UIImage?) { imageView.image = image - updateView() + updateView(hasImage: image != nil) } func setImage(urlString: String?, imagePath: String? = nil) { imageView.setImage(from: urlString, imagePath: imagePath) - updateView() + updateView(hasImage: urlString != nil) } } @@ -111,7 +104,7 @@ private extension SelectImageButton { func setupAttributes() { selectViewLabel.setText(to: "선택") view.backgroundColor = TLColor.backgroundGray - updateView() + updateView(hasImage: false) } func setupLayout() { diff --git a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/View/SelectedLocationButton.swift b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/View/SelectedLocationButton.swift index 8666d41..bea6c9a 100644 --- a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/View/SelectedLocationButton.swift +++ b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/View/SelectedLocationButton.swift @@ -11,7 +11,7 @@ import UIKit final class SelectLocationButton: UIView { private enum Constants { - static let defaultText: String = "선택한 장소" + static let defaultText: String = "장소 선택" static let labelHeight: CGFloat = 24 static let spacing: CGFloat = 6 static let buttonSpacing: CGFloat = 2 diff --git a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/ViewModel/TimelineWritingViewModel.swift b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/ViewModel/TimelineWritingViewModel.swift index d8184db..0c8187b 100644 --- a/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/ViewModel/TimelineWritingViewModel.swift +++ b/iOS/traveline/Sources/Feature/TimelineFeature/TimelineWritingScene/ViewModel/TimelineWritingViewModel.swift @@ -17,7 +17,7 @@ enum TimelineWritingAction: BaseAction { case metaDataTime(String) case searchPlace(String) case placeDidChange(TimelinePlace) - case imageDidChange(Data?) + case imageDidChange case placeDidScrollToBottom case tapCompleteButton(Data?) case configTimelineDetailInfo(TimelineDetailInfo) @@ -43,7 +43,7 @@ enum TimelineWritingSideEffect: BaseSideEffect { case updateTitleState(String) case updateContentState(String) case updateTimeState(String) - case updateImageState(Data?) + case updateImageState case updatePlaceState(TimelinePlace) case updatePlaceKeyword(String) case fetchPlaceList(TimelinePlaceList) @@ -55,6 +55,7 @@ enum TimelineWritingSideEffect: BaseSideEffect { } struct TimelineWritingState: BaseState { + var isOriginImage: Bool = false var isCompletable: Bool = false var timelineDetailRequest: TimelineDetailRequest = .empty var popToTimeline: Bool = false @@ -113,8 +114,8 @@ final class TimelineWritingViewModel: BaseViewModel Bool { + let text = textField.text ?? "" + if text.count + string.count > Constants.titleLimit { + let textLimitToast = TLToastView(type: .failure, message: Constants.titleLimitToastMessage, followsUndockedKeyboard: true) + textLimitToast.show(in: self.view) + return false + } + return true + } } // MARK: - TLBottomSheetDelegate