diff --git a/src/Projects/BKData/Sources/Constant/APIConfig.swift b/src/Projects/BKData/Sources/Constant/APIConfig.swift index b97ef073..5a1d6b04 100644 --- a/src/Projects/BKData/Sources/Constant/APIConfig.swift +++ b/src/Projects/BKData/Sources/Constant/APIConfig.swift @@ -7,17 +7,21 @@ private final class BKDataBundleToken {} enum APIConfig { private static let bundle = Bundle(for: BKDataBundleToken.self) - /// V1 API Base URL (auth, books, users, home) - static let baseURL: String = { + /// API Base URL (xcconfig에서 /api까지만 포함) + private static let baseURL: String = { guard let value = bundle.object(forInfoDictionaryKey: "BASE_API_URL") as? String else { fatalError("Can't load environment: BKData.BASE_API_URL") } return value }() + /// V1 API Base URL (auth, books, users, home) + static let baseURLv1: String = { + return baseURL + "/v1" + }() + /// V2 API Base URL (emotions, reading-records) static let baseURLv2: String = { - // V1 URL에서 v2로 변경 - return baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2") + return baseURL + "/v2" }() } diff --git a/src/Projects/BKData/Sources/DTO/Response/DetailRecordResponseDTO.swift b/src/Projects/BKData/Sources/DTO/Response/DetailRecordResponseDTO.swift index 3415ffdb..f5cf855c 100644 --- a/src/Projects/BKData/Sources/DTO/Response/DetailRecordResponseDTO.swift +++ b/src/Projects/BKData/Sources/DTO/Response/DetailRecordResponseDTO.swift @@ -39,7 +39,6 @@ extension DetailRecordResponseDTO { } } -// api-v2 public struct DetailRecordV2ResponseDTO: Decodable { public let id: String public let userBookId: String diff --git a/src/Projects/BKData/Sources/DataAssembly.swift b/src/Projects/BKData/Sources/DataAssembly.swift index e3e836ae..9bcbf531 100644 --- a/src/Projects/BKData/Sources/DataAssembly.swift +++ b/src/Projects/BKData/Sources/DataAssembly.swift @@ -166,6 +166,10 @@ public struct DataAssembly: Assembly { ) } + container.register(type: ExternalLinkRepository.self) { _ in + return DefaultExternalLinkRepository() + } + container.register( type: EmotionRepository.self, scope: .singleton diff --git a/src/Projects/BKDomain/Sources/Entity/Emotion.swift b/src/Projects/BKDomain/Sources/Entity/Emotion.swift index 9226d3bc..4e9b3319 100644 --- a/src/Projects/BKDomain/Sources/Entity/Emotion.swift +++ b/src/Projects/BKDomain/Sources/Entity/Emotion.swift @@ -7,51 +7,3 @@ public enum Emotion: String, CaseIterable, Decodable { case insight = "깨달음" case other = "기타" } - -public enum SubEmotion: String, CaseIterable { - // 따뜻함 - case comforted = "위로받은" - case cozy = "포근한" - case tender = "다정한" - case grateful = "고마운" - case relieved = "마음이 놓이는" - case peaceful = "편안한" - - // 즐거움 - case excited = "설레는" - case satisfied = "뿌듯한" - case cheerful = "유쾌한" - case joyful = "기쁜" - case thrilling = "흥미진진한" - - // 슬픔 - case hollow = "허무한" - case lonely = "외로운" - case regretful = "아쉬운" - case stunned = "먹먹한" - case bittersweet = "애틋한" - case pitiful = "안타까운" - case nostalgic = "그리운" - - // 깨달음 - case amazed = "감탄한" - case insightful = "통찰력을 얻은" - case inspired = "영감을 받은" - case deepened = "생각이 깊어진" - case understood = "새롭게 이해한" - - public static func subEmotions(for emotion: Emotion) -> [SubEmotion] { - switch emotion { - case .warmth: - return [.comforted, .cozy, .tender, .grateful, .relieved, .peaceful] - case .joy: - return [.excited, .satisfied, .cheerful, .joyful, .thrilling] - case .sad: - return [.hollow, .lonely, .regretful, .stunned, .bittersweet, .pitiful, .nostalgic] - case .insight: - return [.amazed, .insightful, .inspired, .deepened, .understood] - case .other: - return [] - } - } -} diff --git a/src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift b/src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift index a2a4a783..7d6e59de 100644 --- a/src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift +++ b/src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift @@ -31,26 +31,4 @@ public enum PrimaryEmotion: String, CaseIterable, Codable { case .other: return "네 가지 감정으로 표현하기 어려울 때" } } - - /// Emotion으로 변환 - public func toEmotion() -> Emotion { - switch self { - case .warmth: return .warmth - case .joy: return .joy - case .sadness: return .sad - case .insight: return .insight - case .other: return .other - } - } - - /// Emotion에서 변환 - public static func from(emotion: Emotion) -> PrimaryEmotion { - switch emotion { - case .warmth: return .warmth - case .joy: return .joy - case .sad: return .sadness - case .insight: return .insight - case .other: return .other - } - } } diff --git a/src/Projects/BKDomain/Sources/Entity/RecordInfo.swift b/src/Projects/BKDomain/Sources/Entity/RecordInfo.swift index c30e509e..011481cb 100644 --- a/src/Projects/BKDomain/Sources/Entity/RecordInfo.swift +++ b/src/Projects/BKDomain/Sources/Entity/RecordInfo.swift @@ -46,9 +46,4 @@ public struct RecordInfo: Decodable, Equatable { self.bookCoverImageUrl = bookCoverImageUrl self.author = author } - - /// 이전 API와의 호환성을 위한 computed property - public var emotionTags: [Emotion] { - [primaryEmotion.toEmotion()] - } } diff --git a/src/Projects/BKDomain/Sources/VO/RecordDetails/RecordVO.swift b/src/Projects/BKDomain/Sources/VO/RecordDetails/RecordVO.swift index faf134a8..e3dd271a 100644 --- a/src/Projects/BKDomain/Sources/VO/RecordDetails/RecordVO.swift +++ b/src/Projects/BKDomain/Sources/VO/RecordDetails/RecordVO.swift @@ -22,18 +22,4 @@ public struct RecordVO { self.primaryEmotion = primaryEmotion self.detailEmotionIds = detailEmotionIds } - - /// 이전 API와의 호환성을 위한 생성자 - public init( - pageNumber: Int?, - quote: String, - review: String?, - emotionTags: [String] - ) { - self.pageNumber = pageNumber - self.quote = quote - self.memo = review - self.primaryEmotion = .other - self.detailEmotionIds = emotionTags - } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/BookDetail/View/BookDetailViewCell.swift b/src/Projects/BKPresentation/Sources/MainFlow/BookDetail/View/BookDetailViewCell.swift index ef88607c..77b954f8 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/BookDetail/View/BookDetailViewCell.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/BookDetail/View/BookDetailViewCell.swift @@ -84,11 +84,7 @@ final class BookDetailViewCell: UICollectionViewCell { noteLabel.setText(text: displayedNote) emotionTagLabel.setText(text: "#\(emotion.displayName)") creationLabel.setText(text: item.createdAt.toKoreanDotDateString()) - if let page = item.page { - pageLabel.setText(text: "\(page)p") - } else { - pageLabel.setText(text: "-p") - } + pageLabel.setText(text: item.page.toPageString) } func applyMoreButtonGesture( diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift index f404279d..6c956fb6 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift @@ -371,7 +371,6 @@ final class EmotionRegistrationView: BaseView { rowView.addGestureRecognizer(tapGesture) rowView.tag = emotion.hashValue - // Set up chip removal callback rowView.onDetailEmotionRemoved = { [weak self] detailEmotion in self?.removeDetailEmotion(detailEmotion) } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift index 29ee7e7c..d64cce96 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift @@ -8,34 +8,6 @@ struct NoteForm: Equatable { let memo: String? let primaryEmotion: PrimaryEmotion let detailEmotions: [DetailEmotion] - - /// 이전 API 호환성 생성자 - init( - page: Int?, - sentence: String, - emotion: Emotion, - appreciation: String? - ) { - self.page = page - self.sentence = sentence - self.memo = appreciation - self.primaryEmotion = PrimaryEmotion.from(emotion: emotion) - self.detailEmotions = [] - } - - init( - page: Int?, - sentence: String, - memo: String?, - primaryEmotion: PrimaryEmotion, - detailEmotions: [DetailEmotion] - ) { - self.page = page - self.sentence = sentence - self.memo = memo - self.primaryEmotion = primaryEmotion - self.detailEmotions = detailEmotions - } } extension NoteForm { diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift index bfc49dfe..4e4cb16c 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift @@ -80,11 +80,7 @@ final class CollectedSentenceView: BaseView { ) { let displayedText = "\"\(sentence)\"" collectedSentenceLabel.setText(text: displayedText) - if let page { - pageLabel.setText(text: "\(page)p") - } else { - pageLabel.setText(text: "-p") - } + pageLabel.setText(text: page.toPageString) } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/Coordinator/NoteEditCoordinator.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/Coordinator/NoteEditCoordinator.swift index 8ee8fe09..49378ce9 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/Coordinator/NoteEditCoordinator.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/Coordinator/NoteEditCoordinator.swift @@ -37,9 +37,14 @@ extension NoteEditCoordinator: AuthenticationRequiredNotifying, ErrorHandleable } extension NoteEditCoordinator { - func didTapEmotionEdit(currentEmotion: Emotion?, completion: @escaping (Emotion) -> Void) { + func didTapEmotionEdit( + currentEmotion: PrimaryEmotion?, + currentDetailEmotions: [DetailEmotion], + completion: @escaping (PrimaryEmotion, [DetailEmotion]) -> Void + ) { let emotionEditViewController = EmotionEditViewController( currentEmotion: currentEmotion, + currentDetailEmotions: currentDetailEmotions, completion: completion ) emotionEditViewController.coordinator = self diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/EmotionEditView.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/EmotionEditView.swift index 27779494..6ad9cc01 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/EmotionEditView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/EmotionEditView.swift @@ -8,7 +8,8 @@ import SnapKit enum EmotionEditViewEvent { case editButtonTapped - case emotionDidChange(Emotion?) + case emotionDidChange(PrimaryEmotion?) + case emotionSelected(PrimaryEmotion) // 감정 탭 시 (바텀시트 표시용) } final class EmotionEditView: BaseView { @@ -20,10 +21,10 @@ final class EmotionEditView: BaseView { let eventPublisher = PassthroughSubject() private var cancellables = Set() - private var currentSelectedEmotion: Emotion? { + private var currentSelectedPrimaryEmotion: PrimaryEmotion? { guard let form = emotionRegistrationView.registrationForm(), case .emotion(let emotionForm) = form else { return nil } - return emotionForm.emotion + return emotionForm.primaryEmotion } override func setupView() { @@ -35,12 +36,17 @@ final class EmotionEditView: BaseView { override func configure() { editButton.title = "수정하기" editButton.addTarget(self, action: #selector(editButtonTapped), for: .touchUpInside) - + + // 감정 탭 이벤트 전달 (바텀시트 표시용) + emotionRegistrationView.onEmotionSelected = { [weak self] emotion in + self?.eventPublisher.send(.emotionSelected(emotion)) + } + emotionRegistrationView.inputChangedPublisher .sink { [weak self] _ in guard let self = self else { return } - - let newEmotion = self.currentSelectedEmotion + + let newEmotion = self.currentSelectedPrimaryEmotion self.eventPublisher.send(.emotionDidChange(newEmotion)) } .store(in: &cancellables) @@ -72,17 +78,31 @@ final class EmotionEditView: BaseView { } } - func setSelectedEmotion(_ emotion: Emotion) { - emotionRegistrationView.setSelectedEmotion(emotion) + func setSelectedPrimaryEmotion(_ emotion: PrimaryEmotion) { + emotionRegistrationView.setSelectedPrimaryEmotion(emotion) } - + @objc private func editButtonTapped() { eventPublisher.send(.editButtonTapped) } - + func setEditButtonEnabled(_ isEnabled: Bool) { editButton.isDisabled = !isEnabled } + + // MARK: - Detail Emotions + + func setDetailEmotions(_ detailEmotions: [DetailEmotion]) { + emotionRegistrationView.setDetailEmotions(detailEmotions) + } + + func getSelectedDetailEmotions() -> [DetailEmotion] { + return emotionRegistrationView.getSelectedDetailEmotions() + } + + func setLoadingEmotions(_ isLoading: Bool) { + emotionRegistrationView.setLoadingEmotions(isLoading) + } } private extension EmotionEditView { diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/EmotionEditViewController.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/EmotionEditViewController.swift index 8f603160..992679b2 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/EmotionEditViewController.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/EmotionEditViewController.swift @@ -8,50 +8,122 @@ import UIKit final class EmotionEditViewController: BaseViewController { override var bkNavigationTitle: String { "" } - + override var bkNavigationBarStyle: UINavigationController.BKNavigationBarStyle { .standard(viewController: self) } - + weak var coordinator: NoteEditCoordinator? private var cancellables = Set() - - private let initialEmotion: Emotion? - @Published private var selectedEmotion: Emotion? - private let completion: (Emotion) -> Void - - init(currentEmotion: Emotion?, completion: @escaping (Emotion) -> Void) { - self.initialEmotion = currentEmotion + + private let initialPrimaryEmotion: PrimaryEmotion? + private let initialDetailEmotions: [DetailEmotion] + @Published private var selectedPrimaryEmotion: PrimaryEmotion? + private let completion: (PrimaryEmotion, [DetailEmotion]) -> Void + + @Autowired private var fetchDetailEmotionsUseCase: FetchDetailEmotionsUseCase + + // 바텀시트 표시 대기용 + private var pendingEmotionForSheet: PrimaryEmotion? + private var isLoadingEmotions: Bool = false + + init( + currentEmotion: PrimaryEmotion?, + currentDetailEmotions: [DetailEmotion] = [], + completion: @escaping (PrimaryEmotion, [DetailEmotion]) -> Void + ) { + self.initialPrimaryEmotion = currentEmotion + self.initialDetailEmotions = currentDetailEmotions self.completion = completion - self._selectedEmotion = .init(initialValue: currentEmotion) + self._selectedPrimaryEmotion = .init(initialValue: currentEmotion) super.init() } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - - if let emotion = initialEmotion { - contentView.setSelectedEmotion(emotion) + + if let emotion = initialPrimaryEmotion { + contentView.setSelectedPrimaryEmotion(emotion) + contentView.setDetailEmotions(initialDetailEmotions) } contentView.setEditButtonEnabled(false) } - + override func bindAction() { contentView.eventPublisher .sink { [weak self] event in guard let self = self else { return } switch event { case .emotionDidChange(let newEmotion): - self.selectedEmotion = newEmotion - let isDiff = (newEmotion != self.initialEmotion) - self.contentView.setEditButtonEnabled(isDiff) + self.selectedPrimaryEmotion = newEmotion + self.updateEditButtonState() + + case .emotionSelected(let emotion): + // other가 아닌 경우 세부감정 바텀시트 표시 + if emotion != .other { + self.fetchAndPresentDetailEmotionSheet(for: emotion) + } + case .editButtonTapped: - if let emotion = self.selectedEmotion { - self.completion(emotion) + if let emotion = self.selectedPrimaryEmotion { + let detailEmotions = self.contentView.getSelectedDetailEmotions() + self.completion(emotion, detailEmotions) self.navigationController?.popViewController(animated: true) } } } .store(in: &cancellables) } + + private func updateEditButtonState() { + let emotionChanged = selectedPrimaryEmotion != initialPrimaryEmotion + let detailEmotionsChanged = contentView.getSelectedDetailEmotions() != initialDetailEmotions + let isDiff = emotionChanged || detailEmotionsChanged + contentView.setEditButtonEnabled(isDiff) + } + + private func fetchAndPresentDetailEmotionSheet(for emotion: PrimaryEmotion) { + guard !isLoadingEmotions else { return } + + isLoadingEmotions = true + contentView.setLoadingEmotions(true) + + fetchDetailEmotionsUseCase.execute(for: emotion) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoadingEmotions = false + self?.contentView.setLoadingEmotions(false) + if case .failure = completion { + // 에러 처리 - 필요시 coordinator에서 처리 + } + }, + receiveValue: { [weak self] detailEmotions in + self?.presentDetailEmotionSheet(for: emotion, detailEmotions: detailEmotions) + } + ) + .store(in: &cancellables) + } + + private func presentDetailEmotionSheet(for primaryEmotion: PrimaryEmotion, detailEmotions: [DetailEmotion]) { + let currentDetailEmotions = contentView.getSelectedDetailEmotions() + let sheet = BKBottomSheetViewController.makeDetailEmotionSheet( + primaryEmotion: primaryEmotion, + detailEmotions: detailEmotions, + initialSelectedDetailEmotions: currentDetailEmotions, + skipAction: { [weak self] in + // 건너뛰기: 세부감정 초기화 + self?.contentView.setDetailEmotions([]) + self?.updateEditButtonState() + self?.dismiss(animated: true) + }, + confirmAction: { [weak self] selectedDetailEmotions in + self?.contentView.setDetailEmotions(selectedDetailEmotions) + self?.updateEditButtonState() + self?.dismiss(animated: true) + } + ) + + sheet.show(from: self, animated: true) + } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/NoteEditView.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/NoteEditView.swift index 29e61f32..039a0882 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/NoteEditView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/NoteEditView.swift @@ -11,7 +11,7 @@ enum NoteEditViewEvent { case saveButtonTapped case pageDidChange(String) case sentenceDidChange(String) - case appreciationDidChange(String) + case memoDidChange(String) } final class NoteEditView: BaseView { @@ -25,7 +25,7 @@ final class NoteEditView: BaseView { case none case pageField case sentenceTextView - case appreciationTextView + case memoTextView } private let scrollView = UIScrollView() @@ -51,37 +51,61 @@ final class NoteEditView: BaseView { fontStyle: .body2(weight: .medium) ) - private let emotionStatusView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .fill - stackView.alignment = .center - return stackView - }() - + // MARK: - Emotion Card Components + private let emotionTitleLabel = BKLabel( text: "감정", fontStyle: .body1(weight: .medium) ) - - private let emotionRightStackView: UIStackView = { + + private let emotionCardView: UIView = { + let view = UIView() + view.backgroundColor = .bkBaseColor(.secondary) + view.layer.cornerRadius = BKRadius.medium + return view + }() + + private let emotionImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 20 // 40 / 2 + return imageView + }() + + private let emotionInfoStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 4 + stackView.alignment = .leading + return stackView + }() + + private let emotionBadgeView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal - stackView.spacing = BKSpacing.spacing1 + stackView.alignment = .center + stackView.layoutMargins = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + stackView.isLayoutMarginsRelativeArrangement = true return stackView }() - - private let emotionLabel = BKLabel( - fontStyle: .body1(weight: .medium), - color: .bkContentColor(.secondary) + + private let emotionBadgeLabel = BKLabel( + fontStyle: .label2(weight: .semiBold), + color: .bkContentColor(.brand) ) - - private let rightArrowImageView: UIImageView = { + + private let detailEmotionsLabel = BKLabel( + fontStyle: .caption1(weight: .regular), + color: .bkContentColor(.tertiary) + ) + + private let emotionChevronImageView: UIImageView = { let imageView = UIImageView( image: BKImage.Icon.chevronRight .withRenderingMode(.alwaysTemplate) ) - imageView.tintColor = .bkContentColor(.secondary) + imageView.tintColor = .bkContentColor(.tertiary) return imageView }() @@ -91,26 +115,36 @@ final class NoteEditView: BaseView { addSubviews(scrollView, saveButton) scrollView.addSubview(contentView) contentView.addSubviews( - resultView, + resultView, divider, pageField, sentenceTextView, appreciationTextView, - emotionStatusView + emotionTitleLabel, + emotionCardView + ) + + // 감정 카드 내부 구성 + emotionBadgeView.addArrangedSubview(emotionBadgeLabel) + [emotionBadgeView, detailEmotionsLabel].forEach(emotionInfoStack.addArrangedSubview) + emotionCardView.addSubviews( + emotionImageView, + emotionInfoStack, + emotionChevronImageView ) - - [emotionLabel, rightArrowImageView].forEach(emotionRightStackView.addArrangedSubview) - [emotionTitleLabel, emotionRightStackView].forEach(emotionStatusView.addArrangedSubview) } override func configure() { pageField.setTextFieldKeyboardType(.numberPad) pageField.setTextFieldDelegate(self) - - // 감정 상태 영역 탭 제스처 추가 + + // 감정 카드 탭 제스처 추가 let emotionTapGesture = UITapGestureRecognizer(target: self, action: #selector(emotionStatusTapped)) - emotionStatusView.addGestureRecognizer(emotionTapGesture) - emotionStatusView.isUserInteractionEnabled = true + emotionCardView.addGestureRecognizer(emotionTapGesture) + emotionCardView.isUserInteractionEnabled = true + + // 감정 뱃지 초기 설정 + emotionBadgeView.clipsToBounds = true // BKTextFieldView와 BKTextView는 자체적으로 탭을 처리하므로 별도 제스처 불필요 pageField.textDidChangePublisher @@ -130,7 +164,7 @@ final class NoteEditView: BaseView { appreciationTextView.textDidChangePublisher .sink { [weak self] _ in guard let self = self else { return } - self.eventPublisher.send(.appreciationDidChange(self.appreciationTextView.text)) + self.eventPublisher.send(.memoDidChange(self.appreciationTextView.text)) } .store(in: &cancellables) @@ -187,7 +221,7 @@ final class NoteEditView: BaseView { } @objc private func appreciationTextViewDidBeginEditing(_ notification: Notification) { - currentFocusedInput = .appreciationTextView + currentFocusedInput = .memoTextView } @objc private func pageFieldDidBeginEditing(_ notification: Notification) { @@ -225,7 +259,7 @@ final class NoteEditView: BaseView { scrollToPageField() case .sentenceTextView: scrollToSentenceTextView() - case .appreciationTextView: + case .memoTextView: scrollToAppreciationTextView() case .none: break @@ -273,7 +307,7 @@ final class NoteEditView: BaseView { image: recordInfo.bookCoverImageUrl ) - pageField.setText("\(recordInfo.pageNumber)") + pageField.setText(recordInfo.pageNumber.map { "\($0)" } ?? "") sentenceTextView.setText(recordInfo.quote) if let review = recordInfo.review { @@ -282,30 +316,61 @@ final class NoteEditView: BaseView { // 감정 라벨은 selectedEmotion 바인딩에서만 설정 } - func setInitialEmotion(_ emotion: Emotion?) { - if let emotion = emotion { - emotionLabel.setText(text: emotion.rawValue) + func setEmotionInfo(primaryEmotion: PrimaryEmotion?, detailEmotions: [DetailEmotion]) { + guard let emotion = primaryEmotion else { + // 감정 미선택 상태 + emotionImageView.image = BKImage.Graphics.Note.default + emotionBadgeLabel.setText(text: "감정을 선택해주세요") + emotionBadgeView.backgroundColor = .bkBaseColor(.secondary) + emotionBadgeLabel.setColor(color: .bkContentColor(.tertiary)) + detailEmotionsLabel.isHidden = true + return + } + + // 감정 이미지 설정 (40x40 원형) + emotionImageView.image = emotion.noteImage + + // Badge 설정 + emotionBadgeLabel.setText(text: emotion.displayName) + + // 기타 감정일 때 별도 색상 처리 (카드 배경과 구분되도록) + if emotion == .other { + emotionBadgeView.backgroundColor = BKAtomicColor.Neutral.n200.color + emotionBadgeLabel.setColor(color: BKAtomicColor.Neutral.n400.color) + } else { + emotionBadgeView.backgroundColor = emotion.baseColor + emotionBadgeLabel.setColor(color: emotion.color) + } + + // 세부감정 태그 설정 + if detailEmotions.isEmpty { + detailEmotionsLabel.isHidden = true } else { - emotionLabel.setText(text: "감정을 선택해주세요") + let tags = detailEmotions.map { "#\($0.name)" }.joined(separator: " ") + detailEmotionsLabel.setText(text: tags) + detailEmotionsLabel.isHidden = false } } - - func updateEmotionLabel(_ text: String) { - emotionLabel.setText(text: text) + + override func layoutSubviews() { + super.layoutSubviews() + // pill 형태 (양 끝이 완전한 원형) + emotionBadgeView.layoutIfNeeded() + emotionBadgeView.layer.cornerRadius = emotionBadgeView.bounds.height / 2 } public func setSaveButtonEnabled(_ isEnabled: Bool) { saveButton.isDisabled = !isEnabled } - func getCurrentFormData() -> (page: Int?, sentence: String, appreciation: String) { + func getCurrentFormData() -> (page: Int?, sentence: String, memo: String) { let pageText = pageField.text.trimmingCharacters(in: .whitespacesAndNewlines) let sentenceText = sentenceTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) - let appreciationText = appreciationTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) - + let memoText = appreciationTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) + let page = pageText.isEmpty ? nil : Int(pageText) - - return (page: page, sentence: sentenceText, appreciation: appreciationText) + + return (page: page, sentence: sentenceText, memo: memoText) } override func setupLayout() { @@ -355,14 +420,39 @@ final class NoteEditView: BaseView { $0.height.equalTo(LayoutConstants.textViewHeight) } - emotionStatusView.snp.makeConstraints { + emotionTitleLabel.snp.makeConstraints { $0.top.equalTo(appreciationTextView.snp.bottom) .offset(LayoutConstants.fieldSpacing) + $0.leading.equalToSuperview() + .inset(LayoutConstants.horizontalInset) + } + + emotionCardView.snp.makeConstraints { + $0.top.equalTo(emotionTitleLabel.snp.bottom) + .offset(BKInset.inset1) $0.leading.trailing.equalToSuperview() .inset(LayoutConstants.horizontalInset) - $0.height.equalTo(LayoutConstants.emotionStatusViewHeight) + $0.height.greaterThanOrEqualTo(LayoutConstants.emotionCardMinHeight) $0.bottom.equalToSuperview().inset(LayoutConstants.fieldSpacing) } + + emotionImageView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(LayoutConstants.emotionCardPadding) + $0.centerY.equalToSuperview() + $0.size.equalTo(LayoutConstants.emotionImageSize) + } + + emotionInfoStack.snp.makeConstraints { + $0.leading.equalTo(emotionImageView.snp.trailing).offset(BKSpacing.spacing2) + $0.centerY.equalToSuperview() + $0.trailing.lessThanOrEqualTo(emotionChevronImageView.snp.leading).offset(-BKSpacing.spacing2) + } + + emotionChevronImageView.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(LayoutConstants.emotionCardPadding) + $0.centerY.equalToSuperview() + $0.size.equalTo(24) + } saveButton.snp.makeConstraints { $0.leading.trailing.equalToSuperview() @@ -399,8 +489,12 @@ private extension NoteEditView { static let resultViewHeight: CGFloat = 100 static let textFieldHeight: CGFloat = 82 static let textViewHeight: CGFloat = 172 - static let emotionStatusViewHeight: CGFloat = 24 static let saveButtonTopInset: CGFloat = 80 static let saveButtonBottomInset = BKInset.inset4 + + // Emotion Card + static let emotionCardMinHeight: CGFloat = 72 // 16(padding) + 40(image) + 16(padding) + static let emotionCardPadding = BKInset.inset4 // 16 + static let emotionImageSize: CGFloat = 40 } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/NoteEditViewController.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/NoteEditViewController.swift index 917424b0..ac6c21aa 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/NoteEditViewController.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/View/NoteEditViewController.swift @@ -25,6 +25,10 @@ final class NoteEditViewController: BaseViewController, ScreenLogg weak var coordinator: NoteEditCoordinator? let viewModel: AnyViewBindableViewModel private var cancellables = Set() + + // 현재 상태 추적 (EmotionEditViewController에 전달용) + private var currentPrimaryEmotion: PrimaryEmotion? + private var currentDetailEmotions: [DetailEmotion] = [] init(viewModel: NoteEditViewModel) { self.viewModel = AnyViewBindableViewModel(viewModel) @@ -61,11 +65,20 @@ final class NoteEditViewController: BaseViewController, ScreenLogg .store(in: &cancellables) viewModel.statePublisher - .map { $0.selectedEmotion } - .removeDuplicates() + .map { state -> (PrimaryEmotion?, [DetailEmotion]) in + (state.selectedPrimaryEmotion, state.selectedDetailEmotions) + } + .removeDuplicates { (prev: (PrimaryEmotion?, [DetailEmotion]), curr: (PrimaryEmotion?, [DetailEmotion])) in + prev.0 == curr.0 && prev.1 == curr.1 + } .receive(on: DispatchQueue.main) - .sink { [weak self] emotion in - self?.contentView.setInitialEmotion(emotion) + .sink { [weak self] emotionTuple in + self?.currentPrimaryEmotion = emotionTuple.0 + self?.currentDetailEmotions = emotionTuple.1 + self?.contentView.setEmotionInfo( + primaryEmotion: emotionTuple.0, + detailEmotions: emotionTuple.1 + ) } .store(in: &cancellables) @@ -144,8 +157,8 @@ final class NoteEditViewController: BaseViewController, ScreenLogg self.viewModel.send(.pageDidChange(text)) case .sentenceDidChange(let text): self.viewModel.send(.sentenceDidChange(text)) - case .appreciationDidChange(let text): - self.viewModel.send(.appreciationDidChange(text)) + case .memoDidChange(let text): + self.viewModel.send(.memoDidChange(text)) } } .store(in: &cancellables) @@ -154,17 +167,22 @@ final class NoteEditViewController: BaseViewController, ScreenLogg private extension NoteEditViewController { func presentEmotionEdit() { + // 항상 감정 선택 화면으로 이동 (바텀시트는 EmotionEditViewController에서 처리) viewModel.send(.presentEmotionEdit) } - - func handlePresentEmotionEdit(emotion: Emotion?) { - coordinator?.didTapEmotionEdit(currentEmotion: emotion) { [weak self] selectedEmotion in - self?.handleEmotionSelected(selectedEmotion) + + func handlePresentEmotionEdit(emotion: PrimaryEmotion?) { + coordinator?.didTapEmotionEdit( + currentEmotion: emotion, + currentDetailEmotions: currentDetailEmotions + ) { [weak self] selectedEmotion, selectedDetailEmotions in + self?.handleEmotionEditCompleted(emotion: selectedEmotion, detailEmotions: selectedDetailEmotions) } } - - func handleEmotionSelected(_ emotion: Emotion) { + + func handleEmotionEditCompleted(emotion: PrimaryEmotion, detailEmotions: [DetailEmotion]) { viewModel.send(.emotionSelected(emotion)) + viewModel.send(.detailEmotionsSelected(detailEmotions)) } func handleSaveButtonTapped() { diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/ViewModel/NoteEditViewModel.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/ViewModel/NoteEditViewModel.swift index 31b7db62..3c28e851 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/ViewModel/NoteEditViewModel.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteEdit/ViewModel/NoteEditViewModel.swift @@ -8,42 +8,59 @@ import Foundation final class NoteEditViewModel: BaseViewModel { struct State { var recordInfo: RecordInfo? - var selectedEmotion: Emotion? + var selectedPrimaryEmotion: PrimaryEmotion? + var selectedDetailEmotions: [DetailEmotion] = [] var isLoading: Bool = false var error: DomainError? - var shouldPresentEmotionEdit: (emotion: Emotion?, timestamp: Date)? + var shouldPresentEmotionEdit: (emotion: PrimaryEmotion?, timestamp: Date)? var saveCompleted: Bool = false var deleteCompleted: Bool = false - - var currentFormData: (page: String, sentence: String, appreciation: String) = ("", "", "") - + + var currentFormData: (page: String, sentence: String, memo: String) = ("", "", "") + var initialRecordInfo: RecordInfo? - var initialSelectedEmotion: Emotion? + var initialSelectedPrimaryEmotion: PrimaryEmotion? + var initialSelectedDetailEmotions: [DetailEmotion] = [] var isDiff: Bool = false // 변경 내용이 있는지 추적 + + // Detail emotion sheet 관련 상태 + var detailEmotions: [DetailEmotion] = [] + var isLoadingEmotions: Bool = false + + // NoteEditViewController에서 사용하는 별칭 (backward compatibility) + var selectedEmotion: PrimaryEmotion? { + selectedPrimaryEmotion + } } - + enum Action { case onAppear case fetchRecordDetailSuccessed(RecordInfo) case errorOccured(DomainError) case errorHandled case presentEmotionEdit - case emotionSelected(Emotion) - + case emotionSelected(PrimaryEmotion) + case saveButtonTapped case patchRecordSuccessed(RecordInfo) case deleteButtonTapped case deleteRecordSuccessed - + case pageDidChange(String) case sentenceDidChange(String) - case appreciationDidChange(String) + case memoDidChange(String) + + // Detail emotion sheet + case fetchDetailEmotions(PrimaryEmotion) + case detailEmotionsFetched([DetailEmotion]) + case detailEmotionsSelected([DetailEmotion]) } - + enum SideEffect { case fetchRecordDetail(String) case patchRecord(String, NoteForm) case deleteRecord(String) + case fetchDetailEmotions(PrimaryEmotion) } @Published private var state: State @@ -53,6 +70,7 @@ final class NoteEditViewModel: BaseViewModel { @Autowired private var fetchRecordDetailUseCase: FetchRecordDetailUseCase @Autowired private var patchRecordUseCase: PatchRecordUseCase @Autowired private var deleteRecordUseCase: DeleteRecordUseCase + @Autowired private var fetchDetailEmotionsUseCase: FetchDetailEmotionsUseCase private let recordId: String @@ -89,18 +107,19 @@ final class NoteEditViewModel: BaseViewModel { case .fetchRecordDetailSuccessed(let recordInfo): newState.recordInfo = recordInfo newState.initialRecordInfo = recordInfo - + newState.currentFormData = ( - page: "\(recordInfo.pageNumber)", + page: recordInfo.pageNumber.map { "\($0)" } ?? "", sentence: recordInfo.quote, - appreciation: recordInfo.review ?? "" + memo: recordInfo.review ?? "" ) - + // 사용자가 이미 감정을 선택했다면 덮어쓰지 않음 - if newState.selectedEmotion == nil { - let initialEmotion = recordInfo.emotionTags.first - newState.selectedEmotion = initialEmotion - newState.initialSelectedEmotion = initialEmotion + if newState.selectedPrimaryEmotion == nil { + newState.selectedPrimaryEmotion = recordInfo.primaryEmotion + newState.initialSelectedPrimaryEmotion = recordInfo.primaryEmotion + newState.selectedDetailEmotions = recordInfo.detailEmotions + newState.initialSelectedDetailEmotions = recordInfo.detailEmotions } newState.isLoading = false newState.isDiff = false @@ -113,31 +132,36 @@ final class NoteEditViewModel: BaseViewModel { newState.error = nil case .presentEmotionEdit: - newState.shouldPresentEmotionEdit = (emotion: state.selectedEmotion, timestamp: Date()) - + newState.shouldPresentEmotionEdit = (emotion: state.selectedPrimaryEmotion, timestamp: Date()) + case .emotionSelected(let emotion): - newState.selectedEmotion = emotion + newState.selectedPrimaryEmotion = emotion + // 감정이 변경되면 세부감정 초기화 + newState.selectedDetailEmotions = [] newState.isDiff = checkForDiff(state: newState) case .saveButtonTapped: - guard let selectedEmotion = state.selectedEmotion, - let page = Int(state.currentFormData.page), + guard let selectedPrimaryEmotion = state.selectedPrimaryEmotion, !state.currentFormData.sentence.isEmpty else { break } - - // 감상평이 비어있으면 nil, 아니면 텍스트 전달 - let appreciation = state.currentFormData.appreciation.isEmpty + + // 페이지가 비어있으면 nil + let page = Int(state.currentFormData.page) + + // 메모가 비어있으면 nil, 아니면 텍스트 전달 + let memo = state.currentFormData.memo.isEmpty ? nil - : state.currentFormData.appreciation - + : state.currentFormData.memo + let noteForm = NoteForm( page: page, sentence: state.currentFormData.sentence, - emotion: selectedEmotion, - appreciation: appreciation + memo: memo, + primaryEmotion: selectedPrimaryEmotion, + detailEmotions: state.selectedDetailEmotions ) - + newState.isLoading = true effects.append(.patchRecord(recordId, noteForm)) @@ -145,9 +169,9 @@ final class NoteEditViewModel: BaseViewModel { newState.recordInfo = recordInfo newState.initialRecordInfo = recordInfo newState.currentFormData = ( - page: "\(recordInfo.pageNumber)", + page: recordInfo.pageNumber.map { "\($0)" } ?? "", sentence: recordInfo.quote, - appreciation: recordInfo.review ?? "" + memo: recordInfo.review ?? "" ) newState.isLoading = false newState.saveCompleted = true @@ -169,11 +193,23 @@ final class NoteEditViewModel: BaseViewModel { newState.currentFormData.sentence = text newState.isDiff = checkForDiff(state: newState) - case .appreciationDidChange(let text): - newState.currentFormData.appreciation = text + case .memoDidChange(let text): + newState.currentFormData.memo = text + newState.isDiff = checkForDiff(state: newState) + + case .fetchDetailEmotions(let emotion): + newState.isLoadingEmotions = true + effects.append(.fetchDetailEmotions(emotion)) + + case .detailEmotionsFetched(let emotions): + newState.detailEmotions = emotions + newState.isLoadingEmotions = false + + case .detailEmotionsSelected(let emotions): + newState.selectedDetailEmotions = emotions newState.isDiff = checkForDiff(state: newState) } - + return (newState, effects) } @@ -199,6 +235,12 @@ final class NoteEditViewModel: BaseViewModel { .map { _ in Action.deleteRecordSuccessed } .catch { Just(Action.errorOccured($0)) } .eraseToAnyPublisher() + + case .fetchDetailEmotions(let emotion): + return fetchDetailEmotionsUseCase.execute(for: emotion) + .map { Action.detailEmotionsFetched($0) } + .catch { Just(Action.errorOccured($0)) } + .eraseToAnyPublisher() } } @@ -215,14 +257,15 @@ final class NoteEditViewModel: BaseViewModel { guard let initialInfo = state.initialRecordInfo else { return false } - - let pageDiff = state.currentFormData.page != "\(initialInfo.pageNumber)" + + let pageDiff = state.currentFormData.page != (initialInfo.pageNumber.map { "\($0)" } ?? "") let sentenceDiff = state.currentFormData.sentence != initialInfo.quote - let appreciationDiff = state.currentFormData.appreciation != (initialInfo.review ?? "") - - let emotionDiff = state.selectedEmotion != state.initialSelectedEmotion - - return pageDiff || sentenceDiff || appreciationDiff || emotionDiff + let memoDiff = state.currentFormData.memo != (initialInfo.review ?? "") + + let emotionDiff = state.selectedPrimaryEmotion != state.initialSelectedPrimaryEmotion + let detailEmotionDiff = state.selectedDetailEmotions != state.initialSelectedDetailEmotions + + return pageDiff || sentenceDiff || memoDiff || emotionDiff || detailEmotionDiff } } diff --git a/src/SupportingFiles/Booket/Info.plist b/src/SupportingFiles/Booket/Info.plist index 79750d62..1535b65e 100644 --- a/src/SupportingFiles/Booket/Info.plist +++ b/src/SupportingFiles/Booket/Info.plist @@ -102,5 +102,7 @@ UIInterfaceOrientationPortrait + UIDesignRequiresCompatibility +