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..4f95c449 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(InsightRepositoryImpl>.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,19 @@ extension SceneDelegate { return WithdrawUserUseCase(authRepository: authRepository) } + container.register( + FetchInsightUseCase< + InsightRepositoryImpl> + >.self + ) { resolver in + guard let repository = resolver.resolve( + InsightRepositoryImpl>.self + ) else { + fatalError("FetchInsightUseCase dependencies not registered") + } + return FetchInsightUseCase(repository: repository) + } + container.register( FetchUserProfileUseCase< UserRepositoryImpl>, @@ -435,6 +456,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/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..8eef292f --- /dev/null +++ b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift @@ -0,0 +1,29 @@ +// +// 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 { + do { + let response: InsightResponseDTO = try await httpClient.request( + InsightEndpoint.fetch, + accessToken: tokenStorage.get() + ) + 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..344e5241 --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Error/InsightError.swift @@ -0,0 +1,10 @@ +// +// InsightError.swift +// Domain +// + +import Foundation + +public enum InsightError: Error { + 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() + } +} 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..35e65611 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift @@ -0,0 +1,128 @@ +// +// 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] + private var didLayoutTags = false + + // 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.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() { + let containerWidth = tagsContainerView.bounds.width + guard containerWidth > 0 else { return } + didLayoutTags = true + + 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) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift index 15f1c2e7..a856924d 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,104 @@ 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) + + 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 + + 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) } + } } diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift new file mode 100644 index 00000000..c65812e3 --- /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 InsightError.insufficientData { + 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) + } +}