From 35dc74dd8ffa51e52a09cfe7ea96b0057c8eaec7 Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:29:23 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20insight=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/DTO/InsightResponseDTO.swift | 122 ++++++++++++++++++ .../Network/Endpoints/InsightEndpoint.swift | 55 ++++++++ .../Repository/InsightRepositoryImpl.swift | 33 +++++ .../Sources/Core/Error/InsightError.swift | 11 ++ FoodDiary/Domain/Sources/Entity/Insight.swift | 93 +++++++++++++ .../Repository/InsightRepository.swift | 10 ++ .../Sources/UseCase/FetchInsightUseCase.swift | 18 +++ 7 files changed, 342 insertions(+) create mode 100644 FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift create mode 100644 FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift create mode 100644 FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift create mode 100644 FoodDiary/Domain/Sources/Core/Error/InsightError.swift create mode 100644 FoodDiary/Domain/Sources/Entity/Insight.swift create mode 100644 FoodDiary/Domain/Sources/Repository/InsightRepository.swift create mode 100644 FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift diff --git a/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift new file mode 100644 index 00000000..c7354a97 --- /dev/null +++ b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift @@ -0,0 +1,122 @@ +// +// InsightResponseDTO.swift +// Data +// + +import Domain +import Foundation + +struct InsightResponseDTO: Decodable { + let month: String + let photoStats: PhotoStatsDTO + let categoryStats: CategoryStatsDTO + let topMenu: TopMenuDTO + let diaryTimeStats: DiaryTimeStatsDTO + let keywords: [String] + + enum CodingKeys: String, CodingKey { + case month + case photoStats = "photo_stats" + case categoryStats = "category_stats" + case topMenu = "top_menu" + case diaryTimeStats = "diary_time_stats" + case keywords + } + + func toInsight() -> Insight { + Insight( + month: month, + photoStats: photoStats.toEntity(), + categoryStats: categoryStats.toEntity(), + topMenu: topMenu.toEntity(), + diaryTimeStats: diaryTimeStats.toEntity(), + keywords: keywords + ) + } +} + +struct PhotoStatsDTO: Decodable { + let currentMonthCount: Int + let previousMonthCount: Int + let changeRate: Double + + enum CodingKeys: String, CodingKey { + case currentMonthCount = "current_month_count" + case previousMonthCount = "previous_month_count" + case changeRate = "change_rate" + } + + func toEntity() -> PhotoStats { + PhotoStats( + currentMonthCount: currentMonthCount, + previousMonthCount: previousMonthCount, + changeRate: changeRate + ) + } +} + +struct CategoryStatsDTO: Decodable { + let currentMonth: CategoryStatDTO + let previousMonth: CategoryStatDTO + + enum CodingKeys: String, CodingKey { + case currentMonth = "current_month" + case previousMonth = "previous_month" + } + + func toEntity() -> CategoryStats { + CategoryStats( + currentMonth: currentMonth.toEntity(), + previousMonth: previousMonth.toEntity() + ) + } +} + +struct CategoryStatDTO: Decodable { + let topCategory: String + let count: Int + + enum CodingKeys: String, CodingKey { + case topCategory = "top_category" + case count + } + + func toEntity() -> CategoryStat { + CategoryStat(topCategory: topCategory, count: count) + } +} + +struct TopMenuDTO: Decodable { + let name: String + let count: Int + + func toEntity() -> TopMenu { + TopMenu(name: name, count: count) + } +} + +struct DiaryTimeStatsDTO: Decodable { + let mostActiveHour: Int + let distribution: [HourCountDTO] + + enum CodingKeys: String, CodingKey { + case mostActiveHour = "most_active_hour" + case distribution + } + + func toEntity() -> DiaryTimeStats { + DiaryTimeStats( + mostActiveHour: mostActiveHour, + distribution: distribution.map { $0.toEntity() } + ) + } +} + +struct HourCountDTO: Decodable { + let hour: Int + let count: Int + + func toEntity() -> HourCount { + HourCount(hour: hour, count: count) + } +} diff --git a/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift b/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift new file mode 100644 index 00000000..14ce444b --- /dev/null +++ b/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift @@ -0,0 +1,55 @@ +// +// InsightEndpoint.swift +// Data +// + +import Foundation + +public enum InsightEndpoint { + case fetch +} + +extension InsightEndpoint: Requestable { + public var baseURL: String { + guard let url = Bundle.main.infoDictionary?["BASE_URL"] as? String else { + fatalError("BASE_URL이 Info.plist에 설정되지 않았습니다.") + } + + return url + } + + public var path: String { + switch self { + case .fetch: + "/me/insights" + } + } + + public var httpMethod: HTTPMethod { + switch self { + case .fetch: + .get + } + } + + public var queryParameters: Encodable? { + switch self { + case .fetch: + nil + } + } + + public var bodyParameters: HTTPBody { + switch self { + case .fetch: + .none + } + } + + public var headers: [String: String] { + switch self { + case .fetch: + [:] + } + } +} diff --git a/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift new file mode 100644 index 00000000..b4aaca0c --- /dev/null +++ b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift @@ -0,0 +1,33 @@ +// +// InsightRepositoryImpl.swift +// Data +// + +import Domain +import Foundation + +public struct InsightRepositoryImpl: InsightRepository { + private let httpClient: Client + private let tokenStorage: Storage + + public init(httpClient: Client, tokenStorage: Storage) { + self.httpClient = httpClient + self.tokenStorage = tokenStorage + } + + public func fetchInsight() async throws -> Insight { + guard let accessToken = tokenStorage.get() else { + throw InsightError.noAccessToken + } + + do { + let response: InsightResponseDTO = try await httpClient.request( + InsightEndpoint.fetch, + accessToken: accessToken + ) + return response.toInsight() + } catch NetworkError.httpError(statusCode: 400, _) { + throw InsightError.insufficientData + } + } +} diff --git a/FoodDiary/Domain/Sources/Core/Error/InsightError.swift b/FoodDiary/Domain/Sources/Core/Error/InsightError.swift new file mode 100644 index 00000000..a1d1ef62 --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Error/InsightError.swift @@ -0,0 +1,11 @@ +// +// InsightError.swift +// Domain +// + +import Foundation + +public enum InsightError: Error { + case noAccessToken + case insufficientData +} diff --git a/FoodDiary/Domain/Sources/Entity/Insight.swift b/FoodDiary/Domain/Sources/Entity/Insight.swift new file mode 100644 index 00000000..e621f399 --- /dev/null +++ b/FoodDiary/Domain/Sources/Entity/Insight.swift @@ -0,0 +1,93 @@ +// +// Insight.swift +// Domain +// + +import Foundation + +public struct Insight: Equatable, Sendable { + public let month: String + public let photoStats: PhotoStats + public let categoryStats: CategoryStats + public let topMenu: TopMenu + public let diaryTimeStats: DiaryTimeStats + public let keywords: [String] + + public init( + month: String, + photoStats: PhotoStats, + categoryStats: CategoryStats, + topMenu: TopMenu, + diaryTimeStats: DiaryTimeStats, + keywords: [String] + ) { + self.month = month + self.photoStats = photoStats + self.categoryStats = categoryStats + self.topMenu = topMenu + self.diaryTimeStats = diaryTimeStats + self.keywords = keywords + } +} + +public struct PhotoStats: Equatable, Sendable { + public let currentMonthCount: Int + public let previousMonthCount: Int + public let changeRate: Double + + public init(currentMonthCount: Int, previousMonthCount: Int, changeRate: Double) { + self.currentMonthCount = currentMonthCount + self.previousMonthCount = previousMonthCount + self.changeRate = changeRate + } +} + +public struct CategoryStats: Equatable, Sendable { + public let currentMonth: CategoryStat + public let previousMonth: CategoryStat + + public init(currentMonth: CategoryStat, previousMonth: CategoryStat) { + self.currentMonth = currentMonth + self.previousMonth = previousMonth + } +} + +public struct CategoryStat: Equatable, Sendable { + public let topCategory: String + public let count: Int + + public init(topCategory: String, count: Int) { + self.topCategory = topCategory + self.count = count + } +} + +public struct TopMenu: Equatable, Sendable { + public let name: String + public let count: Int + + public init(name: String, count: Int) { + self.name = name + self.count = count + } +} + +public struct DiaryTimeStats: Equatable, Sendable { + public let mostActiveHour: Int + public let distribution: [HourCount] + + public init(mostActiveHour: Int, distribution: [HourCount]) { + self.mostActiveHour = mostActiveHour + self.distribution = distribution + } +} + +public struct HourCount: Equatable, Sendable { + public let hour: Int + public let count: Int + + public init(hour: Int, count: Int) { + self.hour = hour + self.count = count + } +} diff --git a/FoodDiary/Domain/Sources/Repository/InsightRepository.swift b/FoodDiary/Domain/Sources/Repository/InsightRepository.swift new file mode 100644 index 00000000..d0c439f9 --- /dev/null +++ b/FoodDiary/Domain/Sources/Repository/InsightRepository.swift @@ -0,0 +1,10 @@ +// +// InsightRepository.swift +// Domain +// + +import Foundation + +public protocol InsightRepository { + func fetchInsight() async throws -> Insight +} diff --git a/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift new file mode 100644 index 00000000..94189c00 --- /dev/null +++ b/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift @@ -0,0 +1,18 @@ +// +// FetchInsightUseCase.swift +// Domain +// + +import Foundation + +public struct FetchInsightUseCase { + private let repository: Repository + + public init(repository: Repository) { + self.repository = repository + } + + public func execute() async throws -> Insight { + try await repository.fetchInsight() + } +} From 0e961ede8a32cfc5069d69bdbf1d57c1a1ba4b2c Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:16:18 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20InsightViewModel=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Insight/InsightViewModel.swift | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift new file mode 100644 index 00000000..7c1fa867 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift @@ -0,0 +1,99 @@ +// +// InsightViewModel.swift +// Presentation +// + +import Combine +import Domain +import Foundation + +@MainActor +public final class InsightViewModel { + + // MARK: - Output + + public var statePublisher: AnyPublisher { + stateSubject.eraseToAnyPublisher() + } + + public private(set) var state: State { + get { stateSubject.value } + set { stateSubject.value = newValue } + } + + public var eventPublisher: AnyPublisher { + eventSubject.eraseToAnyPublisher() + } + + // MARK: - Input + + public let input = PassthroughSubject() + + // MARK: - Private + + private let stateSubject: CurrentValueSubject + private let eventSubject = PassthroughSubject() + private var cancellables = Set() + + // MARK: - Dependencies + + private let fetchInsightUseCase: FetchInsightUseCase + + // MARK: - Init + + public init(fetchInsightUseCase: FetchInsightUseCase) { + self.fetchInsightUseCase = fetchInsightUseCase + self.stateSubject = CurrentValueSubject(State()) + setupBindings() + } + + // MARK: - Setup + + private func setupBindings() { + input + .sink { [weak self] action in + guard let self else { return } + Task(priority: .userInitiated) { + await self.handleInput(action) + } + } + .store(in: &cancellables) + } + + private func handleInput(_ action: Input) async { + switch action { + case .loadInsight: + state.isLoading = true + do { + let insight = try await fetchInsightUseCase.execute() + state.insight = insight + state.isLoading = false + } catch is InsightError { + state.hasInsufficientData = true + state.isLoading = false + } catch { + state.isLoading = false + eventSubject.send(.loadFailed(error)) + } + } + } +} + +// MARK: - State / Input / Event + +public extension InsightViewModel { + + struct State: Equatable { + public var isLoading: Bool = false + public var insight: Insight? + public var hasInsufficientData: Bool = false + } + + enum Input { + case loadInsight + } + + enum Event { + case loadFailed(Error) + } +} From e427bb98f396379df26b97ba82299539712500bf Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:17:13 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=84=B9=EC=85=98=20=EB=B7=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/InsightCategoryStatsView.swift | 75 +++++++++++ .../InsightDiaryTimeStatsView.swift | 109 +++++++++++++++ .../Components/InsightKeywordsView.swift | 126 ++++++++++++++++++ .../Components/InsightPhotoStatsView.swift | 71 ++++++++++ .../Components/InsightTopMenuView.swift | 61 +++++++++ 5 files changed, 442 insertions(+) create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift new file mode 100644 index 00000000..ec52bf85 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift @@ -0,0 +1,75 @@ +// +// InsightCategoryStatsView.swift +// Presentation +// + +import Domain +import SnapKit +import UIKit + +final class InsightCategoryStatsView: UIView { + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let currentMonthLabel = UILabel() + private let previousMonthLabel = UILabel() + + // MARK: - Init + + init(categoryStats: CategoryStats) { + super.init(frame: .zero) + setupUI(categoryStats: categoryStats) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(categoryStats: CategoryStats) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + titleLabel.setText("🍽️ 카테고리 분석", style: .hd18) + addSubview(titleLabel) + + let current = categoryStats.currentMonth + currentMonthLabel.setText( + "이번 달 최다: \(current.topCategory) (\(current.count)회)", + style: .p15, + color: .gray050 + ) + addSubview(currentMonthLabel) + + let previous = categoryStats.previousMonth + previousMonthLabel.setText( + "지난 달 최다: \(previous.topCategory) (\(previous.count)회)", + style: .p14, + color: .gray300 + ) + addSubview(previousMonthLabel) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + currentMonthLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview().inset(20) + } + + previousMonthLabel.snp.makeConstraints { + $0.top.equalTo(currentMonthLabel.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().inset(20) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift new file mode 100644 index 00000000..66e3e451 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift @@ -0,0 +1,109 @@ +// +// InsightDiaryTimeStatsView.swift +// Presentation +// + +import Domain +import SnapKit +import UIKit + +final class InsightDiaryTimeStatsView: UIView { + + // MARK: - Constants + + private enum Constants { + static let barMaxHeight: CGFloat = 80 + static let barWidth: CGFloat = 6 + static let barSpacing: CGFloat = 4 + } + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let activeHourLabel = UILabel() + private let chartContainerView = UIView() + + // MARK: - Init + + init(diaryTimeStats: DiaryTimeStats) { + super.init(frame: .zero) + setupUI(diaryTimeStats: diaryTimeStats) + setupConstraints() + buildChart(distribution: diaryTimeStats.distribution) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(diaryTimeStats: DiaryTimeStats) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + titleLabel.setText("⏰ 기록 시간대", style: .hd18) + addSubview(titleLabel) + + let hour = diaryTimeStats.mostActiveHour + let hourText = String(format: "%02d시", hour) + activeHourLabel.setText("주로 \(hourText)에 기록해요", style: .p15, color: .gray050) + addSubview(activeHourLabel) + + addSubview(chartContainerView) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + activeHourLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview().inset(20) + } + + chartContainerView.snp.makeConstraints { + $0.top.equalTo(activeHourLabel.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(Constants.barMaxHeight + 20) + $0.bottom.equalToSuperview().inset(20) + } + } + + private func buildChart(distribution: [HourCount]) { + let maxCount = distribution.map(\.count).max() ?? 1 + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .bottom + stackView.distribution = .equalSpacing + stackView.spacing = Constants.barSpacing + chartContainerView.addSubview(stackView) + + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + for hourCount in distribution { + let ratio = maxCount > 0 + ? CGFloat(hourCount.count) / CGFloat(maxCount) + : 0 + let barHeight = max(2, Constants.barMaxHeight * ratio) + + let barView = UIView() + barView.backgroundColor = hourCount.count == maxCount ? .primary : .sd700 + barView.layer.cornerRadius = Constants.barWidth / 2 + + barView.snp.makeConstraints { + $0.width.equalTo(Constants.barWidth) + $0.height.equalTo(barHeight) + } + + stackView.addArrangedSubview(barView) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift new file mode 100644 index 00000000..4068397a --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift @@ -0,0 +1,126 @@ +// +// InsightKeywordsView.swift +// Presentation +// + +import SnapKit +import UIKit + +final class InsightKeywordsView: UIView { + + // MARK: - Constants + + private enum Constants { + static let tagHeight: CGFloat = 32 + static let tagHorizontalPadding: CGFloat = 14 + static let tagSpacing: CGFloat = 8 + static let lineSpacing: CGFloat = 8 + } + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let tagsContainerView = UIView() + private let keywords: [String] + + // MARK: - Init + + init(keywords: [String]) { + self.keywords = keywords + super.init(frame: .zero) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + titleLabel.setText("💬 키워드", style: .hd18) + addSubview(titleLabel) + addSubview(tagsContainerView) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + tagsContainerView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().inset(20) + } + } + + public override func layoutSubviews() { + super.layoutSubviews() + layoutTags() + } + + // MARK: - Tag Layout + + private func layoutTags() { + tagsContainerView.subviews.forEach { $0.removeFromSuperview() } + + let containerWidth = tagsContainerView.bounds.width + guard containerWidth > 0 else { return } + + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + + for keyword in keywords { + let tagView = makeTagView(text: keyword) + let tagSize = tagView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + if currentX + tagSize.width > containerWidth, currentX > 0 { + currentX = 0 + currentY += Constants.tagHeight + Constants.lineSpacing + } + + tagView.frame = CGRect( + x: currentX, + y: currentY, + width: tagSize.width, + height: Constants.tagHeight + ) + tagsContainerView.addSubview(tagView) + currentX += tagSize.width + Constants.tagSpacing + } + + let totalHeight = currentY + Constants.tagHeight + tagsContainerView.snp.updateConstraints { + $0.height.equalTo(totalHeight) + } + } + + private func makeTagView(text: String) -> UIView { + let container = UIView() + container.backgroundColor = .sd700 + container.layer.cornerRadius = Constants.tagHeight / 2 + + let label = UILabel() + label.setText("#\(text)", style: .p14, color: .gray050) + container.addSubview(label) + + label.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(Constants.tagHorizontalPadding) + $0.centerY.equalToSuperview() + } + + container.snp.makeConstraints { + $0.height.equalTo(Constants.tagHeight) + } + + return container + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift new file mode 100644 index 00000000..7f27153f --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -0,0 +1,71 @@ +// +// InsightPhotoStatsView.swift +// Presentation +// + +import Domain +import SnapKit +import UIKit + +final class InsightPhotoStatsView: UIView { + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let countLabel = UILabel() + private let changeRateLabel = UILabel() + + // MARK: - Init + + init(photoStats: PhotoStats, month: String) { + super.init(frame: .zero) + setupUI(photoStats: photoStats, month: month) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(photoStats: PhotoStats, month: String) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + titleLabel.setText("📸 사진 기록", style: .hd18) + addSubview(titleLabel) + + let countText = "\(month)에 \(photoStats.currentMonthCount)장의 사진을 기록했어요" + countLabel.setText(countText, style: .p15, color: .gray050) + countLabel.numberOfLines = 0 + addSubview(countLabel) + + let rate = photoStats.changeRate + let sign = rate >= 0 ? "+" : "" + let rateText = "지난 달(\(photoStats.previousMonthCount)장) 대비 \(sign)\(Int(rate))%" + let rateColor: UIColor = rate >= 0 ? .primary : .gray300 + changeRateLabel.setText(rateText, style: .p14, color: rateColor) + addSubview(changeRateLabel) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + countLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview().inset(20) + } + + changeRateLabel.snp.makeConstraints { + $0.top.equalTo(countLabel.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().inset(20) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift new file mode 100644 index 00000000..888e4477 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift @@ -0,0 +1,61 @@ +// +// InsightTopMenuView.swift +// Presentation +// + +import Domain +import SnapKit +import UIKit + +final class InsightTopMenuView: UIView { + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let menuLabel = UILabel() + + // MARK: - Init + + init(topMenu: TopMenu) { + super.init(frame: .zero) + setupUI(topMenu: topMenu) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(topMenu: TopMenu) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + titleLabel.setText("🏆 최다 메뉴", style: .hd18) + addSubview(titleLabel) + + menuLabel.setText( + "\(topMenu.name) — \(topMenu.count)회 기록", + style: .p15, + color: .gray050 + ) + menuLabel.numberOfLines = 0 + addSubview(menuLabel) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + menuLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().inset(20) + } + } +} From 500746f7710e0c54bb2c82df4d392d6ca85f0ce3 Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:18:35 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20InsightViewController=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20DI=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppFlowController.swift | 10 +- FoodDiary/App/Sources/SceneDelegate.swift | 38 ++++++ .../Insight/InsightViewController.swift | 123 ++++++++++++++++-- 3 files changed, 160 insertions(+), 11 deletions(-) diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index d6b77a95..e8ec2196 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -190,10 +190,18 @@ extension AppFlowController { return vc } + typealias InsightVM = InsightViewModel< + InsightRepositoryImpl> + > + + guard let insightVM = try? container.resolve(InsightVM.self) else { + fatalError("InsightViewModel not registered") + } + let tabBarVC = RootTabBarController( weeklyVC: weeklyCalendarVC, monthlyVC: monthlyCalendarVC, - insightVC: InsightViewController(), + insightVC: InsightViewController(viewModel: insightVM), myPageViewControllerFactory: myPageVCFactory ) diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index d1d1fa2f..eadeaa59 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -177,6 +177,14 @@ extension SceneDelegate { return UserRepositoryImpl(httpClient: client, tokenStorage: storage) } + container.register(InsightRepository.self) { resolver in + guard let client = resolver.resolve(HTTPClient.self), + let storage = resolver.resolve(AuthTokenStorage.self) else { + fatalError("InsightRepositoryImpl dependencies not registered") + } + return InsightRepositoryImpl(httpClient: client, tokenStorage: storage) + } + container.register(NicknameStoring.self) { _ in NicknameStorage() } @@ -378,6 +386,20 @@ extension SceneDelegate { return WithdrawUserUseCase(authRepository: authRepository) } + container.register( + FetchInsightUseCase< + InsightRepositoryImpl> + >.self + ) { resolver in + guard let repository = resolver.resolve(InsightRepository.self), + let concreteRepository = repository + as? InsightRepositoryImpl> + else { + fatalError("FetchInsightUseCase dependencies not registered") + } + return FetchInsightUseCase(repository: concreteRepository) + } + container.register( FetchUserProfileUseCase< UserRepositoryImpl>, @@ -435,6 +457,22 @@ extension SceneDelegate { return LoginViewModel(finalizeAppleLoginUseCase: useCase) } + typealias InsightVM = InsightViewModel< + InsightRepositoryImpl> + > + + container.register(InsightVM.self, scope: .transient) { resolver in + guard let fetchInsightUseCase = resolver.resolve( + FetchInsightUseCase< + InsightRepositoryImpl> + >.self + ) else { + fatalError("FetchInsightUseCase not registered") + } + + return InsightViewModel(fetchInsightUseCase: fetchInsightUseCase) + } + // WeeklyCalendarViewModel 타입 별칭 typealias WeeklyVM = WeeklyCalendarViewModel< FoodRecordRepositoryImpl>, diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift index 15f1c2e7..5c4fe006 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift @@ -3,37 +3,68 @@ // Presentation // +import Combine import DesignSystem +import Domain import SnapKit import UIKit -public final class InsightViewController: UIViewController { - - // MARK: - Constants +private enum InsightConstants { + static let imageSize: CGFloat = 240 + static let textTopSpacing: CGFloat = 32 + static let sectionSpacing: CGFloat = 16 + static let contentInset: CGFloat = 20 +} - private enum Constants { - static let imageSize: CGFloat = 240 - static let textTopSpacing: CGFloat = 32 - } +public final class InsightViewController: UIViewController { // MARK: - UI Components + private let scrollView: UIScrollView = { + let sv = UIScrollView() + sv.showsVerticalScrollIndicator = false + sv.isHidden = true + return sv + }() + + private let contentStackView: UIStackView = { + let sv = UIStackView() + sv.axis = .vertical + sv.spacing = InsightConstants.sectionSpacing + return sv + }() + private let emptyImageView: UIImageView = { let iv = UIImageView() iv.image = DesignSystemAsset.emptyInsight.image iv.contentMode = .scaleAspectFit + iv.isHidden = true return iv }() private let descriptionLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 + label.isHidden = true return label }() + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .large) + indicator.color = .gray300 + indicator.hidesWhenStopped = true + return indicator + }() + + // MARK: - Properties + + private let viewModel: InsightViewModel + private var cancellables = Set() + // MARK: - Init - public init() { + public init(viewModel: InsightViewModel) { + self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @@ -48,6 +79,8 @@ public final class InsightViewController: UIViewController { super.viewDidLoad() setupUI() setupConstraints() + setupBindings() + viewModel.input.send(.loadInsight) } // MARK: - Setup @@ -55,8 +88,12 @@ public final class InsightViewController: UIViewController { private func setupUI() { view.backgroundColor = .sdBase + view.addSubview(scrollView) + scrollView.addSubview(contentStackView) + view.addSubview(emptyImageView) view.addSubview(descriptionLabel) + view.addSubview(loadingIndicator) descriptionLabel.setText( "인사이트를 제공하기 위해\n최소 1주일간의 데이터가 필요해요.", @@ -68,15 +105,81 @@ public final class InsightViewController: UIViewController { } private func setupConstraints() { + scrollView.snp.makeConstraints { + $0.edges.equalTo(view.safeAreaLayoutGuide) + } + + contentStackView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(InsightConstants.contentInset) + $0.width.equalToSuperview().offset(-InsightConstants.contentInset * 2) + } + emptyImageView.snp.makeConstraints { $0.centerX.equalToSuperview() $0.centerY.equalToSuperview().offset(-40) - $0.size.equalTo(Constants.imageSize) + $0.size.equalTo(InsightConstants.imageSize) } descriptionLabel.snp.makeConstraints { $0.centerX.equalToSuperview() - $0.top.equalTo(emptyImageView.snp.bottom).offset(Constants.textTopSpacing) + $0.top.equalTo(emptyImageView.snp.bottom).offset(InsightConstants.textTopSpacing) } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + private func setupBindings() { + viewModel.statePublisher + .map(\.isLoading) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] (isLoading: Bool) in + if isLoading { + self?.loadingIndicator.startAnimating() + } else { + self?.loadingIndicator.stopAnimating() + } + } + .store(in: &cancellables) + + viewModel.statePublisher + .map(\.hasInsufficientData) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] (insufficientData: Bool) in + self?.emptyImageView.isHidden = !insufficientData + self?.descriptionLabel.isHidden = !insufficientData + self?.scrollView.isHidden = insufficientData + } + .store(in: &cancellables) + + viewModel.statePublisher + .compactMap(\.insight) + .first() + .receive(on: DispatchQueue.main) + .sink { [weak self] (insight: Insight) in + self?.buildContentSections(with: insight) + } + .store(in: &cancellables) + } + + // MARK: - Content + + private func buildContentSections(with insight: Insight) { + emptyImageView.isHidden = true + descriptionLabel.isHidden = true + scrollView.isHidden = false + + let sections: [UIView] = [ + InsightPhotoStatsView(photoStats: insight.photoStats, month: insight.month), + InsightCategoryStatsView(categoryStats: insight.categoryStats), + InsightTopMenuView(topMenu: insight.topMenu), + InsightDiaryTimeStatsView(diaryTimeStats: insight.diaryTimeStats), + InsightKeywordsView(keywords: insight.keywords) + ] + + sections.forEach { contentStackView.addArrangedSubview($0) } } } From 7ebde5b91aef0913531fbffce32b6e2cb4e4b67c Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:31:53 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20InsightKeywordsView=20height=20const?= =?UTF-8?q?raint=20=ED=81=AC=EB=9E=98=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Insight/Components/InsightKeywordsView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift index 4068397a..35e65611 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift @@ -22,6 +22,7 @@ final class InsightKeywordsView: UIView { private let titleLabel = UILabel() private let tagsContainerView = UIView() private let keywords: [String] + private var didLayoutTags = false // MARK: - Init @@ -58,22 +59,23 @@ final class InsightKeywordsView: UIView { tagsContainerView.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom).offset(12) $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(0) $0.bottom.equalToSuperview().inset(20) } } public override func layoutSubviews() { super.layoutSubviews() + guard !didLayoutTags else { return } layoutTags() } // MARK: - Tag Layout private func layoutTags() { - tagsContainerView.subviews.forEach { $0.removeFromSuperview() } - let containerWidth = tagsContainerView.bounds.width guard containerWidth > 0 else { return } + didLayoutTags = true var currentX: CGFloat = 0 var currentY: CGFloat = 0 From 5423f3458edd519d209eb56f5261bde3bf2dde47 Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 11:05:07 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=9D=B8=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=95=8C=EB=9F=BF=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Insight/InsightViewController.swift | 23 +++++++++++++++++++ .../Sources/Insight/InsightViewModel.swift | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift index 5c4fe006..a856924d 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift @@ -163,6 +163,29 @@ public final class InsightViewController: UIViewControl self?.buildContentSections(with: insight) } .store(in: &cancellables) + + viewModel.eventPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .loadFailed: + self?.showLoadFailedAlert() + } + } + .store(in: &cancellables) + } + + private func showLoadFailedAlert() { + let alert = UIAlertController( + title: "오류", + message: "데이터를 불러오는 데 실패했습니다.\n다시 시도해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "다시 시도", style: .default) { [weak self] _ in + self?.viewModel.input.send(.loadInsight) + }) + alert.addAction(UIAlertAction(title: "닫기", style: .cancel)) + present(alert, animated: true) } // MARK: - Content diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift index 7c1fa867..c65812e3 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift @@ -68,7 +68,7 @@ public final class InsightViewModel { let insight = try await fetchInsightUseCase.execute() state.insight = insight state.isLoading = false - } catch is InsightError { + } catch InsightError.insufficientData { state.hasInsufficientData = true state.isLoading = false } catch { From 713d85b48657e8176c11925a20d72381e8a23c46 Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 22:35:50 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/SceneDelegate.swift | 11 +++++------ .../Sources/Repository/InsightRepositoryImpl.swift | 6 +----- .../Domain/Sources/Core/Error/InsightError.swift | 1 - 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index eadeaa59..4f95c449 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -177,7 +177,7 @@ extension SceneDelegate { return UserRepositoryImpl(httpClient: client, tokenStorage: storage) } - container.register(InsightRepository.self) { resolver in + container.register(InsightRepositoryImpl>.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), let storage = resolver.resolve(AuthTokenStorage.self) else { fatalError("InsightRepositoryImpl dependencies not registered") @@ -391,13 +391,12 @@ extension SceneDelegate { InsightRepositoryImpl> >.self ) { resolver in - guard let repository = resolver.resolve(InsightRepository.self), - let concreteRepository = repository - as? InsightRepositoryImpl> - else { + guard let repository = resolver.resolve( + InsightRepositoryImpl>.self + ) else { fatalError("FetchInsightUseCase dependencies not registered") } - return FetchInsightUseCase(repository: concreteRepository) + return FetchInsightUseCase(repository: repository) } container.register( diff --git a/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift index b4aaca0c..8eef292f 100644 --- a/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift @@ -16,14 +16,10 @@ public struct InsightRepositoryImpl Insight { - guard let accessToken = tokenStorage.get() else { - throw InsightError.noAccessToken - } - do { let response: InsightResponseDTO = try await httpClient.request( InsightEndpoint.fetch, - accessToken: accessToken + accessToken: tokenStorage.get() ) return response.toInsight() } catch NetworkError.httpError(statusCode: 400, _) { diff --git a/FoodDiary/Domain/Sources/Core/Error/InsightError.swift b/FoodDiary/Domain/Sources/Core/Error/InsightError.swift index a1d1ef62..344e5241 100644 --- a/FoodDiary/Domain/Sources/Core/Error/InsightError.swift +++ b/FoodDiary/Domain/Sources/Core/Error/InsightError.swift @@ -6,6 +6,5 @@ import Foundation public enum InsightError: Error { - case noAccessToken case insufficientData }