diff --git a/src/Projects/BKData/Sources/Constant/APIConfig.swift b/src/Projects/BKData/Sources/Constant/APIConfig.swift index 5a1d6b04..b97ef073 100644 --- a/src/Projects/BKData/Sources/Constant/APIConfig.swift +++ b/src/Projects/BKData/Sources/Constant/APIConfig.swift @@ -7,21 +7,17 @@ private final class BKDataBundleToken {} enum APIConfig { private static let bundle = Bundle(for: BKDataBundleToken.self) - /// API Base URL (xcconfig에서 /api까지만 포함) - private static let baseURL: String = { + /// V1 API Base URL (auth, books, users, home) + 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 = { - return baseURL + "/v2" + // V1 URL에서 v2로 변경 + return baseURL.replacingOccurrences(of: "/api/v1", with: "/api/v2") }() } diff --git a/src/Projects/BKData/Sources/DataAssembly.swift b/src/Projects/BKData/Sources/DataAssembly.swift index 9bcbf531..e3e836ae 100644 --- a/src/Projects/BKData/Sources/DataAssembly.swift +++ b/src/Projects/BKData/Sources/DataAssembly.swift @@ -166,10 +166,6 @@ 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/PrimaryEmotion.swift b/src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift index 399b7666..a2a4a783 100644 --- a/src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift +++ b/src/Projects/BKDomain/Sources/Entity/PrimaryEmotion.swift @@ -42,4 +42,15 @@ public enum PrimaryEmotion: String, CaseIterable, Codable { 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/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift index 3834a087..f404279d 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/EmotionRegistrationView.swift @@ -7,204 +7,471 @@ import SnapKit import UIKit struct EmotionRegistrationForm { - let emotion: Emotion + let primaryEmotion: PrimaryEmotion + let detailEmotions: [DetailEmotion] } -extension Emotion { - var emotionView: UIView { +// MARK: - ChipFlowLayoutView + +private final class ChipFlowLayoutView: UIView { + private var chipViews: [UIView] = [] + private let horizontalSpacing: CGFloat = 8 + private let verticalSpacing: CGFloat = 8 + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setChips(_ views: [UIView]) { + chipViews.forEach { $0.removeFromSuperview() } + chipViews = views + chipViews.forEach { addSubview($0) } + setNeedsLayout() + invalidateIntrinsicContentSize() + } + + override func layoutSubviews() { + super.layoutSubviews() + + let maxWidth = bounds.width + guard maxWidth > 0 else { return } + + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for chip in chipViews { + let chipSize = chip.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + if currentX + chipSize.width > maxWidth, currentX > 0 { + currentX = 0 + currentY += rowHeight + verticalSpacing + rowHeight = 0 + } + + chip.frame = CGRect(x: currentX, y: currentY, width: chipSize.width, height: chipSize.height) + currentX += chipSize.width + horizontalSpacing + rowHeight = max(rowHeight, chipSize.height) + } + + invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: CGSize { + let maxWidth = bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width - 32 - 32 + + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for chip in chipViews { + let chipSize = chip.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + if currentX + chipSize.width > maxWidth, currentX > 0 { + currentX = 0 + currentY += rowHeight + verticalSpacing + rowHeight = 0 + } + + currentX += chipSize.width + horizontalSpacing + rowHeight = max(rowHeight, chipSize.height) + } + + let totalHeight = chipViews.isEmpty ? 0 : currentY + rowHeight + return CGSize(width: UIView.noIntrinsicMetric, height: totalHeight) + } +} + +// MARK: - EmotionRowView + +private final class EmotionRowView: UIView { + private let primaryEmotion: PrimaryEmotion + private let containerView = UIView() + + private let mainContentView = UIView() + + private let iconImageView: UIImageView = { let imageView = UIImageView() - - switch self { - case .warmth: - imageView.image = BKImage.Graphics.warmEmotion - case .joy: - imageView.image = BKImage.Graphics.joyEmotion - case .sad: - imageView.image = BKImage.Graphics.sadEmotion - case .insight: - imageView.image = BKImage.Graphics.insightEmotion - default: - imageView.image = BKImage.Graphics.warmEmotion - } - - imageView.layer.cornerRadius = 12 + imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true + imageView.layer.cornerRadius = 30 + return imageView + }() + + private let textStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 0 + return stackView + }() + + private let titleLabel = BKLabel( + fontStyle: .headline2(weight: .semiBold) + ) + + private let descriptionLabel = BKLabel( + fontStyle: .label1(weight: .medium), + color: .bkContentColor(.tertiary) + ) + + private let chevronImageView: UIImageView = { + let imageView = UIImageView(image: BKImage.Icon.chevronRight) imageView.contentMode = .scaleAspectFit + imageView.tintColor = .bkContentColor(.tertiary) return imageView + }() + + private let checkboxImageView: UIImageView = { + let imageView = UIImageView(image: BKImage.Checkbox.icononlyActiveRectangle) + imageView.contentMode = .scaleAspectFit + imageView.isHidden = true + return imageView + }() + + private let chipContainerView = UIView() + private var chipContainerHeightConstraint: Constraint? + + private let chipFlowLayoutView = ChipFlowLayoutView() + + var isSelectedEmotion: Bool = false { + didSet { + updateSelectionState() + } + } + + var selectedDetailEmotions: [DetailEmotion] = [] { + didSet { + updateChips() + } + } + + var onDetailEmotionRemoved: ((DetailEmotion) -> Void)? + + init(primaryEmotion: PrimaryEmotion) { + self.primaryEmotion = primaryEmotion + super.init(frame: .zero) + setupUI() + setupLayout() + configure() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + addSubview(containerView) + containerView.addSubview(mainContentView) + + if primaryEmotion != .other { + mainContentView.addSubview(iconImageView) + mainContentView.addSubview(chevronImageView) + mainContentView.addSubview(checkboxImageView) + containerView.addSubview(chipContainerView) + chipContainerView.addSubview(chipFlowLayoutView) + } + + mainContentView.addSubview(textStack) + [titleLabel, descriptionLabel].forEach(textStack.addArrangedSubview(_:)) + + containerView.backgroundColor = .bkBaseColor(.secondary) + containerView.layer.cornerRadius = 12 + } + + private func setupLayout() { + containerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + if primaryEmotion != .other { + mainContentView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(84) + } + + iconImageView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(16) + $0.centerY.equalToSuperview() + $0.size.equalTo(60) + } + + textStack.snp.makeConstraints { + $0.leading.equalTo(iconImageView.snp.trailing).offset(16) + $0.centerY.equalToSuperview() + $0.width.equalTo(118) + } + + chevronImageView.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(16) + $0.centerY.equalToSuperview() + $0.size.equalTo(24) + } + + checkboxImageView.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(16) + $0.centerY.equalToSuperview() + $0.size.equalTo(24) + } + + chipContainerView.snp.makeConstraints { + $0.top.equalTo(mainContentView.snp.bottom) + $0.leading.trailing.bottom.equalToSuperview() + chipContainerHeightConstraint = $0.height.equalTo(0).constraint + } + + chipFlowLayoutView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(16) + $0.bottom.equalToSuperview().inset(12) + } + + chipContainerView.clipsToBounds = true + } else { + mainContentView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + textStack.snp.makeConstraints { + $0.leading.equalToSuperview().inset(16) + $0.verticalEdges.equalToSuperview().inset(12) + $0.trailing.equalToSuperview().inset(20) + } + } + } + + private func configure() { + iconImageView.image = primaryEmotion.noteImage + titleLabel.setText(text: primaryEmotion.displayName) + descriptionLabel.setText(text: primaryEmotion.description) + } + + private func updateSelectionState() { + if isSelectedEmotion { + containerView.layer.borderWidth = 1.5 + containerView.layer.borderColor = UIColor.bkBorderColor(.brand).cgColor + if primaryEmotion != .other { + chevronImageView.isHidden = true + checkboxImageView.isHidden = false + } + } else { + containerView.layer.borderWidth = 0 + containerView.layer.borderColor = nil + if primaryEmotion != .other { + chevronImageView.isHidden = false + checkboxImageView.isHidden = true + chipFlowLayoutView.setChips([]) + chipContainerHeightConstraint?.activate() + } + selectedDetailEmotions = [] + } + } + + private func updateChips() { + guard primaryEmotion != .other else { return } + + guard !selectedDetailEmotions.isEmpty else { + chipFlowLayoutView.setChips([]) + chipContainerHeightConstraint?.activate() + return + } + + chipContainerHeightConstraint?.deactivate() + + var chips: [UIView] = [] + for detailEmotion in selectedDetailEmotions { + let chip = BKRemovableChip(title: detailEmotion.name) { [weak self] in + self?.onDetailEmotionRemoved?(detailEmotion) + } + chips.append(chip) + } + chipFlowLayoutView.setChips(chips) } } +// MARK: - EmotionRegistrationView + final class EmotionRegistrationView: BaseView { private let inputChangedSubject = PassthroughSubject() - + private let containerView = UIView() + private let titleLabel = BKLabel( text: "문장에 대해 어떤 감정이 드셨나요?", fontStyle: .heading1(weight: .bold) ) - + private let subtitleLabel = BKLabel( text: "대표 감정을 한 가지 선택해주세요", fontStyle: .label1(weight: .medium), color: .bkContentColor(.tertiary) ) - - private var selectedEmotion: Emotion? - + + private var selectedPrimaryEmotion: PrimaryEmotion? + private var selectedDetailEmotions: [DetailEmotion] = [] + private var isLoadingEmotions: Bool = false + private let titleStack: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical - stackView.spacing = LayoutConstants.titleStackSpacing + stackView.spacing = 4 return stackView }() - - private let emotionVStack: UIStackView = { + + private let emotionListStack: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical - stackView.spacing = LayoutConstants.emotionStackSpacing + stackView.spacing = 8 return stackView }() - - private lazy var emotion1View = makeEmotionView(for: .warmth) - private lazy var emotion2View = makeEmotionView(for: .joy) - private lazy var emotion3View = makeEmotionView(for: .sad) - private lazy var emotion4View = makeEmotionView(for: .insight) - + + private var emotionRows: [PrimaryEmotion: EmotionRowView] = [:] + + // 외부에서 BottomSheet 표시를 위한 콜백 + var onEmotionSelected: ((PrimaryEmotion) -> Void)? + override func setupView() { addSubview(containerView) - containerView.addSubviews(titleStack, emotionVStack) + containerView.addSubviews(titleStack, emotionListStack) [titleLabel, subtitleLabel].forEach(titleStack.addArrangedSubview(_:)) - makeEmotionHStack() + setupEmotionRows() } - + override func setupLayout() { containerView.snp.makeConstraints { $0.edges.equalToSuperview() } - + titleStack.snp.makeConstraints { $0.top.equalToSuperview() $0.horizontalEdges.equalToSuperview() .inset(LayoutConstants.horizontalInset) } - - emotionVStack.snp.makeConstraints { + + emotionListStack.snp.makeConstraints { $0.top.equalTo(titleStack.snp.bottom) - .offset(LayoutConstants.emotionVStackOffset) + .offset(LayoutConstants.listTopOffset) $0.horizontalEdges.equalToSuperview() .inset(LayoutConstants.horizontalInset) $0.bottom.equalToSuperview() } } -} -extension EmotionRegistrationView: RegistrationFormProvidable, FormInputNotifiable { - var inputChangedPublisher: AnyPublisher { - inputChangedSubject.eraseToAnyPublisher() - } + private func setupEmotionRows() { + let emotions: [PrimaryEmotion] = [.warmth, .joy, .sadness, .insight, .other] - private var emotionButtons: [Emotion: UIView] { - [ - .warmth: emotion1View, - .joy: emotion2View, - .sad: emotion3View, - .insight: emotion4View - ] - } - - func registrationForm() -> RegistrationForm? { - guard let selectedEmotion else { return nil } - return .emotion(.init(emotion: selectedEmotion)) - } - - func setSelectedEmotion(_ emotion: Emotion) { - selectedEmotion = emotion - updateSelectionUI() - } -} + for emotion in emotions { + let rowView = EmotionRowView(primaryEmotion: emotion) + rowView.isUserInteractionEnabled = true -private extension EmotionRegistrationView { - func makeEmotionHStack() { - let firstStack = UIStackView() - let secondStack = UIStackView() - - [firstStack, secondStack].forEach { - $0.axis = .horizontal - $0.spacing = LayoutConstants.emotionStackSpacing - $0.distribution = .fillEqually - } - - [emotion1View, emotion2View] - .forEach(firstStack.addArrangedSubview(_:)) - - [emotion3View, emotion4View] - .forEach(secondStack.addArrangedSubview(_:)) - - [firstStack, secondStack].forEach(emotionVStack.addArrangedSubview(_:)) - } - - func makeEmotionView(for emotion: Emotion) -> UIView { - let wrapperView = UIView() - let imageView = UIImageView() + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(emotionRowTapped(_:))) + rowView.addGestureRecognizer(tapGesture) + rowView.tag = emotion.hashValue - switch emotion { - case .warmth: - imageView.image = BKImage.Graphics.warmEmotion - case .joy: - imageView.image = BKImage.Graphics.joyEmotion - case .sad: - imageView.image = BKImage.Graphics.sadEmotion - case .insight: - imageView.image = BKImage.Graphics.insightEmotion - default: - imageView.image = BKImage.Graphics.warmEmotion - } + // Set up chip removal callback + rowView.onDetailEmotionRemoved = { [weak self] detailEmotion in + self?.removeDetailEmotion(detailEmotion) + } - imageView.layer.cornerRadius = 12 - imageView.clipsToBounds = true - imageView.contentMode = .scaleAspectFit + emotionRows[emotion] = rowView + emotionListStack.addArrangedSubview(rowView) + } + } - wrapperView.addSubview(imageView) - imageView.snp.makeConstraints { - $0.edges.equalToSuperview() - $0.height.equalTo(imageView.snp.width).multipliedBy(1.325) + private func removeDetailEmotion(_ detailEmotion: DetailEmotion) { + selectedDetailEmotions.removeAll { $0 == detailEmotion } + if let selectedPrimaryEmotion, let rowView = emotionRows[selectedPrimaryEmotion] { + rowView.selectedDetailEmotions = selectedDetailEmotions } + inputChangedSubject.send(()) + } - wrapperView.isUserInteractionEnabled = true - let gesture = UITapGestureRecognizer(target: self, action: #selector(emotionTapped(_:))) - wrapperView.addGestureRecognizer(gesture) - wrapperView.tag = emotion.hashValue + @objc private func emotionRowTapped(_ sender: UITapGestureRecognizer) { + // 로딩 중에는 탭 무시 + guard !isLoadingEmotions else { return } - return wrapperView - } - - @objc func emotionTapped(_ sender: UITapGestureRecognizer) { guard let tappedView = sender.view, - let emotion = Emotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else { + let emotion = PrimaryEmotion.allCases.first(where: { $0.hashValue == tappedView.tag }) else { return } - selectedEmotion = emotion - updateSelectionUI() - inputChangedSubject.send(()) + // 다른 감정을 선택한 경우에만 세부감정 초기화 + if selectedPrimaryEmotion != emotion { + selectedPrimaryEmotion = emotion + selectedDetailEmotions = [] + updateSelectionUI() + } + + // other가 아닌 경우 바텀시트 표시 (같은 감정 재선택 시에도) + if emotion != .other { + onEmotionSelected?(emotion) + } else { + inputChangedSubject.send(()) + } + } + + private func updateSelectionUI() { + emotionRows.forEach { emotion, rowView in + rowView.isSelectedEmotion = (emotion == selectedPrimaryEmotion) + if emotion != selectedPrimaryEmotion { + rowView.selectedDetailEmotions = [] + } + } } - - func updateSelectionUI() { - emotionButtons.forEach { emotion, view in - view.layer.cornerRadius = BKRadius.medium - view.layer.borderWidth = (emotion == selectedEmotion) - ? 1.5 : 0 - view.layer.borderColor = (emotion == selectedEmotion) - ? UIColor.bkBorderColor(.brand).cgColor : nil + + /// 세부감정 선택 완료 시 호출 + func setDetailEmotions(_ detailEmotions: [DetailEmotion]) { + selectedDetailEmotions = detailEmotions + if let selectedPrimaryEmotion, let rowView = emotionRows[selectedPrimaryEmotion] { + rowView.selectedDetailEmotions = detailEmotions } + inputChangedSubject.send(()) + } + + /// 현재 선택된 감정 반환 + func getSelectedPrimaryEmotion() -> PrimaryEmotion? { + return selectedPrimaryEmotion + } + + /// 현재 선택된 세부감정 반환 + func getSelectedDetailEmotions() -> [DetailEmotion] { + return selectedDetailEmotions + } + + /// 로딩 상태 설정 + func setLoadingEmotions(_ isLoading: Bool) { + isLoadingEmotions = isLoading + } +} + +// MARK: - RegistrationFormProvidable & FormInputNotifiable + +extension EmotionRegistrationView: RegistrationFormProvidable, FormInputNotifiable { + var inputChangedPublisher: AnyPublisher { + inputChangedSubject.eraseToAnyPublisher() + } + + func registrationForm() -> RegistrationForm? { + guard let selectedPrimaryEmotion else { return nil } + + // 대분류 감정만 선택해도 유효 (세부감정은 선택사항) + return .emotion(.init(primaryEmotion: selectedPrimaryEmotion, detailEmotions: selectedDetailEmotions)) + } + + func setSelectedPrimaryEmotion(_ emotion: PrimaryEmotion) { + selectedPrimaryEmotion = emotion + updateSelectionUI() } } +// MARK: - Layout Constants + private extension EmotionRegistrationView { enum LayoutConstants { - static let titleStackSpacing = BKSpacing.spacing1 - static let emotionStackSpacing = BKSpacing.spacing3 static let horizontalInset = BKInset.inset5 - static let emotionVStackOffset: CGFloat = 24 + static let listTopOffset: CGFloat = 32 } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteView.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteView.swift index a6a2b05a..8026822d 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteView.swift @@ -1,6 +1,7 @@ // Copyright © 2025 Booket. All rights reserved import BKDesign +import BKDomain import Combine import SnapKit import UIKit @@ -8,7 +9,6 @@ import UIKit enum RegistrationForm { case sentence(SentenceRegistrationForm) case emotion(EmotionRegistrationForm) - case appreciation(SentenceAppreciationForm) } protocol RegistrationFormProvidable { @@ -23,29 +23,25 @@ final class NoteView: BaseView { let eventPublisher = PassthroughSubject() private var cancellables = Set() private var keyboardCancellables = Set() - + private var currentFocusedInput: FocusedInput = .none - + private enum FocusedInput { case none case pageField case sentenceTextView case scanButton - case appreciationTextView + case memoTextView } - + private lazy var sentenceView = SentenceRegistrationView() private lazy var emotionView = EmotionRegistrationView() - private lazy var appreciationView = SentenceAppreciationView { [weak self] in - self?.guideButtonTapped() - } private lazy var pageViews: [UIView] = [ sentenceView, - emotionView, - appreciationView + emotionView ] - + let pageControl = BKPageControl() private let containerView = UIView() private let contentScrollView = UIScrollView() @@ -55,15 +51,15 @@ final class NoteView: BaseView { stackView.distribution = .fillEqually return stackView }() - + private var nextButton: BKButtonGroup = .singleFullButton(title: "다음") - + override func setupView() { addSubview(containerView) containerView.addSubviews(pageControl, contentScrollView, nextButton) contentScrollView.addSubview(contentStackView) } - + override func configure() { contentScrollView.isPagingEnabled = true contentScrollView.isScrollEnabled = false @@ -72,83 +68,78 @@ final class NoteView: BaseView { pageControl.addTarget(self, action: #selector(pageControlChanged), for: .valueChanged) nextButton.primaryButton?.addTarget(self, action: #selector(nextButtonTapped), for: .touchUpInside) sentenceView.onTextScanTapped = { [weak self] in self?.eventPublisher.send(.didTapOCRButton) } - sentenceView.onPageFieldFocused = { [weak self] in + sentenceView.onPageFieldFocused = { [weak self] in self?.currentFocusedInput = .pageField } - sentenceView.onSentenceTextViewFocused = { [weak self] in + sentenceView.onSentenceTextViewFocused = { [weak self] in self?.currentFocusedInput = .sentenceTextView } - appreciationView.onAppreciationTextViewFocused = { [weak self] in - self?.currentFocusedInput = .appreciationTextView + sentenceView.onMemoTextViewFocused = { [weak self] in + self?.currentFocusedInput = .memoTextView + } + emotionView.onEmotionSelected = { [weak self] emotion in + self?.eventPublisher.send(.didSelectEmotion(emotion)) } let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) addGestureRecognizer(tapGesture) - + setupKeyboardHandling() - + if let notifiable = currentView as? FormInputNotifiable { notifiable.inputChangedPublisher .sink { [weak self] in self?.updateNextButtonEnabled() } .store(in: &cancellables) } } - + override func setupLayout() { containerView.snp.makeConstraints { $0.edges.equalToSuperview() } - + pageControl.snp.makeConstraints { $0.top.equalToSuperview() .inset(LayoutConstants.pageControlTopInset) $0.leading.trailing.equalToSuperview() .inset(LayoutConstants.horizontalInset) } - + nextButton.snp.makeConstraints { $0.leading.trailing.equalToSuperview() $0.top.equalTo(contentScrollView.snp.bottom) $0.bottom.equalTo(safeAreaLayoutGuide.snp.bottom) } - + contentScrollView.snp.makeConstraints { $0.top.equalTo(pageControl.snp.bottom) .offset(LayoutConstants.contentOffset) $0.leading.trailing.equalToSuperview() $0.bottom.equalTo(nextButton.snp.top) } - + contentStackView.snp.makeConstraints { $0.edges.equalTo(contentScrollView.contentLayoutGuide) $0.width.equalTo(contentScrollView.frameLayoutGuide) .multipliedBy(pageViews.count) $0.height.equalTo(contentScrollView.frameLayoutGuide) } - + makeInnerViews() } - - func setAppreciationText(_ text: String) { - appreciationView.setText(text) - } - - func startEditingIfNeeded() { - appreciationView.startEditingIfNeeded() - } } private extension NoteView { var currentView: UIView { pageViews[pageControl.currentPage] } - + func makeInnerViews() { pageViews.forEach { pageView in let scrollView = UIScrollView() scrollView.alwaysBounceVertical = false scrollView.showsVerticalScrollIndicator = false contentStackView.addArrangedSubview(scrollView) - + scrollView.addSubview(pageView) scrollView.snp.makeConstraints { $0.width.equalTo(scrollView.frameLayoutGuide) @@ -158,39 +149,30 @@ private extension NoteView { $0.edges.equalTo(scrollView.contentLayoutGuide) $0.width.equalTo(scrollView.frameLayoutGuide) } - + scrollView.setContentHuggingPriority(.defaultLow, for: .vertical) scrollView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) updateNextButtonEnabled() } } - + func createFormData() -> NoteForm? { let forms: [RegistrationForm] = pageViews.compactMap { view in (view as? RegistrationFormProvidable)?.registrationForm() } return .makeNoteForm(from: forms) } - - func guideButtonTapped() { - eventPublisher.send(.didTapGuideButton) - } - + func updateNextButtonEnabled() { guard pageControl.currentPage < pageViews.count else { return } - - if pageControl.currentPage == 2 { - nextButton.primaryButton?.isEnabled = true - } else { - let isValid = (currentView as? RegistrationFormProvidable)?.registrationForm() != nil - nextButton.primaryButton?.isEnabled = isValid - } + let isValid = (currentView as? RegistrationFormProvidable)?.registrationForm() != nil + nextButton.primaryButton?.isEnabled = isValid } - + @objc func pageControlChanged(_ sender: BKPageControl) { let xpos = CGFloat(sender.currentPage) * contentScrollView.bounds.width contentScrollView.setContentOffset(.init(x: xpos, y: 0), animated: true) - + // input change 관련 cancellable만 제거하고 재설정 cancellables.removeAll() @@ -199,35 +181,35 @@ private extension NoteView { .sink { [weak self] in self?.updateNextButtonEnabled() } .store(in: &cancellables) } - + if pageControl.currentPage >= pageViews.count - 1 { nextButton.primaryButton?.title = "기록 완료" } else { nextButton.primaryButton?.title = "다음" } - + updateNextButtonEnabled() } - + @objc func nextButtonTapped() { var next = min(pageControl.currentPage + 1, pageViews.count - 1) - + if pageControl.currentPage + 1 == pageViews.count { if let formData = createFormData() { eventPublisher.send(.completeForm(formData)) return } else { next = 0 } } - + pageControl.currentPage = next pageControlChanged(pageControl) } - + @objc private func dismissKeyboard() { currentFocusedInput = .none endEditing(true) } - + private func setupKeyboardHandling() { let keyboardWillShow = NotificationCenter.default .publisher(for: UIResponder.keyboardWillShowNotification) @@ -235,25 +217,25 @@ private extension NoteView { guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return nil } return keyboardFrame.height } - + let keyboardWillHide = NotificationCenter.default .publisher(for: UIResponder.keyboardWillHideNotification) .map { _ in CGFloat.zero } - + Publishers.Merge(keyboardWillShow, keyboardWillHide) .sink { [weak self] height in self?.adjustForKeyboard(height: height) } .store(in: &keyboardCancellables) } - + private func adjustForKeyboard(height: CGFloat) { // 각 페이지 뷰의 스크롤뷰 contentInset 조정 contentStackView.arrangedSubviews.enumerated().forEach { index, view in guard let scrollView = view as? UIScrollView else { return } scrollView.contentInset.bottom = height scrollView.verticalScrollIndicatorInsets.bottom = height - + // 현재 페이지에서 키보드가 나타날 때만 스크롤 if index == pageControl.currentPage, height > 0 { // 포커스된 입력에 따라 적절한 스크롤 수행 @@ -264,72 +246,64 @@ private extension NoteView { if index == 0 { scrollToSentenceTextView(in: scrollView) } case .scanButton: if index == 0 { scrollToScanButton(in: scrollView) } - case .appreciationTextView: - if index == 2 { scrollToAppreciationTextView(in: scrollView) } + case .memoTextView: + if index == 0 { scrollToMemoTextView(in: scrollView) } case .none: break } } } } - + private func scrollToScanButton(in scrollView: UIScrollView) { guard let sentenceView = pageViews.first as? SentenceRegistrationView else { return } - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - // SentenceRegistrationView의 스캔 버튼이 보이도록 스크롤 let scanButtonFrame = sentenceView.scanButtonFrame let scanButtonGlobalFrame = sentenceView.convert(scanButtonFrame, to: scrollView) - - // 스캔 버튼이 키보드 위에 20pt 여백을 두고 보이도록 계산 + let visibleHeight = scrollView.frame.height - scrollView.contentInset.bottom let targetY = max(0, scanButtonGlobalFrame.maxY - visibleHeight + 20) - + scrollView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true) } } - + private func scrollToPageField(in scrollView: UIScrollView) { guard let sentenceView = pageViews.first as? SentenceRegistrationView else { return } - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - // 페이지 필드 라벨이 보이도록 스크롤 (상단 여백 포함) let pageFieldFrame = sentenceView.pageFieldFrame let pageFieldGlobalFrame = sentenceView.convert(pageFieldFrame, to: scrollView) - - // 페이지 필드 라벨이 상단에 20pt 여백을 두고 보이도록 + let targetY = max(0, pageFieldGlobalFrame.minY - 20) - + scrollView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true) } } - + private func scrollToSentenceTextView(in scrollView: UIScrollView) { guard let sentenceView = pageViews.first as? SentenceRegistrationView else { return } - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - // 문장 기록 라벨이 보이도록 스크롤 let sentenceTextViewFrame = sentenceView.sentenceTextViewFrame let sentenceTextViewGlobalFrame = sentenceView.convert(sentenceTextViewFrame, to: scrollView) - - // 문장 기록 라벨이 상단에 20pt 여백을 두고 보이도록 + let targetY = max(0, sentenceTextViewGlobalFrame.minY - 20) - + scrollView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true) } } - - private func scrollToAppreciationTextView(in scrollView: UIScrollView) { - guard let appreciationView = pageViews[2] as? SentenceAppreciationView else { return } - + + private func scrollToMemoTextView(in scrollView: UIScrollView) { + guard let sentenceView = pageViews.first as? SentenceRegistrationView else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - // 감상 텍스트뷰 라벨이 보이도록 스크롤 - let appreciationTextViewFrame = appreciationView.appreciationTextViewFrame - let appreciationTextViewGlobalFrame = appreciationView.convert(appreciationTextViewFrame, to: scrollView) - - // 감상 텍스트뷰 라벨이 상단에 20pt 여백을 두고 보이도록 - let targetY = max(0, appreciationTextViewGlobalFrame.minY - 20) - + let memoTextViewFrame = sentenceView.memoTextViewFrame + let memoTextViewGlobalFrame = sentenceView.convert(memoTextViewFrame, to: scrollView) + + let targetY = max(0, memoTextViewGlobalFrame.minY - 20) + scrollView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true) } } @@ -344,7 +318,7 @@ private extension NoteView { } extension NoteView { - + /// 외부에서 이벤트를 직접 처리할 수 있는 메서드 func handleEvent(_ event: NoteViewEvent) { switch event { @@ -354,15 +328,30 @@ extension NoteView { break } } - + /// OCR 텍스트를 SentenceRegistrationView에 설정 func setScannedText(_ text: String) { sentenceView.setScannedText(text) - + // 현재 페이지가 문장 등록 페이지가 아니라면 해당 페이지로 이동 if pageControl.currentPage != 0 { pageControl.currentPage = 0 pageControlChanged(pageControl) } } + + /// 세부감정 선택 완료 시 호출 + func setDetailEmotions(_ detailEmotions: [DetailEmotion]) { + emotionView.setDetailEmotions(detailEmotions) + } + + /// 현재 선택된 세부감정 반환 + func getSelectedDetailEmotions() -> [DetailEmotion] { + return emotionView.getSelectedDetailEmotions() + } + + /// 감정 로딩 상태 설정 + func setLoadingEmotions(_ isLoading: Bool) { + emotionView.setLoadingEmotions(isLoading) + } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteViewController.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteViewController.swift index dfca9f22..d2693090 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteViewController.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/NoteViewController.swift @@ -8,33 +8,34 @@ import UIKit enum NoteViewEvent: Equatable { case completeForm(NoteForm) - case didTapGuideButton case didTapOCRButton case setScannedText(String) + case didSelectEmotion(PrimaryEmotion) } final class NoteViewController: BaseViewController, ScreenLoggable { var screenName: String = GATracking.RecordFlow.start weak var coordinator: NoteCoordinator? - + override var bkNavigationBarStyle: UINavigationController.BKNavigationBarStyle { return .standard(viewController: self) } - + override var bkNavigationTitle: String { "" } - + private var cancellable: Set = [] private let viewModel: AnyViewBindableViewModel - + private var pendingEmotionForSheet: PrimaryEmotion? + init(viewModel: NoteViewModel) { self.viewModel = AnyViewBindableViewModel(viewModel) super.init() } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.tabBarController?.tabBar.isHidden = true - + let backButton = UIBarButtonItem( image: BKImage.Icon.chevronLeft, style: .plain, @@ -44,13 +45,13 @@ final class NoteViewController: BaseViewController, ScreenLoggable { navigationItem.leftBarButtonItem = backButton navigationController?.interactivePopGestureRecognizer?.isEnabled = false } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.tabBarController?.tabBar.isHidden = false navigationController?.interactivePopGestureRecognizer?.isEnabled = true } - + override func bindAction() { contentView.eventPublisher .compactMap { event -> NoteForm? in @@ -61,21 +62,25 @@ final class NoteViewController: BaseViewController, ScreenLoggable { self?.viewModel.send(.submitNoteForm(form)) } .store(in: &cancellable) - + contentView.eventPublisher - .filter { $0 == .didTapGuideButton } + .filter { $0 == .didTapOCRButton } .sink { [weak self] _ in - self?.presentAppreciationGuide() + self?.coordinator?.showOCRScanner() } .store(in: &cancellable) - + contentView.eventPublisher - .filter { $0 == .didTapOCRButton } - .sink { [weak self] _ in - self?.coordinator?.showOCRScanner() + .compactMap { event -> PrimaryEmotion? in + if case let .didSelectEmotion(emotion) = event { return emotion } + return nil + } + .sink { [weak self] emotion in + self?.pendingEmotionForSheet = emotion + self?.viewModel.send(.fetchDetailEmotions(emotion)) } .store(in: &cancellable) - + // TODO: - Remove KVO Binding contentView.pageControl.publisher(for: \.currentPage) .removeDuplicates() @@ -85,27 +90,8 @@ final class NoteViewController: BaseViewController, ScreenLoggable { } .store(in: &cancellable) } - + override func bindState() { - viewModel.statePublisher - .receive(on: DispatchQueue.main) - .map { $0.selectedGuideText } - .removeDuplicates() - .filter { !$0.isEmpty } - .sink { [weak self] selectedText in - self?.contentView.setAppreciationText(selectedText) - } - .store(in: &cancellable) - - viewModel.statePublisher - .receive(on: DispatchQueue.main) - .map { $0.shouldStartEditing } - .filter { $0 } - .sink { [weak self] _ in - self?.contentView.startEditingIfNeeded() - } - .store(in: &cancellable) - viewModel.statePublisher .receive(on: DispatchQueue.main) .removeDuplicates { $0.createCompleted == $1.createCompleted } @@ -115,7 +101,7 @@ final class NoteViewController: BaseViewController, ScreenLoggable { self?.presentRegistrationSuccessDialog(recordInfo: $0) } .store(in: &cancellable) - + viewModel.statePublisher .map(\.error) .compactMap { $0 } @@ -126,7 +112,7 @@ final class NoteViewController: BaseViewController, ScreenLoggable { self?.viewModel.send(.errorHandled) } .store(in: &cancellable) - + viewModel.statePublisher .map(\.isRetrying) .removeDuplicates() @@ -135,7 +121,7 @@ final class NoteViewController: BaseViewController, ScreenLoggable { .sink { [weak self] _ in self?.coordinator?.presentCustomErrorAlert( subtitle: """ - 일시적인 오류로 + 일시적인 오류로 데이터를 불러올 수 없어요 """, onConfirm: { [weak self] in @@ -145,7 +131,7 @@ final class NoteViewController: BaseViewController, ScreenLoggable { self?.viewModel.send(.errorHandled) } .store(in: &cancellable) - + viewModel.statePublisher .map { $0.isLoading } .removeDuplicates() @@ -158,6 +144,36 @@ final class NoteViewController: BaseViewController, ScreenLoggable { } } .store(in: &cancellable) + + // Sync isLoadingEmotions state to view + viewModel.statePublisher + .map(\.isLoadingEmotions) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isLoading in + self?.contentView.setLoadingEmotions(isLoading) + } + .store(in: &cancellable) + + // Detail emotions loaded - present bottom sheet + // Watch for isLoadingEmotions transition from true to false (loading completed) + viewModel.statePublisher + .map { ($0.isLoadingEmotions, $0.detailEmotions) } + .scan((false, false, [DetailEmotion]())) { prev, current in + // (wasLoading, isLoading, detailEmotions) + (prev.1, current.0, current.1) + } + .filter { wasLoading, isLoading, _ in wasLoading && !isLoading } + .map { $0.2 } + .filter { !$0.isEmpty } + .receive(on: DispatchQueue.main) + .sink { [weak self] detailEmotions in + guard let self, + let primaryEmotion = self.pendingEmotionForSheet else { return } + self.presentDetailEmotionSheet(for: primaryEmotion, detailEmotions: detailEmotions) + self.pendingEmotionForSheet = nil + } + .store(in: &cancellable) } } @@ -165,15 +181,13 @@ extension NoteViewController { func setScannedText(_ text: String) { contentView.handleEvent(.setScannedText(text)) } - + private func logPageView(for pageIndex: Int) { switch pageIndex { case 0: // sentence 페이지 logScreenView(name: GATracking.RecordFlow.inputSentence) case 1: // emotion 페이지 logScreenView(name: GATracking.RecordFlow.selectEmotion) - case 2: // appreciation 페이지 - logScreenView(name: GATracking.RecordFlow.inputOpinion) default: break } @@ -189,10 +203,10 @@ private extension NoteViewController { contentView.pageControl.currentPage -= 1 } } - + func presentRegistrationSuccessDialog(recordInfo: RecordInfo) { logScreenView(name: GATracking.RecordFlow.complete) - + let imageView = UIImageView(image: BKImage.Graphics.noteCompleted) let dialog = BKDialog( title: "기록이 저장되었어요!", @@ -211,11 +225,11 @@ private extension NoteViewController { ), suppliedContentStyle: .upper(imageView) ) - + let dialogViewController = BKDialogViewController(dialog: dialog) present(dialogViewController, animated: true) } - + func presentCancelConfirmationDialog() { let dialog = BKDialog( title: "기록을 그만하고 나가시겠어요?", @@ -232,21 +246,28 @@ private extension NoteViewController { } ) ) - + let dialogViewController = BKDialogViewController(dialog: dialog) present(dialogViewController, animated: true) } - - func presentAppreciationGuide() { - logScreenView(name: GATracking.RecordFlow.inputHelp) - - let sheet = BKBottomSheetViewController.makeAppreciationGuideSheet( - confirmAction: { [weak self] selectedGuide in - self?.viewModel.send(.appreciationGuideSelected(selectedGuide.rawValue)) + + 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?.dismiss(animated: true) + }, + confirmAction: { [weak self] selectedDetailEmotions in + self?.contentView.setDetailEmotions(selectedDetailEmotions) self?.dismiss(animated: true) } ) - + sheet.show(from: self, animated: true) } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceAppreciationView.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceAppreciationView.swift index 46442d9e..492dac4c 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceAppreciationView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceAppreciationView.swift @@ -144,13 +144,11 @@ extension SentenceAppreciationView: RegistrationFormProvidable, FormInputNotifia } func registrationForm() -> RegistrationForm? { - let trimmedSentence = appreciationTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) - - return .appreciation(SentenceAppreciationForm( - appreciation: trimmedSentence - )) + // NOTE: Step3(SentenceAppreciationView)는 Phase 4에서 제거 예정 + // 메모 기능이 Step1으로 통합되었으므로 nil 반환 + return nil } - + } private extension SentenceAppreciationView { diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceRegistrationView.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceRegistrationView.swift index 6ba611dd..96e96f3a 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceRegistrationView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/View/SentenceRegistrationView.swift @@ -6,15 +6,17 @@ import SnapKit import UIKit struct SentenceRegistrationForm { - let page: Int + let page: Int? let sentence: String + let memo: String? } final class SentenceRegistrationView: BaseView { private let inputChangedSubject = PassthroughSubject() private var cancellables = Set() - - private let containerView = UIView() + + // MARK: - Title Section + private let titleLabel = BKLabel( text: """ 기록하고 싶은 페이지와 @@ -22,59 +24,133 @@ final class SentenceRegistrationView: BaseView { """, fontStyle: .heading1(weight: .bold) ) - - private let pageField = BKTextFieldView( - labelText: "책 페이지", - placeholder: "기록하고 싶은 페이지를 작성해보세요" + + // MARK: - Sentence Section (Required) + + private let sentenceLabelRow: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = LayoutConstants.labelBadgeSpacing + stackView.alignment = .center + return stackView + }() + + private let sentenceLabel = BKLabel( + text: "문장 기록", + fontStyle: .body1(weight: .medium) ) - + private let sentenceTextView = BKTextView( - labelText: "문장 기록", placeholder: "기록하고 싶은 문장을 작성해보세요" ) - + private let textScanButton = BKButton( - style: .stroke, - size: .rounded + style: .tertiary, + size: .medium ) - - private let tooltipView = TooltipView(text: "스캔으로 빠르게 입력해요") - + + // MARK: - Page Section (Optional) + + private let pageLabelRow: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = LayoutConstants.labelBadgeSpacing + stackView.alignment = .center + return stackView + }() + + private let pageLabel = BKLabel( + text: "책 페이지", + fontStyle: .body1(weight: .medium) + ) + + private let pageBadge = BadgeView(title: "선택") + + private let pageField = BKTextFieldView( + placeholder: "기록하고 싶은 페이지를 작성해보세요" + ) + + // MARK: - Memo Section (Optional) + + private let memoLabelRow: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = LayoutConstants.labelBadgeSpacing + stackView.alignment = .center + return stackView + }() + + private let memoLabel = BKLabel( + text: "메모", + fontStyle: .body1(weight: .medium) + ) + + private let memoBadge = BadgeView(title: "선택") + + private let memoTextView = BKTextView( + placeholder: "기록하고 싶은 메모가 있다면 작성해보세요" + ) + + // MARK: - Callbacks + var onTextScanTapped: (() -> Void)? var onPageFieldFocused: (() -> Void)? var onSentenceTextViewFocused: (() -> Void)? - + var onMemoTextViewFocused: (() -> Void)? + + // MARK: - Frame Accessors + var scanButtonFrame: CGRect { return textScanButton.frame } - + var pageFieldFrame: CGRect { return pageField.frame } - + var sentenceTextViewFrame: CGRect { return sentenceTextView.frame } - + + var memoTextViewFrame: CGRect { + return memoTextView.frame + } + + // MARK: - Lifecycle + override init(frame: CGRect = .zero) { super.init(frame: frame) bindInputs() } - + deinit { NotificationCenter.default.removeObserver(self) } - + override func setupView() { + // Sentence label row + [sentenceLabel].forEach(sentenceLabelRow.addArrangedSubview(_:)) + + // Page label row with badge + [pageLabel, pageBadge].forEach(pageLabelRow.addArrangedSubview(_:)) + pageLabelRow.addArrangedSubview(UIView()) // spacer + + // Memo label row with badge + [memoLabel, memoBadge].forEach(memoLabelRow.addArrangedSubview(_:)) + memoLabelRow.addArrangedSubview(UIView()) // spacer + addSubviews( titleLabel, - pageField, + sentenceLabelRow, sentenceTextView, textScanButton, - tooltipView + pageLabelRow, + pageField, + memoLabelRow, + memoTextView ) } - + override func configure() { titleLabel.numberOfLines = .zero textScanButton.title = "문장 스캔하기" @@ -82,111 +158,150 @@ final class SentenceRegistrationView: BaseView { pageField.setTextFieldDelegate(self) pageField.setTextFieldKeyboardType(.numberPad) textScanButton.addTarget(self, action: #selector(textScanButtonTapped), for: .touchUpInside) - + setupTextFieldFocusHandling() } - + override func setupLayout() { titleLabel.snp.makeConstraints { $0.top.equalToSuperview() $0.leading.trailing.equalToSuperview() .inset(LayoutConstants.horizontalInset) } - - pageField.snp.makeConstraints { + + // Sentence section + sentenceLabelRow.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom) - .offset(LayoutConstants.pageFieldOffset) + .offset(LayoutConstants.sectionTopOffset) $0.leading.trailing.equalToSuperview() .inset(LayoutConstants.horizontalInset) } - + sentenceTextView.snp.makeConstraints { - $0.top.equalTo(pageField.snp.bottom) - .offset(LayoutConstants.sentenceViewOffset) + $0.top.equalTo(sentenceLabelRow.snp.bottom) + .offset(LayoutConstants.labelToFieldSpacing) $0.leading.trailing.equalToSuperview() .inset(LayoutConstants.horizontalInset) } - + textScanButton.snp.makeConstraints { $0.top.equalTo(sentenceTextView.snp.bottom) .offset(LayoutConstants.buttonOffset) - $0.trailing.equalToSuperview() + $0.leading.trailing.equalToSuperview() .inset(LayoutConstants.horizontalInset) - $0.bottom.equalToSuperview() } - - tooltipView.snp.makeConstraints { - $0.centerY.equalTo(textScanButton) - $0.trailing.equalTo(textScanButton.snp.leading).offset(-8) - $0.height.equalTo(34) + + // Page section + pageLabelRow.snp.makeConstraints { + $0.top.equalTo(textScanButton.snp.bottom) + .offset(LayoutConstants.sectionSpacing) + $0.leading.trailing.equalToSuperview() + .inset(LayoutConstants.horizontalInset) + } + + pageField.snp.makeConstraints { + $0.top.equalTo(pageLabelRow.snp.bottom) + .offset(LayoutConstants.labelToFieldSpacing) + $0.leading.trailing.equalToSuperview() + .inset(LayoutConstants.horizontalInset) + } + + // Memo section + memoLabelRow.snp.makeConstraints { + $0.top.equalTo(pageField.snp.bottom) + .offset(LayoutConstants.sectionSpacing) + $0.leading.trailing.equalToSuperview() + .inset(LayoutConstants.horizontalInset) + } + + memoTextView.snp.makeConstraints { + $0.top.equalTo(memoLabelRow.snp.bottom) + .offset(LayoutConstants.labelToFieldSpacing) + $0.leading.trailing.equalToSuperview() + .inset(LayoutConstants.horizontalInset) + $0.bottom.equalToSuperview() } } } +// MARK: - RegistrationFormProvidable & FormInputNotifiable + extension SentenceRegistrationView: RegistrationFormProvidable, FormInputNotifiable { var inputChangedPublisher: AnyPublisher { inputChangedSubject.eraseToAnyPublisher() } - + func registrationForm() -> RegistrationForm? { - let trimmedPage = pageField.text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedSentence = sentenceTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPage.isEmpty, - !trimmedSentence.isEmpty, - let page = Int(trimmedPage) - else { + guard !trimmedSentence.isEmpty else { return nil } + let trimmedPage = pageField.text.trimmingCharacters(in: .whitespacesAndNewlines) + let page: Int? = trimmedPage.isEmpty ? nil : Int(trimmedPage) + + let trimmedMemo = memoTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) + let memo: String? = trimmedMemo.isEmpty ? nil : trimmedMemo + return .sentence(SentenceRegistrationForm( page: page, - sentence: trimmedSentence + sentence: trimmedSentence, + memo: memo )) } - + private func bindInputs() { pageField.textDidChangePublisher .merge(with: sentenceTextView.textDidChangePublisher) + .merge(with: memoTextView.textDidChangePublisher) .sink { [weak self] _ in self?.inputChangedSubject.send(()) } .store(in: &cancellables) } - + @objc func textScanButtonTapped() { - tooltipView.isHidden = true onTextScanTapped?() } - + func setScannedText(_ text: String) { sentenceTextView.setText(text) inputChangedSubject.send(()) } - + private func setupTextFieldFocusHandling() { - // BKTextView의 메서드를 통한 포커스 감지 sentenceTextView.addTextViewFocusObserver( target: self, - selector: #selector(textViewDidBeginEditing) + selector: #selector(sentenceTextViewDidBeginEditing) ) - - // BKTextFieldView의 메서드를 통한 포커스 감지 + pageField.addTextFieldFocusObserver( target: self, selector: #selector(pageFieldDidBeginEditing) ) + + memoTextView.addTextViewFocusObserver( + target: self, + selector: #selector(memoTextViewDidBeginEditing) + ) } - - @objc private func textViewDidBeginEditing(_ notification: Notification) { + + @objc private func sentenceTextViewDidBeginEditing(_ notification: Notification) { onSentenceTextViewFocused?() } - + @objc private func pageFieldDidBeginEditing(_ notification: Notification) { onPageFieldFocused?() } + + @objc private func memoTextViewDidBeginEditing(_ notification: Notification) { + onMemoTextViewFocused?() + } } +// MARK: - UITextFieldDelegate + extension SentenceRegistrationView: UITextFieldDelegate { func textField( _ textField: UITextField, @@ -212,13 +327,15 @@ extension SentenceRegistrationView: UITextFieldDelegate { } } +// MARK: - Layout Constants + private extension SentenceRegistrationView { enum LayoutConstants { static let horizontalInset = BKInset.inset5 - static let buttonBorderWidth = BKBorder.border1 - static let pageFieldOffset: CGFloat = 40 - static let sentenceViewOffset: CGFloat = 32 + static let sectionTopOffset: CGFloat = 40 + static let sectionSpacing: CGFloat = 48 + static let labelToFieldSpacing: CGFloat = 8 + static let labelBadgeSpacing: CGFloat = 8 static let buttonOffset: CGFloat = 12 - static let buttonHeight: CGFloat = 38 } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift index 560fe43d..29ee7e7c 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteForm.swift @@ -3,52 +3,82 @@ import BKDomain struct NoteForm: Equatable { - let page: Int + let page: Int? let sentence: String - let emotion: Emotion - let appreciation: String? + 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 { static func makeNoteForm(from forms: [RegistrationForm]) -> NoteForm? { var page: Int? var sentence: String? - var emotion: Emotion? - var appreciation: String? + var memo: String? + var primaryEmotion: PrimaryEmotion? + var detailEmotions: [DetailEmotion] = [] for form in forms { switch form { case .sentence(let s): page = s.page sentence = s.sentence + memo = s.memo case .emotion(let e): - emotion = e.emotion - case .appreciation(let a): - appreciation = a.appreciation + primaryEmotion = e.primaryEmotion + detailEmotions = e.detailEmotions } } - - guard let finalPage = page, - let finalSentence = sentence, - let finalEmotion = emotion, - let finalAppreciation = appreciation else { + + guard let finalSentence = sentence, + let finalPrimaryEmotion = primaryEmotion else { return nil } return NoteForm( - page: finalPage, + page: page, sentence: finalSentence, - emotion: finalEmotion, - appreciation: finalAppreciation + memo: memo, + primaryEmotion: finalPrimaryEmotion, + detailEmotions: detailEmotions ) } - + func toRecordVO() -> RecordVO { return RecordVO( pageNumber: page, quote: sentence, - review: appreciation, - emotionTags: [emotion.rawValue] + memo: memo, + primaryEmotion: primaryEmotion, + detailEmotionIds: detailEmotions.map { $0.id } ) } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift b/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift index 89979417..f54c5ff8 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Note/ViewModel/NoteViewModel.swift @@ -7,69 +7,68 @@ import Foundation final class NoteViewModel: BaseViewModel { struct State: Equatable { - var selectedGuideText: String = "" var createCompleted: Bool = false - var shouldStartEditing: Bool = false var isLoading: Bool = false var recordInfo: RecordInfo? var error: DomainError? = nil var isRetrying: Bool = false + var detailEmotions: [DetailEmotion] = [] + var isLoadingEmotions: Bool = false } - + enum Action { - case appreciationGuideSelected(String) case submitNoteForm(NoteForm) case submitNoteFormSuccessed(RecordInfo) case errorOccured(DomainError) case errorHandled case retryTapped + case fetchDetailEmotions(PrimaryEmotion) + case fetchDetailEmotionsSuccessed([DetailEmotion]) } - + enum SideEffect { case submit(NoteForm) + case fetchDetailEmotions(PrimaryEmotion) } - + @Published private var state: State = State() private var cancellables = Set() private let sideEffectSubject = PassthroughSubject() private let bookId: String private var lastEffect: SideEffect? = nil - + @Autowired var createRecordUseCase: CreateRecordUseCase - + @Autowired var fetchDetailEmotionsUseCase: FetchDetailEmotionsUseCase + var statePublisher: AnyPublisher { $state.eraseToAnyPublisher() } - + init(bookId: String) { self.bookId = bookId bindSideEffects() } - + func send(_ action: Action) { let (newState, effects) = reduce(action: action, state: state) state = newState effects.forEach { sideEffectSubject.send($0) } } - + func reduce(action: Action, state: State) -> (State, [SideEffect]) { var newState = state var effects: [SideEffect] = [] - + switch action { - case .appreciationGuideSelected(let guideText): - newState.selectedGuideText = guideText - newState.shouldStartEditing = true - case .submitNoteForm(let noteForm): newState.isLoading = true effects.append(.submit(noteForm)) - + case .submitNoteFormSuccessed(let recordInfo): newState.isLoading = false newState.createCompleted = true newState.recordInfo = recordInfo - + case .errorOccured(let error): newState.isLoading = false if newState.isRetrying == false { @@ -83,14 +82,22 @@ final class NoteViewModel: BaseViewModel { if let last = lastEffect { effects.append(last) } - + case .errorHandled: newState.error = nil + + case .fetchDetailEmotions(let primaryEmotion): + newState.isLoadingEmotions = true + effects.append(.fetchDetailEmotions(primaryEmotion)) + + case .fetchDetailEmotionsSuccessed(let detailEmotions): + newState.isLoadingEmotions = false + newState.detailEmotions = detailEmotions } - + return (newState, effects) } - + func handle(_ effect: SideEffect) -> AnyPublisher { switch effect { case .submit(let noteForm): @@ -104,9 +111,15 @@ final class NoteViewModel: BaseViewModel { return Just(Action.errorOccured($0)) } .eraseToAnyPublisher() + + case .fetchDetailEmotions(let primaryEmotion): + return fetchDetailEmotionsUseCase.execute(for: primaryEmotion) + .map { Action.fetchDetailEmotionsSuccessed($0) } + .catch { Just(Action.errorOccured($0)) } + .eraseToAnyPublisher() } } - + private func bindSideEffects() { sideEffectSubject .flatMap { [weak self] effect in diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift index 09afd71e..89e8bfc6 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/AppreciationResultView.swift @@ -5,166 +5,342 @@ import BKDomain import SnapKit import UIKit -enum EmotionIcon: String { - case warmth = "#따뜻함" - case joy = "#즐거움" - case sadness = "#슬픔" - case insight = "#깨달음" - - var icon: UIImage { - switch self { - case .warmth: return BKImage.Graphics.warmCircle - case .joy: return BKImage.Graphics.joyCircle - case .sadness: return BKImage.Graphics.sadCircle - case .insight: return BKImage.Graphics.insightCircle - } - } - - static func from(emotion: Emotion) -> Self { - switch emotion { - case .warmth: - return .warmth - case .joy: - return .joy - case .sad: - return .sadness - case .insight: - return .insight - default: - return .warmth - } - } -} - final class AppreciationResultView: BaseView { private let containerView = UIView() - + private let titleLabel = BKLabel( text: "감상평 기록", fontStyle: .body1(weight: .medium), color: .bkContentColor(.primary) ) - + private let rootStackBackgroundView = UIView() private let rootStack: UIStackView = { let stackView = UIStackView() stackView.axis = .vertical stackView.spacing = LayoutConstants.rootStackSpacing - stackView.alignment = .center + stackView.alignment = .fill return stackView }() - - private let emotionIcon = UIImageView() - private let emotionLabel = BKLabel( - fontStyle: .body2(weight: .medium), - color: .bkContentColor(.brand) + + // 감상평 텍스트 + private let appreciationLabel = BKLabel( + fontStyle: .label1(weight: .medium), + color: .bkContentColor(.primary) ) - - private let emotionStack: UIStackView = { + + // 하단 컨테이너 (감정 정보 + 날짜) - UIView로 변경하여 유연한 레이아웃 + private let bottomContainerView = UIView() + + // 감정 아이콘 + private let emotionIcon: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = LayoutConstants.emotionIconSize / 2 + return imageView + }() + + // 대표 감정 뱃지 + private let emotionBadge: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal - stackView.spacing = LayoutConstants.emotionStackSpacing stackView.alignment = .center + stackView.layoutMargins = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + stackView.isLayoutMarginsRelativeArrangement = true return stackView }() - + + private let emotionBadgeLabel = BKLabel( + fontStyle: .label2(weight: .semiBold), + color: .bkContentColor(.brand) + ) + + // 세부 감정 태그들 (FlowLayout) + private let detailEmotionTagsView = TagFlowView() + + // 날짜 private let creationLabel = BKLabel( fontStyle: .label2(weight: .regular), color: .bkContentColor(.tertiary), alignment: .right ) - - private let summaryStack: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - return stackView - }() - - private let appreciationLabel = BKLabel( - fontStyle: .label1(weight: .medium), - color: .bkContentColor(.secondary) - ) - + override func setupView() { addSubview(containerView) - [emotionIcon, emotionLabel].forEach(emotionStack.addArrangedSubview(_:)) - [emotionStack, creationLabel].forEach(summaryStack.addArrangedSubview(_:)) - [summaryStack, appreciationLabel].forEach(rootStack.addArrangedSubview(_:)) + + // 뱃지 구성 + emotionBadge.addArrangedSubview(emotionBadgeLabel) + + // 하단 컨테이너에 요소들 추가 + bottomContainerView.addSubviews( + emotionIcon, + emotionBadge, + detailEmotionTagsView, + creationLabel + ) + + // 루트 스택 (감상평 + 하단 컨테이너) + [appreciationLabel, bottomContainerView].forEach(rootStack.addArrangedSubview(_:)) + rootStackBackgroundView.addSubview(rootStack) containerView.addSubviews(titleLabel, rootStackBackgroundView) } - + override func configure() { appreciationLabel.numberOfLines = 0 - rootStackBackgroundView.backgroundColor = .bkBaseColor(.secondary) + rootStackBackgroundView.backgroundColor = UIColor.bkBaseColor(.secondary) rootStackBackgroundView.layer.cornerRadius = LayoutConstants.rootStackRadius + emotionBadge.backgroundColor = UIColor.bkBackgroundColor(.tertiary) + emotionBadge.clipsToBounds = true } - + override func setupLayout() { containerView.snp.makeConstraints { $0.edges.equalToSuperview() } - + titleLabel.snp.makeConstraints { $0.top.leading.equalToSuperview() } - + rootStackBackgroundView.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom) .offset(LayoutConstants.rootStackTopOffset) $0.leading.trailing.bottom.equalToSuperview() } - + rootStack.snp.makeConstraints { $0.edges.equalToSuperview() .inset(LayoutConstants.rootStackInset) } - - appreciationLabel.snp.makeConstraints { - $0.width.equalToSuperview() + + // 감정 아이콘 + emotionIcon.snp.makeConstraints { + $0.leading.top.equalToSuperview() + $0.size.equalTo(LayoutConstants.emotionIconSize) } - - summaryStack.snp.makeConstraints { - $0.width.equalToSuperview() + + // 대표 감정 뱃지 (기본: emotionIcon 중앙 정렬, 세부 감정 있으면 상단 정렬로 변경) + emotionBadge.snp.makeConstraints { + $0.leading.equalTo(emotionIcon.snp.trailing).offset(LayoutConstants.emotionContainerSpacing) + $0.centerY.equalTo(emotionIcon) } - - emotionIcon.snp.makeConstraints { - $0.size.equalTo( - CGSize( - width: LayoutConstants.emotionIconSize, - height: LayoutConstants.emotionIconSize - ) - ) + + // 날짜 (trailing 고정, 기본 centerY는 emotionIcon 기준) + creationLabel.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.centerY.equalTo(emotionIcon) } + + // 세부 감정 태그 (뱃지 아래, trailing은 날짜와 16px 간격) + detailEmotionTagsView.snp.makeConstraints { + $0.leading.equalTo(emotionBadge) + $0.top.equalTo(emotionBadge.snp.bottom).offset(LayoutConstants.emotionInfoSpacing) + $0.trailing.equalTo(creationLabel.snp.leading).offset(-LayoutConstants.minSpacingToDate) + } + + // 하단 컨테이너 높이 (아이콘 또는 태그 중 큰 쪽) + bottomContainerView.snp.makeConstraints { + $0.bottom.greaterThanOrEqualTo(emotionIcon) + $0.bottom.greaterThanOrEqualTo(detailEmotionTagsView) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + // emotionBadge의 레이아웃을 먼저 확정시킨 후 cornerRadius 적용 + emotionBadge.layoutIfNeeded() + // pill 형태 (양 끝이 완전한 원형) + emotionBadge.layer.cornerRadius = emotionBadge.bounds.height / 2 } - + func apply( - emotion: EmotionIcon, + emotion: PrimaryEmotion, + detailEmotions: [DetailEmotion] = [], creationDate: Date, appreciation: String? = "" ) { - emotionIcon.image = emotion.icon - emotionLabel.setText(text: emotion.rawValue) - creationLabel.setText(text: creationDate.toKoreanDateString()) - + // 감정 아이콘 (원형 이미지) + emotionIcon.image = emotion.noteImage + + // 대표 감정 뱃지 + emotionBadgeLabel.setText(text: emotion.displayName) + emotionBadge.isHidden = false + + // 기타 감정일 때 칩 색상 변경 + if emotion == .other { + emotionBadge.backgroundColor = BKAtomicColor.Neutral.n200.color + emotionBadgeLabel.setColor(color: BKAtomicColor.Neutral.n400.color) + } else { + emotionBadge.backgroundColor = UIColor.bkBackgroundColor(.tertiary) + emotionBadgeLabel.setColor(color: UIColor.bkContentColor(.brand)) + } + + // 세부 감정 태그들 + updateDetailEmotionTags(detailEmotions) + + // 날짜 위치 업데이트 (세부 감정 유무에 따라 다름) + updateCreationLabelPosition(hasDetailEmotions: !detailEmotions.isEmpty) + + // 날짜 + creationLabel.setText(text: creationDate.toKoreanDotDateString()) + + // 감상평 if let review = appreciation, !review.isEmpty { - appreciationLabel.setText(text: review) - appreciationLabel.isHidden = false - rootStack.spacing = LayoutConstants.rootStackSpacing + appreciationLabel.setText(text: review) + appreciationLabel.isHidden = false + rootStack.spacing = LayoutConstants.rootStackSpacing + } else { + appreciationLabel.isHidden = true + rootStack.spacing = 0 + } + } + + private func updateDetailEmotionTags(_ detailEmotions: [DetailEmotion]) { + guard !detailEmotions.isEmpty else { + detailEmotionTagsView.isHidden = true + return + } + + detailEmotionTagsView.isHidden = false + let tags = detailEmotions.map { "#\($0.name)" } + detailEmotionTagsView.setTags(tags) + } + + /// 세부 감정 유무에 따라 레이아웃 업데이트 + /// - 세부 감정 없음: 뱃지와 날짜 모두 emotionIcon의 centerY에 맞춤 (중앙 정렬) + /// - 세부 감정 있음: 뱃지는 상단 정렬, 날짜는 태그 하단에 맞춤 + private func updateCreationLabelPosition(hasDetailEmotions: Bool) { + // 대표 감정 뱃지 위치 업데이트 + emotionBadge.snp.remakeConstraints { + $0.leading.equalTo(emotionIcon.snp.trailing).offset(LayoutConstants.emotionContainerSpacing) + if hasDetailEmotions { + $0.top.equalToSuperview() } else { - appreciationLabel.isHidden = true - rootStack.spacing = 0 + $0.centerY.equalTo(emotionIcon) + } + } + + // 날짜 위치 업데이트 + creationLabel.snp.remakeConstraints { + $0.trailing.equalToSuperview() + if hasDetailEmotions { + $0.bottom.equalTo(detailEmotionTagsView) + } else { + $0.centerY.equalTo(emotionIcon) + } + } + } +} + +// MARK: - TagFlowView + +/// 태그들을 FlowLayout으로 배치하는 뷰 +/// - 가로로 배치하다가 공간이 부족하면 다음 줄로 +/// - 태그가 중간에 끊기지 않음 +private final class TagFlowView: UIView { + private var tagLabels: [BKLabel] = [] + private let horizontalSpacing: CGFloat = 8 + private let verticalSpacing: CGFloat = 4 + + func setTags(_ tags: [String]) { + // 기존 태그 제거 + tagLabels.forEach { $0.removeFromSuperview() } + tagLabels.removeAll() + + // 새 태그 생성 + for tag in tags { + let label = BKLabel( + fontStyle: .caption1(weight: .regular), + color: .bkContentColor(.tertiary) + ) + label.setText(text: tag) + addSubview(label) + tagLabels.append(label) + } + + setNeedsLayout() + invalidateIntrinsicContentSize() + } + + override func layoutSubviews() { + super.layoutSubviews() + layoutTags() + } + + private func layoutTags() { + var x: CGFloat = 0 + var y: CGFloat = 0 + var lineHeight: CGFloat = 0 + + for label in tagLabels { + label.sizeToFit() + let labelWidth = label.bounds.width + let labelHeight = label.bounds.height + + // 현재 줄에 맞지 않으면 다음 줄로 + if x + labelWidth > bounds.width && x > 0 { + x = 0 + y += lineHeight + verticalSpacing + lineHeight = 0 + } + + label.frame = CGRect(x: x, y: y, width: labelWidth, height: labelHeight) + x += labelWidth + horizontalSpacing + lineHeight = max(lineHeight, labelHeight) + } + + // 높이가 변경되면 intrinsicContentSize 업데이트 + let newHeight = y + lineHeight + if abs(newHeight - bounds.height) > 1 { + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + guard !tagLabels.isEmpty else { + return CGSize(width: UIView.noIntrinsicMetric, height: 0) + } + + // 너비가 아직 결정되지 않았으면 기본값 사용 + let availableWidth = bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width - 100 + + var x: CGFloat = 0 + var y: CGFloat = 0 + var lineHeight: CGFloat = 0 + + for label in tagLabels { + label.sizeToFit() + let labelWidth = label.bounds.width + let labelHeight = label.bounds.height + + if x + labelWidth > availableWidth && x > 0 { + x = 0 + y += lineHeight + verticalSpacing + lineHeight = 0 } + + x += labelWidth + horizontalSpacing + lineHeight = max(lineHeight, labelHeight) + } + + return CGSize(width: UIView.noIntrinsicMetric, height: y + lineHeight) } } +// MARK: - LayoutConstants + private extension AppreciationResultView { enum LayoutConstants { - static let rootStackSpacing = BKSpacing.spacing3 + static let rootStackSpacing = BKSpacing.spacing5 static let rootStackTopOffset = BKInset.inset2 static let rootStackInset = BKInset.inset4 static let rootStackRadius = BKRadius.medium - static let emotionStackSpacing = BKSpacing.spacing2 + static let emotionContainerSpacing: CGFloat = 8 + static let emotionInfoSpacing: CGFloat = 4 static let emotionIconSize: CGFloat = 40 + static let badgeCornerRadius: CGFloat = 12 + static let minSpacingToDate: CGFloat = 16 } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift index 1052e5fa..bfc49dfe 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/CollectedSentenceView.swift @@ -80,10 +80,10 @@ final class CollectedSentenceView: BaseView { ) { let displayedText = "\"\(sentence)\"" collectedSentenceLabel.setText(text: displayedText) - if let page = page { + if let page { pageLabel.setText(text: "\(page)p") } else { - pageLabel.isHidden = true + pageLabel.setText(text: "-p") } } } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/NoteCompletionView.swift b/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/NoteCompletionView.swift index a8383508..008a41b2 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/NoteCompletionView.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/NoteCompletion/View/NoteCompletionView.swift @@ -44,7 +44,8 @@ final class NoteCompletionView: BaseView { ) appreciationResultView.apply( - emotion: EmotionIcon.from(emotion: recordInfo.emotionTags.first ?? .joy), + emotion: recordInfo.primaryEmotion, + detailEmotions: recordInfo.detailEmotions, creationDate: recordInfo.createdAt, appreciation: recordInfo.review ?? "" )