diff --git a/FLINT/Data/Sources/DTO/Base/BaseResponse.swift b/FLINT/Data/Sources/DTO/Base/BaseResponse.swift index cd097c8a..3a1832df 100644 --- a/FLINT/Data/Sources/DTO/Base/BaseResponse.swift +++ b/FLINT/Data/Sources/DTO/Base/BaseResponse.swift @@ -19,7 +19,7 @@ public struct BaseResponse: Codable { public let additionalInfo: [String: String]? public let status: Int - public let message: String + public let message: String? public let data: T? } @@ -36,7 +36,7 @@ extension BaseResponse { errorCode: errorCode ?? "", additionalInfo: additionalInfo ?? [:], status: status, - message: message + message: message ?? "" ) } } diff --git a/FLINT/Domain/Sources/UseCase/Profile/RecalculateKeywordsUseCase.swift b/FLINT/Domain/Sources/UseCase/Profile/RecalculateKeywordsUseCase.swift new file mode 100644 index 00000000..ea41d566 --- /dev/null +++ b/FLINT/Domain/Sources/UseCase/Profile/RecalculateKeywordsUseCase.swift @@ -0,0 +1,28 @@ +// +// RecalculateKeywordsUseCase.swift +// Domain +// +// Created by 진소은 on 6/20/26. +// + +import Combine +import Foundation + +import Repository + +public protocol RecalculateKeywordsUseCase { + func callAsFunction() -> AnyPublisher +} + +public final class DefaultRecalculateKeywordsUseCase: RecalculateKeywordsUseCase { + + private let userRepository: UserRepository + + public init(userRepository: UserRepository) { + self.userRepository = userRepository + } + + public func callAsFunction() -> AnyPublisher { + return userRepository.recalculateMyKeywords() + } +} diff --git a/FLINT/FLINT/Dependency/Factory/UseCase/Profile/RecalculateKeywordsUseCaseFactory.swift b/FLINT/FLINT/Dependency/Factory/UseCase/Profile/RecalculateKeywordsUseCaseFactory.swift new file mode 100644 index 00000000..b1df094a --- /dev/null +++ b/FLINT/FLINT/Dependency/Factory/UseCase/Profile/RecalculateKeywordsUseCaseFactory.swift @@ -0,0 +1,20 @@ +// +// RecalculateKeywordsUseCaseFactory.swift +// FLINT +// +// Created by 진소은 on 6/20/26. +// + +import Foundation + +import Domain + +protocol RecalculateKeywordsUseCaseFactory: UserRepositoryFactory { + func makeRecalculateKeywordsUseCase() -> RecalculateKeywordsUseCase +} + +extension RecalculateKeywordsUseCaseFactory { + func makeRecalculateKeywordsUseCase() -> RecalculateKeywordsUseCase { + return DefaultRecalculateKeywordsUseCase(userRepository: makeUserRepository()) + } +} diff --git a/FLINT/FLINT/Dependency/Factory/ViewModel/ProfileViewModelFactory.swift b/FLINT/FLINT/Dependency/Factory/ViewModel/ProfileViewModelFactory.swift index be70ecac..30265aab 100644 --- a/FLINT/FLINT/Dependency/Factory/ViewModel/ProfileViewModelFactory.swift +++ b/FLINT/FLINT/Dependency/Factory/ViewModel/ProfileViewModelFactory.swift @@ -10,12 +10,20 @@ import Foundation import Domain import Presentation -protocol ProfileViewModelFactory: FetchProfileUseCaseFactory, FetchKeywordsUseCaseFactory, FetchCreatedCollectionsUseCaseFactory, FetchBookmarkedCollectionsUseCaseFactory, FetchBookmarkedContentsUseCaseFactory { +protocol ProfileViewModelFactory: FetchProfileUseCaseFactory, FetchKeywordsUseCaseFactory, FetchCreatedCollectionsUseCaseFactory, FetchBookmarkedCollectionsUseCaseFactory, FetchBookmarkedContentsUseCaseFactory, RecalculateKeywordsUseCaseFactory { func makeProfileViewModel(target: UserTarget) -> ProfileViewModel } extension ProfileViewModelFactory { func makeProfileViewModel(target: UserTarget) -> ProfileViewModel { - return ProfileViewModel(target: target, fetchProfileUseCase: makeFetchProfileUseCase(), fetchKeywordsUseCase: makeFetchKeywordsUseCase(), fetchCreatedCollectionsUseCase: makeFetchCreatedCollectionsUseCase(), fetchBookmarkedCollectionsUseCase: makeFetchBookmarkedCollectionsUseCase(), fetchBookmarkedContentsUseCase: makeFetchBookmarkedContentsUseCase()) + return ProfileViewModel( + target: target, + fetchProfileUseCase: makeFetchProfileUseCase(), + fetchKeywordsUseCase: makeFetchKeywordsUseCase(), + fetchCreatedCollectionsUseCase: makeFetchCreatedCollectionsUseCase(), + fetchBookmarkedCollectionsUseCase: makeFetchBookmarkedCollectionsUseCase(), + fetchBookmarkedContentsUseCase: makeFetchBookmarkedContentsUseCase(), + recalculateKeywordsUseCase: makeRecalculateKeywordsUseCase() + ) } } diff --git a/FLINT/Presentation/Sources/View/Component/Cell/TitleHeaderTableViewCell.swift b/FLINT/Presentation/Sources/View/Component/Cell/TitleHeaderTableViewCell.swift index 1d90f1c9..8a3525e0 100644 --- a/FLINT/Presentation/Sources/View/Component/Cell/TitleHeaderTableViewCell.swift +++ b/FLINT/Presentation/Sources/View/Component/Cell/TitleHeaderTableViewCell.swift @@ -11,98 +11,251 @@ import SnapKit import Then public final class TitleHeaderTableViewCell: BaseTableViewCell { - + // MARK: - Type - + public enum TitleHeaderStyle { case normal case more } - + // MARK: - Public Event - + public var onTapMore: (() -> Void)? - + public var onTapInfo: (() -> Void)? + public var onTapRefresh: (() -> Void)? + // MARK: - UI - - private let middleGuideView = UIView() - + + private let titleStack = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .center + $0.spacing = 4 + } + private let titleLabel = UILabel().then { $0.textColor = .flintWhite $0.numberOfLines = 1 } - + + private let infoButton = UIButton().then { + $0.setImage(UIImage(resource: .icInfo), for: .normal) + $0.tintColor = .flintWhite + $0.isHidden = true + } + private let subtitleLabel = UILabel().then { $0.textColor = .flintGray200 $0.numberOfLines = 1 } - + + private let middleGuideView = UIView() + let moreButton = UIButton().then { $0.setImage(.icMore, for: .normal) $0.isHidden = true } - + + private let refreshStack = UIStackView().then { + $0.axis = .vertical + $0.alignment = .center + $0.spacing = 4 + $0.isHidden = true + $0.isUserInteractionEnabled = true + } + + private let refreshIconImageView = UIImageView().then { + $0.image = UIImage(named: "ic_refresh", in: .module, with: nil) + $0.tintColor = DesignSystem.Color.secondary400 + $0.contentMode = .scaleAspectFit + } + + private let refreshLabel = UILabel().then { + $0.numberOfLines = 1 + } + + private let tooltipView = UIView().then { + $0.backgroundColor = DesignSystem.Color.gray800 + $0.layer.cornerRadius = 12 + $0.layer.masksToBounds = true + $0.isHidden = true + } + + private let tooltipLabel = UILabel().then { + $0.numberOfLines = 0 + $0.lineBreakMode = .byWordWrapping + } + // MARK: - Override - + public override func setStyle() { backgroundColor = .clear contentView.backgroundColor = .clear selectionStyle = .none + clipsToBounds = false + contentView.clipsToBounds = false + layer.masksToBounds = false + contentView.layer.masksToBounds = false } - + public override func setHierarchy() { - contentView.addSubviews(titleLabel, subtitleLabel, moreButton, middleGuideView) + contentView.addSubviews(titleStack, subtitleLabel, moreButton, refreshStack, tooltipView, middleGuideView) + titleStack.addArrangedSubviews(titleLabel, infoButton) + refreshStack.addArrangedSubviews(refreshIconImageView, refreshLabel) + tooltipView.addSubview(tooltipLabel) setAction() } + public override func setLayout() { - titleLabel.snp.makeConstraints { + titleStack.snp.makeConstraints { $0.top.equalToSuperview() - $0.leading.trailing.equalToSuperview().inset(16) + $0.leading.equalToSuperview().inset(16) + $0.trailing.lessThanOrEqualToSuperview().inset(60) + } + + infoButton.snp.makeConstraints { + $0.size.equalTo(20) } - + subtitleLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(4) - $0.leading.trailing.equalTo(titleLabel) + $0.top.equalTo(titleStack.snp.bottom).offset(4) + $0.leading.equalToSuperview().inset(16) + $0.trailing.lessThanOrEqualToSuperview().inset(60) $0.bottom.equalToSuperview().inset(24) } - + middleGuideView.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom) + $0.top.equalTo(titleStack.snp.bottom) $0.bottom.equalTo(subtitleLabel.snp.top) $0.trailing.equalToSuperview() $0.width.equalTo(1) } - + moreButton.snp.makeConstraints { $0.centerY.equalTo(middleGuideView.snp.centerY) $0.trailing.equalToSuperview().inset(12) } + + refreshIconImageView.snp.makeConstraints { + $0.size.equalTo(24) + } + + refreshStack.snp.makeConstraints { + $0.top.equalToSuperview() + $0.trailing.equalToSuperview().inset(16) + } + + tooltipView.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(12) + $0.leading.equalToSuperview().inset(16) + $0.trailing.equalToSuperview().inset(13) + } + + tooltipLabel.snp.makeConstraints { + $0.top.bottom.equalToSuperview().inset(14) + $0.leading.trailing.equalToSuperview().inset(12) + } } - - + public override func prepare() { titleLabel.attributedText = nil subtitleLabel.attributedText = nil + refreshLabel.attributedText = nil + tooltipLabel.attributedText = nil moreButton.isHidden = true + infoButton.isHidden = true + refreshStack.isHidden = true + tooltipView.isHidden = true + layer.zPosition = 0 onTapMore = nil + onTapInfo = nil + onTapRefresh = nil } - + // MARK: - Action - + private func setAction() { moreButton.addTarget(self, action: #selector(didTapMore), for: .touchUpInside) + infoButton.addTarget(self, action: #selector(didTapInfo), for: .touchUpInside) + + let refreshTap = UITapGestureRecognizer(target: self, action: #selector(didTapRefresh)) + refreshStack.addGestureRecognizer(refreshTap) } - + @objc private func didTapMore() { onTapMore?() } - + + @objc private func didTapInfo() { + onTapInfo?() + } + + @objc private func didTapRefresh() { + onTapRefresh?() + } + // MARK: - Configure - - public func configure(style: TitleHeaderStyle, title: String, subtitle: String) { + + public func configure( + style: TitleHeaderStyle, + title: String, + subtitle: String, + showInfo: Bool = false, + showRefresh: Bool = false, + isRefreshing: Bool = false, + tooltipText: String? = nil + ) { moreButton.isHidden = (style != .more) - + infoButton.isHidden = !showInfo + refreshStack.isHidden = !showRefresh + titleLabel.attributedText = .pretendard(.head3_sb_18, text: title) subtitleLabel.attributedText = .pretendard(.body2_r_14, text: subtitle, color: .flintGray200) + refreshLabel.attributedText = .pretendard( + .micro1_m_10, + text: "업데이트", + color: DesignSystem.Color.secondary400 + ) + + if showRefresh && isRefreshing { + startRefreshAnimationIfNeeded() + } else { + stopRefreshAnimation() + } + + let showTooltip = tooltipText != nil + tooltipView.isHidden = !showTooltip + layer.zPosition = showTooltip ? 100 : 0 + if let text = tooltipText { + tooltipLabel.attributedText = .pretendard( + .body2_r_14, + text: text, + color: .flintGray300, + lineBreakMode: .byWordWrapping, + lineBreakStrategy: .hangulWordPriority + ) + } + } + + // MARK: - Refresh Animation + + private enum RefreshAnimation { + static let key = "refreshRotation" + static let duration: CFTimeInterval = 1.0 + } + + private func startRefreshAnimationIfNeeded() { + guard refreshIconImageView.layer.animation(forKey: RefreshAnimation.key) == nil else { return } + let rotation = CABasicAnimation(keyPath: "transform.rotation.z") + rotation.fromValue = 0 + rotation.toValue = Double.pi * 2 + rotation.duration = RefreshAnimation.duration + rotation.repeatCount = .infinity + rotation.isRemovedOnCompletion = false + refreshIconImageView.layer.add(rotation, forKey: RefreshAnimation.key) + } + + private func stopRefreshAnimation() { + refreshIconImageView.layer.removeAnimation(forKey: RefreshAnimation.key) } } diff --git a/FLINT/Presentation/Sources/View/Component/NavigationBar/FlintNavigationBar.swift b/FLINT/Presentation/Sources/View/Component/NavigationBar/FlintNavigationBar.swift index 86724aaa..41e74b8c 100644 --- a/FLINT/Presentation/Sources/View/Component/NavigationBar/FlintNavigationBar.swift +++ b/FLINT/Presentation/Sources/View/Component/NavigationBar/FlintNavigationBar.swift @@ -114,11 +114,21 @@ public final class FlintNavigationBar: BaseView { case .close: rightButton.setImage(UIImage(resource: .icCancel), for: .normal) setPadding(button: rightButton, padding: 12, image: .icCancel) - + + case .setting: + let image = UIImage(named: "ic_setting", in: .module, with: nil) ?? UIImage() + rightButton.tintColor = .flintWhite + setPadding(button: rightButton, padding: 12, image: image) + case .text(let title, let color): rightButton.setAttributedTitle(.pretendard(.body1_b_16, text: title, color: color, alignment: .center), for: .normal) setPadding(button: rightButton, padding: 16, image: nil) - + + case .icon(let name, let tint): + let image = UIImage(named: name, in: .module, with: nil) ?? UIImage() + rightButton.tintColor = tint + setPadding(button: rightButton, padding: 12, image: image.withRenderingMode(.alwaysTemplate)) + case .none: rightButton.isHidden = true } diff --git a/FLINT/Presentation/Sources/View/Component/NavigationBar/NavigationBarConfig.swift b/FLINT/Presentation/Sources/View/Component/NavigationBar/NavigationBarConfig.swift index 5f8e7203..0c4de119 100644 --- a/FLINT/Presentation/Sources/View/Component/NavigationBar/NavigationBarConfig.swift +++ b/FLINT/Presentation/Sources/View/Component/NavigationBar/NavigationBarConfig.swift @@ -15,7 +15,9 @@ public enum NavLeftItem { public enum NavRightItem { case close + case setting case text(title: String, color: UIColor) + case icon(name: String, tint: UIColor) case none } diff --git a/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/KeywordGraphView.swift b/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/KeywordGraphView.swift new file mode 100644 index 00000000..83a2b38b --- /dev/null +++ b/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/KeywordGraphView.swift @@ -0,0 +1,165 @@ +// +// KeywordGraphView.swift +// FLINT +// +// Created by 진소은 on 6/16/26. +// + +import UIKit + +import SnapKit +import Then + +import Domain + +public final class KeywordGraphView: BaseView { + + private enum Metric { + static let rowSpacing: CGFloat = 12 + static let dotSize: CGFloat = 12 + static let dotToNameSpacing: CGFloat = 8 + static let barWidth: CGFloat = 160 + static let barHeight: CGFloat = 12 + static let barToPercentSpacing: CGFloat = 12 + static let topThreeCount = 3 + } + + private let vStack = UIStackView().then { + $0.axis = .vertical + $0.spacing = Metric.rowSpacing + $0.alignment = .fill + $0.distribution = .fill + } + + public override func setHierarchy() { + addSubview(vStack) + } + + public override func setLayout() { + vStack.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + public func configure(keywords: [KeywordEntity]) { + vStack.arrangedSubviews.forEach { + vStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + let topThree = keywords.sorted { $0.rank < $1.rank }.prefix(Metric.topThreeCount) + topThree.forEach { keyword in + let row = KeywordGraphRowView() + row.configure(keyword: keyword) + vStack.addArrangedSubview(row) + } + } +} + +private final class KeywordGraphRowView: BaseView { + + private enum Metric { + static let dotSize: CGFloat = 12 + static let dotToNameSpacing: CGFloat = 8 + static let barWidth: CGFloat = 160 + static let barHeight: CGFloat = 12 + static let barToPercentSpacing: CGFloat = 12 + static let barCornerRadius: CGFloat = 4 + } + + private let dotView = UIView().then { + $0.layer.cornerRadius = Metric.dotSize / 2 + $0.layer.masksToBounds = true + } + + private let nameLabel = UILabel() + private let percentLabel = UILabel() + + private let barContainerView = UIView().then { + $0.backgroundColor = UIColor.flintGray500.withAlphaComponent(0.3) + $0.layer.cornerRadius = Metric.barCornerRadius + $0.layer.masksToBounds = true + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor.white.withAlphaComponent(0.25).cgColor + } + + private let barFillView = GradientView().then { + $0.startPoint = CGPoint(x: 0, y: 0.5) + $0.endPoint = CGPoint(x: 1, y: 0.5) + $0.layer.cornerRadius = Metric.barCornerRadius + $0.layer.masksToBounds = true + } + + private let topHighlightView = GradientView().then { + $0.colors = [UIColor.white.withAlphaComponent(0.25), .clear] + $0.startPoint = CGPoint(x: 0.5, y: 0) + $0.endPoint = CGPoint(x: 0.5, y: 1) + $0.isUserInteractionEnabled = false + } + + private var barFillWidthConstraint: Constraint? + + override func setHierarchy() { + addSubviews(dotView, nameLabel, barContainerView, percentLabel) + barContainerView.addSubviews(barFillView, topHighlightView) + } + + override func setLayout() { + dotView.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.centerY.equalToSuperview() + $0.size.equalTo(Metric.dotSize) + } + + nameLabel.snp.makeConstraints { + $0.leading.equalTo(dotView.snp.trailing).offset(Metric.dotToNameSpacing) + $0.centerY.equalToSuperview() + } + + percentLabel.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.centerY.equalToSuperview() + $0.top.bottom.equalToSuperview() + } + + barContainerView.snp.makeConstraints { + $0.trailing.equalTo(percentLabel.snp.leading).offset(-Metric.barToPercentSpacing) + $0.centerY.equalToSuperview() + $0.width.equalTo(Metric.barWidth) + $0.height.equalTo(Metric.barHeight) + } + + barFillView.snp.makeConstraints { + $0.leading.top.bottom.equalToSuperview() + barFillWidthConstraint = $0.width.equalTo(0).constraint + } + + topHighlightView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + func configure(keyword: KeywordEntity) { + let color = Self.uiColor(for: keyword.color) + + dotView.backgroundColor = color + nameLabel.attributedText = .pretendard(.body1_m_16, text: keyword.name, color: .white) + percentLabel.attributedText = .pretendard(.body1_m_16, text: "\(keyword.percentage)%", color: .white) + + barFillView.colors = [color.withAlphaComponent(0.2), color] + + let clampedPercent = max(0, min(100, keyword.percentage)) + let width = Metric.barWidth * CGFloat(clampedPercent) / 100.0 + barFillWidthConstraint?.update(offset: width) + } + + private static func uiColor(for keywordColor: KeywordColor) -> UIColor { + switch keywordColor { + case .blue: return .flintBlue + case .pink: return .flintPink + case .green: return .flintGreen + case .orange: return .flintOrange + case .yellow: return .flintYellow + } + } +} diff --git a/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/PreferenceChip.swift b/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/PreferenceChip.swift index c1fc4ba9..a96c005c 100644 --- a/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/PreferenceChip.swift +++ b/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/PreferenceChip.swift @@ -66,8 +66,8 @@ public final class PreferenceChip: BaseView { // MARK: - Public - public func configure(keyword: KeywordEntity) { - style = PreferenceChipStyle.from(rank: keyword.rank, color: keyword.color) + public func configure(keyword: KeywordEntity, displayRank: Int? = nil) { + style = PreferenceChipStyle.from(rank: displayRank ?? keyword.rank, color: keyword.color) keywordLabel.attributedText = .pretendard(.head2_m_20, text: keyword.name, color: .white) diff --git a/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/PreferenceRankedChipView.swift b/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/PreferenceRankedChipView.swift index 5f670937..3df4fea7 100644 --- a/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/PreferenceRankedChipView.swift +++ b/FLINT/Presentation/Sources/View/Component/PreferenceKeyword/PreferenceRankedChipView.swift @@ -32,7 +32,10 @@ public final class PreferenceRankedChipView: BaseView { } public func configure(keywords: [KeywordEntity]) { - let byRank = Dictionary(uniqueKeysWithValues: keywords.map { ($0.rank, $0) }) + let sortedKeywords = keywords.sorted { $0.rank < $1.rank } + let byRank = Dictionary( + uniqueKeysWithValues: sortedKeywords.enumerated().map { ($0.offset + 1, $0.element) } + ) let sumA = [1, 2, 4].compactMap { byRank[$0]?.name.count }.reduce(0, +) let sumB = [3, 5, 6].compactMap { byRank[$0]?.name.count }.reduce(0, +) @@ -72,7 +75,7 @@ public final class PreferenceRankedChipView: BaseView { rankRow.forEach { r in guard let dto = byRank[r] else { return } let chip = PreferenceChip() - chip.configure(keyword: dto) + chip.configure(keyword: dto, displayRank: r) rowStack.addArrangedSubview(chip) } diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_refresh.imageset/Contents.json b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_refresh.imageset/Contents.json new file mode 100644 index 00000000..66ceb87e --- /dev/null +++ b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_refresh.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "ic_refresh.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_refresh.imageset/ic_refresh.svg b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_refresh.imageset/ic_refresh.svg new file mode 100644 index 00000000..da4e65f0 --- /dev/null +++ b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_refresh.imageset/ic_refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_setting.imageset/Contents.json b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_setting.imageset/Contents.json index 2985354d..2e0291e0 100644 --- a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_setting.imageset/Contents.json +++ b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/24/ic_setting.imageset/Contents.json @@ -8,5 +8,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/ic_info.imageset/Contents.json b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/ic_info.imageset/Contents.json new file mode 100644 index 00000000..2721c563 --- /dev/null +++ b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/ic_info.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "ic_info.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/ic_info.imageset/ic_info.svg b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/ic_info.imageset/ic_info.svg new file mode 100644 index 00000000..526a3031 --- /dev/null +++ b/FLINT/Presentation/Sources/View/Resource/Assets.xcassets/Icon/Common/ic_info.imageset/ic_info.svg @@ -0,0 +1,3 @@ + + + diff --git a/FLINT/Presentation/Sources/View/Scene/Profile/KeywordGraphTableViewCell.swift b/FLINT/Presentation/Sources/View/Scene/Profile/KeywordGraphTableViewCell.swift new file mode 100644 index 00000000..a41c0f50 --- /dev/null +++ b/FLINT/Presentation/Sources/View/Scene/Profile/KeywordGraphTableViewCell.swift @@ -0,0 +1,43 @@ +// +// KeywordGraphTableViewCell.swift +// FLINT +// +// Created by 진소은 on 6/16/26. +// + +import UIKit + +import SnapKit +import Then + +import Entity + +public final class KeywordGraphTableViewCell: BaseTableViewCell { + + private let graphView = KeywordGraphView() + + public override func setStyle() { + contentView.backgroundColor = .flintBackground + selectionStyle = .none + } + + public override func setHierarchy() { + contentView.addSubview(graphView) + } + + public override func setLayout() { + graphView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(16) + } + } + + public override func prepareForReuse() { + super.prepareForReuse() + graphView.configure(keywords: []) + } + + public func configure(keywords: [KeywordEntity]) { + graphView.configure(keywords: keywords) + } +} diff --git a/FLINT/Presentation/Sources/View/Scene/Profile/ProfileView.swift b/FLINT/Presentation/Sources/View/Scene/Profile/ProfileView.swift index e1e1f049..b3637cd1 100644 --- a/FLINT/Presentation/Sources/View/Scene/Profile/ProfileView.swift +++ b/FLINT/Presentation/Sources/View/Scene/Profile/ProfileView.swift @@ -39,7 +39,7 @@ public final class ProfileView: UIView { private func setLayout() { tableView.snp.makeConstraints { - $0.edges.equalTo(safeAreaLayoutGuide) + $0.edges.equalToSuperview() } } } diff --git a/FLINT/Presentation/Sources/ViewController/Scene/Profile/ProfileViewController.swift b/FLINT/Presentation/Sources/ViewController/Scene/Profile/ProfileViewController.swift index 0c734ea2..ee6a7a89 100644 --- a/FLINT/Presentation/Sources/ViewController/Scene/Profile/ProfileViewController.swift +++ b/FLINT/Presentation/Sources/ViewController/Scene/Profile/ProfileViewController.swift @@ -39,6 +39,9 @@ public final class ProfileViewController: BaseViewController { public override func viewDidLoad() { super.viewDidLoad() + rootView.snp.remakeConstraints { + $0.edges.equalToSuperview() + } setupTableView() bind() profileViewModel.load() @@ -48,9 +51,21 @@ public final class ProfileViewController: BaseViewController { super.viewWillAppear(animated) setNavigationBar(.init(left: .back, backgroundStyle: .clear)) + let rightItem: NavRightItem = profileViewModel.isMe ? .setting : .none + + setNavigationBar( + .init(left: .back, right: rightItem, backgroundStyle: .clear), + onTapRight: { [weak self] in + self?.didTapSetting() + } + ) + } + + private func didTapSetting() { + // TODO: SettingViewController push (DI에 SettingViewControllerFactory 등록 후 연결) + print("setting tapped") } - private func setupTableView() { let tableView = rootView.tableView tableView.dataSource = self @@ -83,6 +98,15 @@ public final class ProfileViewController: BaseViewController { let vc = BaseBottomSheetViewController(content: .ott(platforms: platforms)) present(vc, animated: false) } + + private func pushCollectionDetail(collectionIdString: String) { + guard let collectionId = Int64(collectionIdString) else { + print("invalid collectionId:", collectionIdString) + return + } + guard let vc = viewControllerFactory?.makeCollectionDetailViewController(collectionId: collectionId) else { return } + navigationController?.pushViewController(vc, animated: true) + } } @@ -101,6 +125,8 @@ extension ProfileViewController: UITableViewDelegate { case .titleHeader: return 0 case .preferenceChips: + return 32 + case .keywordGraph: return 48 case .myCollections, .savedCollections: return 24 @@ -142,18 +168,38 @@ extension ProfileViewController: UITableViewDataSource { cell.configure(keywords: keywords) return cell - case let .titleHeader(style, title, subtitle): + case let .keywordGraph(keywords): + let cell = tableView.dequeueReusableCell(KeywordGraphTableViewCell.self, for: indexPath) + cell.selectionStyle = .none + cell.configure(keywords: keywords) + return cell + + case let .titleHeader(style, title, subtitle, showInfo, showRefresh, isRefreshing, tooltipText): let cell = tableView.dequeueReusableCell(TitleHeaderTableViewCell.self, for: indexPath) cell.selectionStyle = .none - cell.configure(style: map(style), title: title, subtitle: subtitle) + cell.configure( + style: map(style), + title: title, + subtitle: subtitle, + showInfo: showInfo, + showRefresh: showRefresh, + isRefreshing: isRefreshing, + tooltipText: tooltipText + ) + cell.onTapInfo = { [weak self] in + self?.profileViewModel.toggleKeywordInfoTooltip() + } + cell.onTapRefresh = { [weak self] in + self?.profileViewModel.refreshKeywords() + } return cell case let .myCollections(items): let cell = tableView.dequeueReusableCell(MoreNoMoreCollectionTableViewCell.self, for: indexPath) cell.selectionStyle = .none cell.configure(items: items) - cell.onSelectItem = { entity in - print("컬렉션 선택:", entity.id) + cell.onSelectItem = { [weak self] entity in + self?.pushCollectionDetail(collectionIdString: entity.id) } return cell @@ -161,8 +207,8 @@ extension ProfileViewController: UITableViewDataSource { let cell = tableView.dequeueReusableCell(MoreNoMoreCollectionTableViewCell.self, for: indexPath) cell.selectionStyle = .none cell.configure(items: items) - cell.onSelectItem = { entity in - print("저장 컬렉션 선택:", entity.id) + cell.onSelectItem = { [weak self] entity in + self?.pushCollectionDetail(collectionIdString: entity.id) } return cell diff --git a/FLINT/Presentation/Sources/ViewModel/Scene/Profile/ProfileViewModel.swift b/FLINT/Presentation/Sources/ViewModel/Scene/Profile/ProfileViewModel.swift index c30ee27d..60842061 100644 --- a/FLINT/Presentation/Sources/ViewModel/Scene/Profile/ProfileViewModel.swift +++ b/FLINT/Presentation/Sources/ViewModel/Scene/Profile/ProfileViewModel.swift @@ -10,39 +10,46 @@ import Foundation import Domain -//public protocol ProfileViewModelInput { -// -//} - -//public protocol ProfileViewModelOutput { -// var userProfileEntity: CurrentValueSubject { get set } -//} - -//public typealias ProfileViewModel = ProfileViewModelInput & ProfileViewModelOutput - -//public final class DefaultProfileViewModel: ProfileViewModel { public final class ProfileViewModel { -// public var userProfileEntity: CurrentValueSubject private let target: UserTarget public enum Row { case profileHeader(nickname: String, profileImageUrl: URL?, isFliner: Bool) - case titleHeader(style: TitleHeaderStyle, title: String, subtitle: String) + case titleHeader( + style: TitleHeaderStyle, + title: String, + subtitle: String, + showInfo: Bool, + showRefresh: Bool, + isRefreshing: Bool, + tooltipText: String? + ) case preferenceChips(keywords: [KeywordEntity]) + case keywordGraph(keywords: [KeywordEntity]) case myCollections(items: [CollectionEntity]) case savedCollections(items: [CollectionEntity]) case savedContents(items: [ContentInfoEntity]) } + + private enum Const { + static let keywordInfoTooltipText = + "저장한 작품들에서 반복되는 키워드를 분석해 취향키워드를 만들어요. 20개 이상 작품이 쌓이면 업데이트할 수 있어요." + } public enum TitleHeaderStyle { case normal case more } - + // MARK: - Output @Published public private(set) var rows: [Row] = [] + + public var isMe: Bool { + if case .me = target { return true } + return false + } // MARK: - Dependencies private let fetchProfileUseCase: FetchProfileUseCase @@ -50,18 +57,21 @@ public final class ProfileViewModel { private let fetchCreatedCollectionsUseCase: FetchCreatedCollectionsUseCase private let fetchBookmarkedCollectionsUseCase: FetchBookmarkedCollectionsUseCase private let fetchBookmarkedContentsUseCase: FetchBookmarkedContentsUseCase + private let recalculateKeywordsUseCase: RecalculateKeywordsUseCase private var cancellables = Set() - + // MARK: - State private var nickname: String private var isFliner: Bool private var profileImageUrl: URL? - + private var keywords: [KeywordEntity] = [] private var myCollections: [CollectionEntity] = [] private var savedCollections: [CollectionEntity] = [] private var savedContents: [ContentInfoEntity] = [] - + private var isKeywordInfoTooltipVisible: Bool = false + private var isRefreshing: Bool = false + public init( target: UserTarget, fetchProfileUseCase: FetchProfileUseCase, @@ -69,6 +79,7 @@ public final class ProfileViewModel { fetchCreatedCollectionsUseCase: FetchCreatedCollectionsUseCase, fetchBookmarkedCollectionsUseCase: FetchBookmarkedCollectionsUseCase, fetchBookmarkedContentsUseCase: FetchBookmarkedContentsUseCase, + recalculateKeywordsUseCase: RecalculateKeywordsUseCase, initialNickname: String = "플링", initialIsFliner: Bool = true ) { @@ -78,6 +89,7 @@ public final class ProfileViewModel { self.fetchCreatedCollectionsUseCase = fetchCreatedCollectionsUseCase self.fetchBookmarkedCollectionsUseCase = fetchBookmarkedCollectionsUseCase self.fetchBookmarkedContentsUseCase = fetchBookmarkedContentsUseCase + self.recalculateKeywordsUseCase = recalculateKeywordsUseCase self.nickname = initialNickname self.isFliner = initialIsFliner self.rows = makeRows() @@ -101,7 +113,7 @@ public final class ProfileViewModel { .manageThread() .sink { completion in if case let .failure(error) = completion { - print("❌ fetchKeywords failed:", error) + print("fetchKeywords failed:", error) } } receiveValue: { [weak self] keywords in guard let self else { return } @@ -114,7 +126,7 @@ public final class ProfileViewModel { .manageThread() .sink { completion in if case let .failure(error) = completion { - print("❌ fetchMyCollections failed:", error) + print("fetchMyCollections failed:", error) } } receiveValue: { [weak self] items in guard let self else { return } @@ -127,7 +139,7 @@ public final class ProfileViewModel { .manageThread() .sink { completion in if case let .failure(error) = completion { - print("❌ fetchSavedCollections failed:", error) + print("fetchSavedCollections failed:", error) } } receiveValue: { [weak self] items in print("asdf", items.count) @@ -141,7 +153,7 @@ public final class ProfileViewModel { .manageThread() .sink { completion in if case let .failure(error) = completion { - print("❌ fetchSavedContents failed:", error) + print("fetchSavedContents failed:", error) } } receiveValue: { [weak self] items in guard let self else { return } @@ -151,6 +163,38 @@ public final class ProfileViewModel { .store(in: &cancellables) } + public func toggleKeywordInfoTooltip() { + guard isMe else { return } + isKeywordInfoTooltipVisible.toggle() + rows = makeRows() + } + + public func refreshKeywords() { + guard isMe, !isRefreshing else { return } + isRefreshing = true + rows = makeRows() + + let target = self.target + let fetchKeywords = fetchKeywordsUseCase + + recalculateKeywordsUseCase() + .flatMap { _ in fetchKeywords(for: target) } + .manageThread() + .sink { [weak self] completion in + guard let self else { return } + self.isRefreshing = false + if case let .failure(error) = completion { + print("recalculateKeywords failed:", error) + } + self.rows = self.makeRows() + } receiveValue: { [weak self] keywords in + guard let self else { return } + self.keywords = keywords + self.rows = self.makeRows() + } + .store(in: &cancellables) + } + // MARK: - Row builder private func makeRows() -> [Row] { var result: [Row] = [] @@ -175,46 +219,64 @@ public final class ProfileViewModel { result.append(content) } - // 취향 키워드 - appendSectionIfNotEmpty( - keywords.isEmpty, - header: .titleHeader( - style: .normal, - title: "\(nickname)님의 취향 키워드", - subtitle: "\(nickname)님이 관심 있어 하는 키워드에요" - ), - content: .preferenceChips(keywords: keywords) - ) - + // 취향 키워드 (header + chips + graph) + if !keywords.isEmpty { + result.append( + .titleHeader( + style: .normal, + title: "\(nickname)님의 취향 키워드", + subtitle: "\(nickname)님이 관심 있어 하는 키워드에요", + showInfo: isMe, + showRefresh: isMe, + isRefreshing: isRefreshing, + tooltipText: (isMe && isKeywordInfoTooltipVisible) ? Const.keywordInfoTooltipText : nil + ) + ) + result.append(.preferenceChips(keywords: keywords)) + result.append(.keywordGraph(keywords: keywords)) + } + // 내가 만든 컬렉션 appendSectionIfNotEmpty( myCollections.isEmpty, header: .titleHeader( - style: .normal, + style: .more, title: "\(nickname)님의 컬렉션", - subtitle: "\(nickname)님이 생성한 컬렉션이에요" + subtitle: "\(nickname)님이 생성한 컬렉션이에요", + showInfo: false, + showRefresh: false, + isRefreshing: false, + tooltipText: nil ), content: .myCollections(items: myCollections) ) - + // 저장한 컬렉션 appendSectionIfNotEmpty( savedCollections.isEmpty, header: .titleHeader( - style: .normal, + style: .more, title: "저장한 컬렉션", - subtitle: "\(nickname)님이 저장한 컬렉션이에요" + subtitle: "\(nickname)님이 저장한 컬렉션이에요", + showInfo: false, + showRefresh: false, + isRefreshing: false, + tooltipText: nil ), content: .savedCollections(items: savedCollections) ) - - // 저장한 콘텐츠 + + // 저장한 작품 appendSectionIfNotEmpty( savedContents.isEmpty, header: .titleHeader( - style: .normal, - title: "저장한 콘텐츠", - subtitle: "\(nickname)님이 저장한 콘텐츠에요" + style: .more, + title: "저장한 작품", + subtitle: "\(nickname)님이 저장한 작품이에요", + showInfo: false, + showRefresh: false, + isRefreshing: false, + tooltipText: nil ), content: .savedContents(items: savedContents) )