diff --git a/FLINT/Domain/Sources/Entity/Collection/CreateCollectionEntity.swift b/FLINT/Domain/Sources/Entity/Collection/CreateCollectionEntity.swift index 70d8407f..b77e6061 100644 --- a/FLINT/Domain/Sources/Entity/Collection/CreateCollectionEntity.swift +++ b/FLINT/Domain/Sources/Entity/Collection/CreateCollectionEntity.swift @@ -14,11 +14,12 @@ public struct CreateCollectionEntity: Encodable { public let isPublic: Bool public let contentList: [CreateCollectionContents] - public init(imgaeUrl: String, - title: String, - description: String, - isPublic: Bool, - contentList: [CreateCollectionContents] + public init( + imgaeUrl: String, + title: String, + description: String, + isPublic: Bool, + contentList: [CreateCollectionContents] ) { self.imageUrl = imgaeUrl self.title = title @@ -34,11 +35,18 @@ public extension CreateCollectionEntity { public let contentId: Int64 public let isSpoiler: Bool public let reason: String + public let customImage: String? - public init(contentId: Int64, isSpoiler: Bool, reason: String) { + public init( + contentId: Int64, + isSpoiler: Bool, + reason: String, + customImage customImageKey: String? = nil + ) { self.contentId = contentId self.isSpoiler = isSpoiler self.reason = reason + self.customImage = customImageKey } } } diff --git a/FLINT/Domain/Sources/UseCase/Collection/UploadCollectionImageUseCase.swift b/FLINT/Domain/Sources/UseCase/Collection/UploadCollectionImageUseCase.swift new file mode 100644 index 00000000..14e6e6b1 --- /dev/null +++ b/FLINT/Domain/Sources/UseCase/Collection/UploadCollectionImageUseCase.swift @@ -0,0 +1,39 @@ +// +// UploadCollectionImageUseCase.swift +// Domain +// +// Created by 소은 on 5/29/26. +// + +import Combine +import Foundation +import UIKit + +import Entity +import Repository + +public protocol UploadCollectionImageUseCase { + func callAsFunction(_ image: UIImage) -> AnyPublisher +} + +public final class DefaultUploadCollectionImageUseCase: UploadCollectionImageUseCase { + + private let storageRepository: StorageRepository + + public init(storageRepository: StorageRepository) { + self.storageRepository = storageRepository + } + + public func callAsFunction(_ image: UIImage) -> AnyPublisher { + storageRepository.fetchPresignedURL(uploadType: .collectionContent, fileExtension: .png) + .flatMap { [storageRepository] presignedUrlInfoEntity in + guard let imageData = image.pngData() else { + return Fail(error: FlintError.imageEncodingFailed).eraseToAnyPublisher() + } + return storageRepository.uploadImageToS3(imageData: imageData, uploadUrl: presignedUrlInfoEntity.uploadUrl, fileExtension: .png) + .map { _ in return presignedUrlInfoEntity.key } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} diff --git a/FLINT/FLINT/Dependency/DIContainer.swift b/FLINT/FLINT/Dependency/DIContainer.swift index ac8255cf..1340f325 100644 --- a/FLINT/FLINT/Dependency/DIContainer.swift +++ b/FLINT/FLINT/Dependency/DIContainer.swift @@ -27,7 +27,9 @@ typealias DependencyFactory = ViewControllerFactory & CreateCollectionViewModelFactory & AddContentSelectViewModelFactory & CollectionFolderListViewModelFactory & - CollectionDetailViewModelFactory + CollectionDetailViewModelFactory & + StorageRepositoryFactory & + UploadCollectionImageUseCaseFactory final class DIContainer: DependencyFactory { @@ -54,6 +56,12 @@ final class DIContainer: DependencyFactory { lazy var presignedUrlService: any PresignedUrlService = DefaultPresignedUrlService() + // MARK: - UseCase + + lazy var uploadCollectionImageUseCase: UploadCollectionImageUseCase = DefaultUploadCollectionImageUseCase( + storageRepository: makeStorageRepository() + ) + // MARK: - Init init() { diff --git a/FLINT/FLINT/Dependency/Factory/UseCase/Collection/UploadCollectionImageUseCaseFactory.swift b/FLINT/FLINT/Dependency/Factory/UseCase/Collection/UploadCollectionImageUseCaseFactory.swift new file mode 100644 index 00000000..2271dff8 --- /dev/null +++ b/FLINT/FLINT/Dependency/Factory/UseCase/Collection/UploadCollectionImageUseCaseFactory.swift @@ -0,0 +1,20 @@ +// +// UploadCollectionImageUseCaseFactory.swift +// FLINT +// +// Created by 소은 on 2026.05.29. +// + +import Foundation + +import Domain + +protocol UploadCollectionImageUseCaseFactory: StorageRepositoryFactory { + func makeUploadCollectionImageUseCase() -> UploadCollectionImageUseCase +} + +extension UploadCollectionImageUseCaseFactory { + func makeUploadCollectionImageUseCase() -> UploadCollectionImageUseCase { + return DefaultUploadCollectionImageUseCase(storageRepository: makeStorageRepository()) + } +} diff --git a/FLINT/FLINT/Dependency/Factory/ViewController/CreateCollectionViewControllerFactory+.swift b/FLINT/FLINT/Dependency/Factory/ViewController/CreateCollectionViewControllerFactory+.swift index 5a83079a..da5d3303 100644 --- a/FLINT/FLINT/Dependency/Factory/ViewController/CreateCollectionViewControllerFactory+.swift +++ b/FLINT/FLINT/Dependency/Factory/ViewController/CreateCollectionViewControllerFactory+.swift @@ -9,9 +9,13 @@ import Foundation import Presentation -extension CreateCollectionViewControllerFactory where Self: CreateCollectionViewModelFactory & ViewControllerFactory { +extension CreateCollectionViewControllerFactory where Self: CreateCollectionViewModelFactory & ViewControllerFactory & UploadCollectionImageUseCaseFactory { func makeCreateCollectionViewController() -> CreateCollectionViewController { let vm = makeCreateCollectionViewModel() - return CreateCollectionViewController(viewModel: vm, viewControllerFactory: self) + return CreateCollectionViewController( + viewModel: vm, + uploadImageUseCase: makeUploadCollectionImageUseCase(), + viewControllerFactory: self + ) } } diff --git a/FLINT/Presentation/Sources/View/Component/FlinerRecommendCard/CustomPageControl.swift b/FLINT/Presentation/Sources/View/Component/FlinerRecommendCard/CustomPageControl.swift index 33347eb7..55d919d3 100644 --- a/FLINT/Presentation/Sources/View/Component/FlinerRecommendCard/CustomPageControl.swift +++ b/FLINT/Presentation/Sources/View/Component/FlinerRecommendCard/CustomPageControl.swift @@ -7,7 +7,7 @@ import UIKit -final class CustomPageControl: BaseView { +public final class CustomPageControl: BaseView { // MARK: - Property diff --git a/FLINT/Presentation/Sources/View/Component/SelectedContentReasonTableViewCell/SelectedContentReasonTableViewCell.swift b/FLINT/Presentation/Sources/View/Component/SelectedContentReasonTableViewCell/SelectedContentReasonTableViewCell.swift index 771086b0..766baf37 100644 --- a/FLINT/Presentation/Sources/View/Component/SelectedContentReasonTableViewCell/SelectedContentReasonTableViewCell.swift +++ b/FLINT/Presentation/Sources/View/Component/SelectedContentReasonTableViewCell/SelectedContentReasonTableViewCell.swift @@ -1,4 +1,3 @@ - // // SelectedContentReasonTableViewCell.swift // FLINT @@ -13,19 +12,46 @@ import Then public final class SelectedContentReasonTableViewCell: BaseTableViewCell { + // MARK: - Metric + + private enum Metric { + static let posterTop: CGFloat = 64 + static let posterLeading: CGFloat = 16 + static let posterWidth: CGFloat = 100 + static let posterHeight: CGFloat = 150 + static let photoHeight: CGFloat = 270 + static let horizontalInset: CGFloat = 16 + } + + // MARK: - Closure + public var onTapClose: (() -> Void)? public var onToggleSpoiler: ((Bool) -> Void)? public var onChangeReasonText: ((String) -> Void)? public var onTapCloseWithDraft: (() -> Void)? + public var onTapAddPhoto: (() -> Void)? + public var onPhotosChanged: (() -> Void)? - private var isSpoilerOn: Bool = false + public var currentReasonText: String { textView.text ?? "" } + public var currentPhotos: [UIImage] { photos } + + // MARK: - State + + private var photos: [UIImage] = [] + private var photoScrollHeightConstraint: Constraint? + private var pageControlTopConstraint: Constraint? + private var pageControlHeightConstraint: Constraint? - //MARK: - UI + // MARK: - UI private let containerView = UIView().then { $0.backgroundColor = .clear } + private let closeButton = UIButton().then { + $0.setImage(.icPrimaryXmark, for: .normal) + } + private let posterImageView = UIImageView().then { $0.contentMode = .scaleAspectFill $0.clipsToBounds = true @@ -35,11 +61,6 @@ public final class SelectedContentReasonTableViewCell: BaseTableViewCell { $0.backgroundColor = .clear } - private let closeButton = UIButton().then { - $0.setImage(.icCancel, for: .normal) - $0.tintColor = .flintWhite - } - private let titleLabel = UILabel().then { $0.numberOfLines = 2 $0.lineBreakMode = .byTruncatingTail @@ -54,8 +75,29 @@ public final class SelectedContentReasonTableViewCell: BaseTableViewCell { $0.numberOfLines = 1 } + private let photoScrollView = UIScrollView().then { + $0.showsHorizontalScrollIndicator = false + $0.isPagingEnabled = true + $0.isHidden = true + } + + private let photoStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 0 + } + + private let pageControl = CustomPageControl().then { + $0.isHidden = true + } + private let sectionTitleLabel = UILabel() + private let textView = FlintTextView(placeholder: "이 작품의 매력 포인트를 적어주세요.") + + private let addPhotoButton = UIButton().then { + $0.setImage(.icAddPhoto, for: .normal) + } + private let spoilerLabel = UILabel() private let checkboxToggleView = ToggleBarView( @@ -64,9 +106,8 @@ public final class SelectedContentReasonTableViewCell: BaseTableViewCell { knobSize: 24, contentInset: 2 ) - private let textView = FlintTextView(placeholder: "이 작품의 매력 포인트를 적어주세요.") - //MARK: - Setup + // MARK: - Setup public override func setStyle() { backgroundColor = .clear @@ -74,31 +115,36 @@ public final class SelectedContentReasonTableViewCell: BaseTableViewCell { sectionTitleLabel.attributedText = .pretendard(.head3_m_18, text: "이 작품을 선택한 이유", color: .flintWhite) spoilerLabel.attributedText = .pretendard(.caption1_m_12, text: "스포일러", color: .flintWhite) + addPhotoButton.imageView?.contentMode = .scaleAspectFill closeButton.addTarget(self, action: #selector(didTapClose), for: .touchUpInside) + addPhotoButton.addTarget(self, action: #selector(didTapAddPhoto), for: .touchUpInside) + photoScrollView.delegate = self checkboxToggleView.onValueChanged = { [weak self] isOn in self?.onToggleSpoiler?(isOn) } + textView.delegate = self } public override func setHierarchy() { - contentView.addSubviews(containerView, closeButton) + contentView.addSubview(containerView) containerView.addSubviews( posterImageView, infoContainerView, + photoScrollView, + pageControl, sectionTitleLabel, + textView, + addPhotoButton, spoilerLabel, checkboxToggleView, - textView + closeButton ) - infoContainerView.addSubviews( - titleLabel, - directorLabel, - yearLabel - ) + infoContainerView.addSubviews(titleLabel, directorLabel, yearLabel) + photoScrollView.addSubview(photoStackView) } public override func setLayout() { @@ -112,14 +158,14 @@ public final class SelectedContentReasonTableViewCell: BaseTableViewCell { } posterImageView.snp.makeConstraints { - $0.top.equalToSuperview().inset(64) - $0.leading.equalToSuperview().inset(16) - $0.width.equalTo(100) - $0.height.equalTo(150) + $0.top.equalToSuperview().inset(Metric.posterTop) + $0.leading.equalToSuperview().inset(Metric.posterLeading) + $0.width.equalTo(Metric.posterWidth) + $0.height.equalTo(Metric.posterHeight) } infoContainerView.snp.makeConstraints { - $0.top.equalTo(posterImageView.snp.top) + $0.top.equalTo(posterImageView) $0.leading.equalTo(posterImageView.snp.trailing).offset(16) $0.trailing.equalToSuperview().inset(24) } @@ -131,7 +177,7 @@ public final class SelectedContentReasonTableViewCell: BaseTableViewCell { directorLabel.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom).offset(12) - $0.leading.trailing.equalToSuperview() + $0.horizontalEdges.equalToSuperview() } yearLabel.snp.makeConstraints { @@ -140,57 +186,75 @@ public final class SelectedContentReasonTableViewCell: BaseTableViewCell { $0.bottom.equalToSuperview().inset(24) } - sectionTitleLabel.snp.makeConstraints { + photoScrollView.snp.makeConstraints { $0.top.equalTo(posterImageView.snp.bottom).offset(16) - $0.leading.equalToSuperview().inset(16) + $0.horizontalEdges.equalToSuperview() + photoScrollHeightConstraint = $0.height.equalTo(0).constraint } - checkboxToggleView.snp.makeConstraints { - $0.centerY.equalTo(sectionTitleLabel.snp.centerY) - $0.trailing.equalToSuperview().inset(16) - $0.width.equalTo(44) - $0.height.equalTo(28) + photoStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalToSuperview() } - spoilerLabel.snp.makeConstraints { - $0.centerY.equalTo(sectionTitleLabel.snp.centerY) - $0.trailing.equalTo(checkboxToggleView.snp.leading).offset(-8) + pageControl.snp.makeConstraints { + pageControlTopConstraint = $0.top.equalTo(photoScrollView.snp.bottom).offset(0).constraint + $0.centerX.equalToSuperview() + pageControlHeightConstraint = $0.height.equalTo(0).constraint + } + + sectionTitleLabel.snp.makeConstraints { + $0.top.equalTo(pageControl.snp.bottom).offset(16) + $0.leading.equalToSuperview().inset(Metric.horizontalInset) } textView.snp.makeConstraints { $0.top.equalTo(sectionTitleLabel.snp.bottom).offset(16) - $0.horizontalEdges.equalToSuperview().inset(16) - $0.bottom.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(Metric.horizontalInset) $0.height.greaterThanOrEqualTo(104) } + + addPhotoButton.snp.makeConstraints { + $0.centerY.equalTo(checkboxToggleView) + $0.leading.equalToSuperview().inset(Metric.horizontalInset) + $0.width.equalTo(48) + $0.height.equalTo(28) + } + + checkboxToggleView.snp.makeConstraints { + $0.top.equalTo(textView.snp.bottom).offset(12) + $0.trailing.equalToSuperview().inset(Metric.horizontalInset) + $0.width.equalTo(44) + $0.height.equalTo(28) + $0.bottom.equalToSuperview().inset(16) + } + + spoilerLabel.snp.makeConstraints { + $0.centerY.equalTo(checkboxToggleView) + $0.trailing.equalTo(checkboxToggleView.snp.leading).offset(-8) + } } public override func prepare() { super.prepare() - posterImageView.image = nil - titleLabel.attributedText = nil directorLabel.attributedText = nil yearLabel.attributedText = nil - - sectionTitleLabel.attributedText = nil - spoilerLabel.attributedText = nil - isSpoilerOn = false checkboxToggleView.setOn(false, animated: true) - textView.text = "" - + resetPhotos() } public override func prepareForReuse() { super.prepareForReuse() posterImageView.kf.cancelDownloadTask() posterImageView.image = nil + resetPhotos() } - //MARK: - Configure + // MARK: - Configure public func configure(with item: SelectedContentReasonTableViewCellItem) { if let url = item.posterURL { @@ -198,32 +262,163 @@ public final class SelectedContentReasonTableViewCell: BaseTableViewCell { } else { posterImageView.image = item.posterImage } - + titleLabel.attributedText = .pretendard(.head3_m_18, text: item.title, color: .flintWhite) directorLabel.attributedText = .pretendard(.body1_r_16, text: item.director, color: .flintGray300) yearLabel.attributedText = .pretendard(.body1_r_16, text: item.year, color: .flintGray300) - sectionTitleLabel.attributedText = .pretendard(.head3_m_18, text: "이 작품을 선택한 이유", color: .flintWhite) spoilerLabel.attributedText = .pretendard(.caption1_m_12, text: "스포일러", color: .flintWhite) - + checkboxToggleView.setOn(item.isSpoiler, animated: false) - textView.text = item.reasonText ?? "" - + if textView.text.isEmpty || textView.text != item.reasonText { + textView.text = item.reasonText ?? "" + textView.subviews.compactMap { $0 as? UILabel }.first?.isHidden = !(item.reasonText ?? "").isEmpty + } + + configurePhotos(item.photos) } - //MARK: - Action + // MARK: - Private + + private var isSpoilerOn: Bool = false + + private func resetPhotos() { + photos = [] + photoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + applyPhotoLayout(hasPhotos: false) + } + + private func configurePhotos(_ newPhotos: [UIImage]) { + photos = newPhotos + photoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + applyPhotoLayout(hasPhotos: !photos.isEmpty) + guard !photos.isEmpty else { return } + + pageControl.numberOfPages = photos.count + pageControl.currentPage = 0 + + setupInfiniteScroll() + } + + private func setupInfiniteScroll() { + let infinitePhotos = [photos.last!] + photos + [photos.first!] + + infinitePhotos.enumerated().forEach { index, image in + let realIndex: Int + if index == 0 { realIndex = photos.count - 1 } + else if index == photos.count + 1 { realIndex = 0 } + else { realIndex = index - 1 } + + photoStackView.addArrangedSubview(makePhotoWrapper(image: image, realIndex: realIndex)) + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.layoutIfNeeded() + let width = self.photoScrollView.bounds.width + guard width > 0 else { return } + self.photoScrollView.setContentOffset(CGPoint(x: width, y: 0), animated: false) + } + } + + private func applyPhotoLayout(hasPhotos: Bool) { + photoScrollView.isHidden = !hasPhotos + photoScrollHeightConstraint?.update(offset: hasPhotos ? Metric.photoHeight : 0) + pageControl.isHidden = !hasPhotos + pageControlTopConstraint?.update(offset: hasPhotos ? 8 : 0) + pageControlHeightConstraint?.update(offset: hasPhotos ? 8 : 0) + + sectionTitleLabel.snp.remakeConstraints { + $0.top.equalTo(pageControl.snp.bottom).offset(16) + $0.leading.equalToSuperview().inset(Metric.horizontalInset) + } + } + + private func makePhotoWrapper(image: UIImage, realIndex: Int) -> UIView { + let wrapper = UIView().then { $0.clipsToBounds = true } + + let imageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + $0.image = image + } + + let deleteButton = UIButton().then { + $0.setImage(.icBlackXmark, for: .normal) + $0.tag = realIndex + $0.addTarget(self, action: #selector(didTapDeletePhoto(_:)), for: .touchUpInside) + } + + wrapper.addSubviews(imageView, deleteButton) + + imageView.snp.makeConstraints { $0.edges.equalToSuperview() } + + deleteButton.snp.makeConstraints { + $0.top.trailing.equalToSuperview().inset(12) + $0.size.equalTo(48) + } + + wrapper.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width) + $0.height.equalTo(Metric.photoHeight) + } + + return wrapper + } + + // MARK: - Action @objc private func didTapClose() { let text = (textView.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let hasDraft = !text.isEmpty + text.isEmpty ? onTapClose?() : onTapCloseWithDraft?() + } + + @objc private func didTapAddPhoto() { + onTapAddPhoto?() + } + + @objc private func didTapDeletePhoto(_ sender: UIButton) { + let index = sender.tag + guard index < photos.count else { return } + photos.remove(at: index) + configurePhotos(photos) + onPhotosChanged?() + } +} - if hasDraft { - onTapCloseWithDraft?() - } else { - onTapClose?() +// MARK: - UIScrollViewDelegate + +extension SelectedContentReasonTableViewCell: UIScrollViewDelegate { + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let width = scrollView.bounds.width + guard width > 0, !photos.isEmpty else { return } + let page = Int(round(scrollView.contentOffset.x / width)) + pageControl.currentPage = (page - 1 + photos.count) % photos.count + } + + public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + let width = scrollView.bounds.width + guard width > 0, !photos.isEmpty else { return } + let page = Int(round(scrollView.contentOffset.x / width)) + + if page == 0 { + scrollView.setContentOffset(CGPoint(x: width * CGFloat(photos.count), y: 0), animated: false) + } else if page == photos.count + 1 { + scrollView.setContentOffset(CGPoint(x: width, y: 0), animated: false) } + } - + } +extension SelectedContentReasonTableViewCell: UITextViewDelegate { + public func textViewDidChange(_ textView: UITextView) { + if let flintTextView = textView as? FlintTextView { + let placeholderLabel = flintTextView.subviews.compactMap { $0 as? UILabel }.first + placeholderLabel?.isHidden = !textView.text.isEmpty + } + onChangeReasonText?(textView.text ?? "") + } +} diff --git a/FLINT/Presentation/Sources/View/Component/SelectedContentReasonTableViewCell/SelectedContentReasonTableViewCellItem.swift b/FLINT/Presentation/Sources/View/Component/SelectedContentReasonTableViewCell/SelectedContentReasonTableViewCellItem.swift index 8d80bc89..ff65ab0d 100644 --- a/FLINT/Presentation/Sources/View/Component/SelectedContentReasonTableViewCell/SelectedContentReasonTableViewCellItem.swift +++ b/FLINT/Presentation/Sources/View/Component/SelectedContentReasonTableViewCell/SelectedContentReasonTableViewCellItem.swift @@ -17,6 +17,8 @@ public struct SelectedContentReasonTableViewCellItem { public var isSpoiler: Bool public var reasonText: String? + public var photos: [UIImage] + public var customImageKey: String? = nil public init( contentId: Int64, @@ -26,7 +28,8 @@ public struct SelectedContentReasonTableViewCellItem { director: String, year: String, isSpoiler: Bool = false, - reasonText: String? = nil + reasonText: String? = nil, + photos: [UIImage] = [] ) { self.contentId = contentId self.posterURL = posterURL @@ -36,5 +39,6 @@ public struct SelectedContentReasonTableViewCellItem { self.year = year self.isSpoiler = isSpoiler self.reasonText = reasonText + self.photos = photos } } diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/Contents.json b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/Contents.json new file mode 100644 index 00000000..2cc5e6f1 --- /dev/null +++ b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "photo_btn.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "photo_btn@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "photo_btn@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn.png new file mode 100644 index 00000000..21bc4689 Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn.png differ diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn@2x.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn@2x.png new file mode 100644 index 00000000..e38d8b4b Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn@2x.png differ diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn@3x.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn@3x.png new file mode 100644 index 00000000..eccf7c7e Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_add_photo.imageset/photo_btn@3x.png differ diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Contents.json b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Contents.json new file mode 100644 index 00000000..8bd8636b --- /dev/null +++ b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Frame 2087330175.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Frame 2087330175@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Frame 2087330175@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175.png new file mode 100644 index 00000000..ac40bcf9 Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175.png differ diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175@2x.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175@2x.png new file mode 100644 index 00000000..66b8a73b Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175@2x.png differ diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175@3x.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175@3x.png new file mode 100644 index 00000000..48cb09cc Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_black_xmark.imageset/Frame 2087330175@3x.png differ diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Contents.json b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Contents.json new file mode 100644 index 00000000..8bd8636b --- /dev/null +++ b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Frame 2087330175.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Frame 2087330175@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Frame 2087330175@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175.png new file mode 100644 index 00000000..9806499a Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175.png differ diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175@2x.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175@2x.png new file mode 100644 index 00000000..6189ec19 Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175@2x.png differ diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175@3x.png b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175@3x.png new file mode 100644 index 00000000..29868b80 Binary files /dev/null and b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/48/ic_primary+xmark.imageset/Frame 2087330175@3x.png differ diff --git a/FLINT/Presentation/Sources/View/Scene/CreateCollection/Cell/CreateCollectionHeaderImageCell.swift b/FLINT/Presentation/Sources/View/Scene/CreateCollection/Cell/CreateCollectionHeaderImageCell.swift index d88c0b22..893b5685 100644 --- a/FLINT/Presentation/Sources/View/Scene/CreateCollection/Cell/CreateCollectionHeaderImageCell.swift +++ b/FLINT/Presentation/Sources/View/Scene/CreateCollection/Cell/CreateCollectionHeaderImageCell.swift @@ -12,6 +12,10 @@ import Then public final class CreateCollectionHeaderImageCell: BaseTableViewCell { + public var onTapAddPhoto: (() -> Void)? + public var onTapSelectPhoto: (() -> Void)? + public var onTapDeletePhoto: (() -> Void)? + private let headerImageView = UIImageView().then { $0.contentMode = .scaleAspectFill $0.clipsToBounds = true @@ -24,13 +28,19 @@ public final class CreateCollectionHeaderImageCell: BaseTableViewCell { $0.clipsToBounds = true } + private let addPhotoButton = UIButton().then { + $0.setImage(.icBackgroundPhoto, for: .normal) + } + + public override func setStyle() { backgroundColor = .flintBackground contentView.backgroundColor = .flintBackground + addPhotoButton.addTarget(self, action: #selector(didTapAddPhoto), for: .touchUpInside) } public override func setHierarchy() { - contentView.addSubviews(headerImageView, blackOverlayView) + contentView.addSubviews(headerImageView, blackOverlayView, addPhotoButton) } public override func setLayout() { @@ -42,5 +52,18 @@ public final class CreateCollectionHeaderImageCell: BaseTableViewCell { blackOverlayView.snp.makeConstraints { $0.edges.equalToSuperview() } + + addPhotoButton.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(48) + } + } + + public func configure(with image: UIImage?) { + headerImageView.image = image ?? .imgBackgroundGradiantMiddle + } + + @objc private func didTapAddPhoto() { + onTapAddPhoto?() } } diff --git a/FLINT/Presentation/Sources/View/Scene/CreateCollection/CreateCollectionView/CreateCollectionView.swift b/FLINT/Presentation/Sources/View/Scene/CreateCollection/CreateCollectionView/CreateCollectionView.swift index 973306c4..007c7a79 100644 --- a/FLINT/Presentation/Sources/View/Scene/CreateCollection/CreateCollectionView/CreateCollectionView.swift +++ b/FLINT/Presentation/Sources/View/Scene/CreateCollection/CreateCollectionView/CreateCollectionView.swift @@ -87,7 +87,22 @@ public final class CreateCollectionView: BaseView { footerContainerView.subviews.forEach { $0.removeFromSuperview() } footerContainerView.backgroundColor = .clear - footerContainerView.addSubview(button) + let copyrightLabel = UILabel().then { + $0.attributedText = .pretendard( + .caption1_r_12, + text: "Flint에서 제공하는 영화 · 드라마를 포함한 모든 콘텐츠의 저작권은 각 권리자에게 있으며, 관련 법령에 따라 보호됩니다. 컬렉션 이용 시 저작권을 준수해 주세요.", + color: .flintGray300 + ) + $0.textAlignment = .left + $0.numberOfLines = 3 + } + + footerContainerView.addSubviews(copyrightLabel, button) + + copyrightLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(4) + $0.horizontalEdges.equalToSuperview().inset(footerSideInset) + } button.addAction( UIAction { [weak self] _ in @@ -97,21 +112,20 @@ public final class CreateCollectionView: BaseView { ) button.snp.makeConstraints { - $0.top.equalToSuperview() + $0.top.equalTo(copyrightLabel.snp.bottom).offset(4) $0.horizontalEdges.equalToSuperview().inset(footerSideInset) $0.height.equalTo(footerButtonHeight) $0.bottom.equalToSuperview().inset(footerBottomInset) } - let footerHeight = footerButtonHeight + footerBottomInset + let footerHeight = footerButtonHeight + footerBottomInset + 8 + 8 footerContainerView.frame = CGRect( x: 0, y: 0, width: tableView.bounds.width, - height: footerHeight + height: footerHeight + 40 ) tableView.tableFooterView = footerContainerView } - } diff --git a/FLINT/Presentation/Sources/View/Scene/Home/HomeView.swift b/FLINT/Presentation/Sources/View/Scene/Home/HomeView.swift index 2c94b57e..de26ca08 100644 --- a/FLINT/Presentation/Sources/View/Scene/Home/HomeView.swift +++ b/FLINT/Presentation/Sources/View/Scene/Home/HomeView.swift @@ -22,7 +22,6 @@ public final class HomeView: BaseView { $0.showsVerticalScrollIndicator = false } - // 이부분이추가 public let flinerCollectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal diff --git a/FLINT/Presentation/Sources/ViewController/Scene/CreateCollection/CreateCollectionView/CreateCollectionViewController.swift b/FLINT/Presentation/Sources/ViewController/Scene/CreateCollection/CreateCollectionView/CreateCollectionViewController.swift index 360034b6..4b3a7640 100644 --- a/FLINT/Presentation/Sources/ViewController/Scene/CreateCollection/CreateCollectionView/CreateCollectionViewController.swift +++ b/FLINT/Presentation/Sources/ViewController/Scene/CreateCollection/CreateCollectionView/CreateCollectionViewController.swift @@ -12,6 +12,8 @@ import Domain import View import ViewModel +import PhotosUI + public protocol CreateCollectionViewControllerFactory { func makeCreateCollectionViewController() -> CreateCollectionViewController } @@ -36,13 +38,22 @@ public final class CreateCollectionViewController: BaseViewController [CreateCollectionEntity.CreateCollectionContents] { return selectedReasonItems.map { item in - return CreateCollectionEntity.CreateCollectionContents( contentId: item.contentId, isSpoiler: item.isSpoiler, - reason: item.reasonText ?? "" + reason: item.reasonText ?? "", + customImage: item.customImageKey ) } } @@ -168,7 +181,8 @@ private extension CreateCollectionViewController { return SelectedContentReasonTableViewCellItem( contentId: model.contentId, posterURL: model.posterURL, - posterImage: model.posterImage, title: model.title, + posterImage: model.posterImage, + title: model.title, director: model.director, year: model.year, isSpoiler: false, @@ -179,7 +193,6 @@ private extension CreateCollectionViewController { updateCreatePayload() } - func presentAddContentSelect() { guard let factory = viewControllerFactory else { return } @@ -239,6 +252,27 @@ private extension CreateCollectionViewController { modalRef = modal modal.show(in: hostView) } + + func presentPhotoPicker() { + var config = PHPickerConfiguration() + config.selectionLimit = 5 + config.filter = .images + + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + present(picker, animated: true) + } + + func presentHeaderPhotoPicker() { + var config = PHPickerConfiguration() + config.selectionLimit = 1 + config.filter = .images + + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + currentPhotoPickerIndex = -1 + present(picker, animated: true) + } } // MARK: - UITableViewDataSource @@ -264,10 +298,36 @@ extension CreateCollectionViewController: UITableViewDataSource { switch row { case .header: - return tableView.dequeueReusableCell( + let cell = tableView.dequeueReusableCell( withIdentifier: CreateCollectionHeaderImageCell.reuseIdentifier, for: indexPath - ) + ) as! CreateCollectionHeaderImageCell + cell.configure(with: headerImage) + cell.onTapAddPhoto = { [weak self] in + guard let self else { return } + let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + sheet.overrideUserInterfaceStyle = .dark + + sheet.addAction(UIAlertAction(title: "앨범에서 선택", style: .default) { [weak self] _ in + self?.presentHeaderPhotoPicker() + }) + + sheet.addAction(UIAlertAction(title: "커버 사진 삭제", style: .destructive) { [weak self] _ in + guard let self else { return } + self.headerImage = nil + self.headerImageKey = nil + self.updateCreatePayload() + self.rootView.tableView.reloadRows( + at: [IndexPath(row: 0, section: 0)], + with: .none + ) + }) + + sheet.addAction(UIAlertAction(title: "닫기", style: .cancel)) + + self.present(sheet, animated: true) + } + return cell case .title: let cell = tableView.dequeueReusableCell( @@ -314,7 +374,7 @@ extension CreateCollectionViewController: UITableViewDataSource { return UITableViewCell() } } - + if indexPath.section == 1 { if indexPath.row == 0 { @@ -341,7 +401,6 @@ extension CreateCollectionViewController: UITableViewDataSource { cell.onTapClose = { [weak self, weak cell] in guard let self, let cell, let indexPath = self.rootView.tableView.indexPath(for: cell) else { return } - let reasonIndex = indexPath.row - 1 let item = self.selectedReasonItems[reasonIndex] self.deleteReasonItem(item, at: reasonIndex) @@ -350,7 +409,6 @@ extension CreateCollectionViewController: UITableViewDataSource { cell.onTapCloseWithDraft = { [weak self, weak cell] in guard let self, let cell, let indexPath = self.rootView.tableView.indexPath(for: cell) else { return } - let reasonIndex = indexPath.row - 1 let item = self.selectedReasonItems[reasonIndex] self.presentDeleteConfirmModal { @@ -361,7 +419,6 @@ extension CreateCollectionViewController: UITableViewDataSource { cell.onToggleSpoiler = { [weak self, weak cell] isOn in guard let self, let cell, let indexPath = self.rootView.tableView.indexPath(for: cell) else { return } - let reasonIndex = indexPath.row - 1 self.selectedReasonItems[reasonIndex].isSpoiler = isOn self.updateCreatePayload() @@ -370,12 +427,27 @@ extension CreateCollectionViewController: UITableViewDataSource { cell.onChangeReasonText = { [weak self, weak cell] text in guard let self, let cell, let indexPath = self.rootView.tableView.indexPath(for: cell) else { return } - let reasonIndex = indexPath.row - 1 self.selectedReasonItems[reasonIndex].reasonText = text self.updateCreatePayload() } + cell.onTapAddPhoto = { [weak self, weak cell] in + guard let self, let cell, + let indexPath = self.rootView.tableView.indexPath(for: cell) else { return } + self.currentPhotoPickerIndex = indexPath.row - 1 + self.presentPhotoPicker() + } + + cell.onPhotosChanged = { [weak self, weak cell] in + guard let self, let cell, + let indexPath = self.rootView.tableView.indexPath(for: cell) else { return } + let reasonIndex = indexPath.row - 1 + self.selectedReasonItems[reasonIndex].photos = cell.currentPhotos + self.rootView.tableView.beginUpdates() + self.rootView.tableView.endUpdates() + } + return cell } @@ -404,3 +476,93 @@ extension CreateCollectionViewController: UITableViewDelegate { return UITableView.automaticDimension } } + +// MARK: - PHPickerViewControllerDelegate + +extension CreateCollectionViewController: PHPickerViewControllerDelegate { + public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let index = currentPhotoPickerIndex else { return } + + Task { @MainActor in + let images = await self.loadImages(from: results) + guard let image = images.first else { return } + + // 헤더 이미지 처리 + if index == -1 { + let publishers = [self.uploadImageUseCase(image)] + Publishers.MergeMany(publishers) + .collect() + .receive(on: RunLoop.main) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("헤더 이미지 업로드 실패:", error) + } + }, + receiveValue: { [weak self] keys in + guard let self else { return } + self.headerImage = image + self.headerImageKey = keys.first + self.rootView.tableView.reloadRows( + at: [IndexPath(row: 0, section: 0)], + with: .none + ) + } + ) + .store(in: &self.cancellables) + return + } + + let publishers = images.map { self.uploadImageUseCase($0) } + Publishers.MergeMany(publishers) + .collect() + .receive(on: RunLoop.main) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("이미지 업로드 실패:", error) + } + }, + receiveValue: { [weak self] keys in + guard let self else { return } + if let cell = self.rootView.tableView.cellForRow(at: IndexPath(row: index + 1, section: 1)) as? SelectedContentReasonTableViewCell { + self.selectedReasonItems[index].reasonText = cell.currentReasonText + } + self.selectedReasonItems[index].photos = images + self.selectedReasonItems[index].customImageKey = keys.first + self.rootView.tableView.reloadRows( + at: [IndexPath(row: index + 1, section: 1)], + with: .none + ) + } + ) + .store(in: &self.cancellables) + } + } + + private func loadImages(from results: [PHPickerResult]) async -> [UIImage] { + await withTaskGroup(of: (Int, UIImage?).self) { group in + for (index, result) in results.enumerated() { + group.addTask { + await withCheckedContinuation { continuation in + result.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in + continuation.resume(returning: (index, object as? UIImage)) + } + } + } + } + + var indexedImages: [(Int, UIImage)] = [] + for await (index, image) in group { + if let image = image { + indexedImages.append((index, image)) + } + } + + return indexedImages + .sorted { $0.0 < $1.0 } + .map { $0.1 } + } + } +} diff --git a/FLINT/Presentation/Sources/ViewModel/Scene/CreateCollection/CreateCollectionView/CreateCollectionViewModel.swift b/FLINT/Presentation/Sources/ViewModel/Scene/CreateCollection/CreateCollectionView/CreateCollectionViewModel.swift index e1d5307a..1a313755 100644 --- a/FLINT/Presentation/Sources/ViewModel/Scene/CreateCollection/CreateCollectionView/CreateCollectionViewModel.swift +++ b/FLINT/Presentation/Sources/ViewModel/Scene/CreateCollection/CreateCollectionView/CreateCollectionViewModel.swift @@ -15,12 +15,13 @@ public protocol CreateCollectionViewModelInput { func updateDescription(_ description: String) func updateVisibility(_ isPublic: Bool) func updateContentList(_ list: [CreateCollectionEntity.CreateCollectionContents]) + func updateImageUrl(_ imageUrl: String) func createCollection() } public protocol CreateCollectionViewModelOutput { var isDoneEnabled: CurrentValueSubject { get } - var createSuccess: PassthroughSubject { get } + var createSuccess: PassthroughSubject { get } var createFailure: PassthroughSubject { get } } @@ -31,7 +32,7 @@ public final class DefaultCreateCollectionViewModel: CreateCollectionViewModel { private let createCollectionUseCase: CreateCollectionUseCase public var isDoneEnabled: CurrentValueSubject = .init(false) - public var createSuccess: PassthroughSubject = .init() + public var createSuccess: PassthroughSubject = .init() public var createFailure: PassthroughSubject = .init() // MARK: - State @@ -68,6 +69,11 @@ public final class DefaultCreateCollectionViewModel: CreateCollectionViewModel { self.contentList = list evaluateDoneEnabled() } + + public func updateImageUrl(_ imageUrl: String) { + self.imageUrl = imageUrl + evaluateDoneEnabled() + } public func createCollection() { guard isDoneEnabled.value else { return } @@ -75,12 +81,12 @@ public final class DefaultCreateCollectionViewModel: CreateCollectionViewModel { createCollectionUseCase(collectionInfo: entity) .manageThread() - .map { _ in Result.success(()) } - .catch { Just(Result.failure($0)) } + .map { collectionId in Result.success(collectionId) } + .catch { Just(Result.failure($0)) } .sinkHandledCompletion { [weak self] result in switch result { - case .success: - self?.createSuccess.send(()) + case .success(let collectionId): + self?.createSuccess.send(collectionId) case .failure(let error): self?.createFailure.send(error) } @@ -92,9 +98,9 @@ public final class DefaultCreateCollectionViewModel: CreateCollectionViewModel { private func evaluateDoneEnabled() { let titleValid = !titleText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let countValid = contentList.count >= 2 - let visibilityValid = isPublic == true // (정책 나중에) - let descriptionValid = true // (정책 나중에) - let imageValid = true // (정책 나중에) + let visibilityValid = isPublic == true + let descriptionValid = true + let imageValid = true let canCreate = titleValid && countValid && visibilityValid && descriptionValid && imageValid