From b1add74fe3e4980121fb9e643aec8da3c8452b4a Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:29:23 +0900 Subject: [PATCH 01/97] =?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 0adf431dc86db56d0199d81b9959091c58637bab Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:16:18 +0900 Subject: [PATCH 02/97] =?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 e3aae9c21460eb360c7c5fa6bc89c65e09830ff5 Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:17:13 +0900 Subject: [PATCH 03/97] =?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 9412c99042d3f808f8cade05788569b38191eb99 Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:18:35 +0900 Subject: [PATCH 04/97] =?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 6a9f92ca63e1bdd2492127653d5058d34ea42dda Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 10:31:53 +0900 Subject: [PATCH 05/97] =?UTF-8?q?fix:=20InsightKeywordsView=20height=20con?= =?UTF-8?q?straint=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 d1242c901da5344135947fbb38a38b1991590b2e Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 11:05:07 +0900 Subject: [PATCH 06/97] =?UTF-8?q?fix:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=95=8C=EB=9F=BF?= =?UTF-8?q?=20=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 b15a9ddc6ef5a2ee819c857a26fc653ba56a6f92 Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 8 Mar 2026 22:35:50 +0900 Subject: [PATCH 07/97] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=EB=B0=98?= =?UTF-8?q?=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 } From 2b8b296a7e99a7f3f55edc44589cf7992d0a0da1 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 8 Mar 2026 23:21:18 +0900 Subject: [PATCH 08/97] =?UTF-8?q?fix:=20CI=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/Data/Tests/RequestableTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FoodDiary/Data/Tests/RequestableTests.swift b/FoodDiary/Data/Tests/RequestableTests.swift index 68a76362..31969b33 100644 --- a/FoodDiary/Data/Tests/RequestableTests.swift +++ b/FoodDiary/Data/Tests/RequestableTests.swift @@ -75,6 +75,7 @@ struct RequestableTests { func testMultipartBody() throws { let formData = MultipartFormData( date: "2026-02-17", + deviceId: "abcd", photos: [ File(fileName: "photo1.jpg", mimeType: "image/jpeg", data: "dummy-1".data(using: .utf8)!), File(fileName: "photo2.jpg", mimeType: "image/jpeg", data: "dummy-2".data(using: .utf8)!) @@ -91,6 +92,7 @@ struct RequestableTests { func testMultipartContentTypeHeader() throws { let formData = MultipartFormData( date: "2026-02-17", + deviceId: "abcd", photos: [ File(fileName: "photo1.jpg", mimeType: "image/jpeg", data: "dummy-1".data(using: .utf8)!), File(fileName: "photo2.jpg", mimeType: "image/jpeg", data: "dummy-2".data(using: .utf8)!) From 25d2ca9476447e16991f50b4a633f64f0a012450 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Mon, 9 Mar 2026 20:29:44 +0900 Subject: [PATCH 09/97] =?UTF-8?q?chore:=20blue,=20blueLight,=20primaryLigh?= =?UTF-8?q?t100=20=EC=BB=AC=EB=9F=AC=20=EC=97=90=EC=85=8B=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 --- .../Colors/blue.colorset/Contents.json | 23 +++++++++++++++++++ .../Colors/blueLight.colorset/Contents.json | 20 ++++++++++++++++ .../primaryLight100.colorset/Contents.json | 20 ++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blue.colorset/Contents.json create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueLight.colorset/Contents.json create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryLight100.colorset/Contents.json diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blue.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blue.colorset/Contents.json new file mode 100644 index 00000000..4dadb55b --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blue.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x99", + "green" : "0x51", + "red" : "0x41" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueLight.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueLight.colorset/Contents.json new file mode 100644 index 00000000..9274779e --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueLight.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE6", + "green" : "0xA6", + "red" : "0x8A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryLight100.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryLight100.colorset/Contents.json new file mode 100644 index 00000000..fc8f545e --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryLight100.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x83", + "green" : "0xB1", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From 3cdebfcc02d9f809f2e0f6d172550abdb3fff05a Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Mon, 9 Mar 2026 20:29:53 +0900 Subject: [PATCH 10/97] =?UTF-8?q?feat:=20InsightPhotoStatsView=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=BB=AC=EB=9F=AC=20=EC=9D=B5?= =?UTF-8?q?=EC=8A=A4=ED=85=90=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Core/UIColor+DesignSystem.swift | 7 + .../Components/InsightPhotoStatsView.swift | 204 ++++++++++++++++-- 2 files changed, 191 insertions(+), 20 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift index 40688ba5..441722eb 100644 --- a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift +++ b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift @@ -10,6 +10,13 @@ public extension UIColor { // MARK: - Primary static let primary = DesignSystemAsset.primary.color + static let primaryLight = DesignSystemAsset.primaryLight.color + static let primaryLight100 = DesignSystemAsset.primaryLight100.color + + // MARK: - Blue + + static let blue = DesignSystemAsset.blue.color + static let blueLight = DesignSystemAsset.blueLight.color // MARK: - Red Positive diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift index 7f27153f..7799473b 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -9,11 +9,29 @@ import UIKit final class InsightPhotoStatsView: UIView { + // MARK: - Constants + + private enum Constants { + static let maxBarHeight: CGFloat = 160 + static let lineCount: Int = 6 + } + // MARK: - UI Components - private let titleLabel = UILabel() - private let countLabel = UILabel() - private let changeRateLabel = UILabel() + private let descriptionLabel = UILabel() + private let changeRateStackView = UIStackView() + private let headerStackView = UIStackView() + private let chartContainerView = UIView() + private let linesStackView = UIStackView() + private let barsStackView = UIStackView() + private let monthLabelsStackView = UIStackView() + + private struct BarInfo { + let barView: GradientBarView + let label: UIView + let barHeight: CGFloat + } + private var barInfos: [BarInfo] = [] // MARK: - Init @@ -35,37 +53,183 @@ final class InsightPhotoStatsView: UIView { layer.cornerRadius = 16 clipsToBounds = true - titleLabel.setText("📸 사진 기록", style: .hd18) - addSubview(titleLabel) + descriptionLabel.setText("먹기 전에\n카메라부터 찾았네요.", style: .hd16, color: .gray050) + descriptionLabel.numberOfLines = 2 + + setupChangeRateLabel(photoStats: photoStats) - let countText = "\(month)에 \(photoStats.currentMonthCount)장의 사진을 기록했어요" - countLabel.setText(countText, style: .p15, color: .gray050) - countLabel.numberOfLines = 0 - addSubview(countLabel) + headerStackView.axis = .vertical + headerStackView.spacing = 10 + headerStackView.addArrangedSubview(descriptionLabel) + headerStackView.addArrangedSubview(changeRateStackView) + addSubview(headerStackView) + + setupChart(photoStats: photoStats, month: month) + } + private func setupChangeRateLabel(photoStats: PhotoStats) { 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) + let rateColor: UIColor = rate >= 0 ? .primary : .rn500 + + let prefixLabel = UILabel() + prefixLabel.setText("지난 달 대비 기록된 사진이 ", style: .p10, color: .gray050) + + let valueLabel = UILabel() + valueLabel.setText("\(Int(rate))%", style: .hd16, color: rateColor) + + let suffixLabel = UILabel() + suffixLabel.setText(" 증가했어요.", style: .p10, color: .gray050) + + changeRateStackView.axis = .horizontal + changeRateStackView.alignment = .center + changeRateStackView.spacing = 0 + changeRateStackView.addArrangedSubview(prefixLabel) + changeRateStackView.addArrangedSubview(valueLabel) + changeRateStackView.addArrangedSubview(suffixLabel) + } + + private func setupChart(photoStats: PhotoStats, month: String) { + addSubview(chartContainerView) + + setupLines() + setupBars(photoStats: photoStats, month: month) + setupMonthLabels(photoStats: photoStats, month: month) + } + + private func setupLines() { + linesStackView.axis = .vertical + linesStackView.distribution = .equalSpacing + chartContainerView.addSubview(linesStackView) + + for _ in 0.. 0 ? (count / total) * Constants.maxBarHeight : 0 + let barView = GradientBarView(colors: gradientColors[i]) + + let label = UILabel() + label.text = "\(countInts[i])" + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .white + label.textAlignment = .center + + barView.addSubview(label) + + barInfos.append(BarInfo(barView: barView, label: label, barHeight: barHeight)) + barsStackView.addArrangedSubview(barView) + } + } + + private func setupMonthLabels(photoStats: PhotoStats, month: String) { + monthLabelsStackView.axis = .horizontal + monthLabelsStackView.distribution = .fill + monthLabelsStackView.spacing = 60 + addSubview(monthLabelsStackView) + + let currentMonthNum = month.split(separator: "-").last.flatMap { Int($0) } ?? 1 + let previousMonthNum = currentMonthNum == 1 ? 12 : currentMonthNum - 1 + + for name in ["\(previousMonthNum)월", "\(currentMonthNum)월"] { + let label = UILabel() + label.setText(name, style: .p10, color: .gray200) + label.textAlignment = .center + label.snp.makeConstraints { $0.width.equalTo(60) } + monthLabelsStackView.addArrangedSubview(label) + } } private func setupConstraints() { - titleLabel.snp.makeConstraints { + headerStackView.snp.makeConstraints { $0.top.leading.equalToSuperview().inset(20) $0.trailing.lessThanOrEqualToSuperview().inset(20) } - countLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(12) + chartContainerView.snp.makeConstraints { + $0.top.equalTo(headerStackView.snp.bottom).offset(24) $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(Constants.maxBarHeight) } - changeRateLabel.snp.makeConstraints { - $0.top.equalTo(countLabel.snp.bottom).offset(8) - $0.leading.trailing.equalToSuperview().inset(20) + linesStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + barsStackView.snp.makeConstraints { + $0.centerX.bottom.equalToSuperview() + $0.top.greaterThanOrEqualToSuperview() + } + + for info in barInfos { + info.barView.snp.makeConstraints { + $0.width.equalTo(60) + $0.height.equalTo(info.barHeight) + } + + info.label.snp.makeConstraints { + $0.top.equalToSuperview().inset(6) + $0.centerX.equalToSuperview() + } + } + + monthLabelsStackView.snp.makeConstraints { + $0.top.equalTo(chartContainerView.snp.bottom).offset(8) + $0.centerX.equalTo(barsStackView) + $0.width.equalTo(barsStackView) $0.bottom.equalToSuperview().inset(20) } } } + +// MARK: - GradientBarView + +private final class GradientBarView: UIView { + + private let gradientLayer = CAGradientLayer() + + init(colors: [UIColor]) { + super.init(frame: .zero) + clipsToBounds = true + layer.cornerRadius = 4 + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + gradientLayer.colors = colors.map(\.cgColor) + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + layer.insertSublayer(gradientLayer, at: 0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } +} From 29887c0aed56bd9ae641f405a4152a675aa93c92 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 10 Mar 2026 00:47:22 +0900 Subject: [PATCH 11/97] =?UTF-8?q?chore:=20Color=20Asset=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contents.json | 0 .../Contents.json | 0 .../Contents.json | 0 .../Contents.json | 20 +++++++++++++++++++ .../Sources/Core/UIColor+DesignSystem.swift | 11 +++++----- .../Components/InsightPhotoStatsView.swift | 4 ++-- 6 files changed, 28 insertions(+), 7 deletions(-) rename FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/{blueLight.colorset => blueGradientEnd.colorset}/Contents.json (100%) rename FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/{blue.colorset => blueGradientStart.colorset}/Contents.json (100%) rename FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/{primaryLight100.colorset => primaryGradientEnd.colorset}/Contents.json (100%) create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientStart.colorset/Contents.json diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueLight.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientEnd.colorset/Contents.json similarity index 100% rename from FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueLight.colorset/Contents.json rename to FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientEnd.colorset/Contents.json diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blue.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientStart.colorset/Contents.json similarity index 100% rename from FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blue.colorset/Contents.json rename to FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientStart.colorset/Contents.json diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryLight100.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientEnd.colorset/Contents.json similarity index 100% rename from FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryLight100.colorset/Contents.json rename to FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientEnd.colorset/Contents.json diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientStart.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientStart.colorset/Contents.json new file mode 100644 index 00000000..3e0dcabe --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientStart.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0E", + "green" : "0x67", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift index 441722eb..6ccccea8 100644 --- a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift +++ b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift @@ -11,12 +11,13 @@ public extension UIColor { static let primary = DesignSystemAsset.primary.color static let primaryLight = DesignSystemAsset.primaryLight.color - static let primaryLight100 = DesignSystemAsset.primaryLight100.color - + static let primaryGradientStart = DesignSystemAsset.primaryGradientStart.color + static let primaryGradientEnd = DesignSystemAsset.primaryGradientEnd.color + // MARK: - Blue - - static let blue = DesignSystemAsset.blue.color - static let blueLight = DesignSystemAsset.blueLight.color + + static let blueGradientStart = DesignSystemAsset.blueGradientStart.color + static let blueGradientEnd = DesignSystemAsset.blueGradientEnd.color // MARK: - Red Positive diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift index 7799473b..ad1aab45 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -124,8 +124,8 @@ final class InsightPhotoStatsView: UIView { let counts = [prevCount, currCount] let countInts = [Int(prevCount), Int(currCount)] let gradientColors: [[UIColor]] = [ - [.blue, .blueLight], - [.primary, .primaryLight100] + [.blueGradientStart, .blueGradientEnd], + [.primaryGradientStart, .primaryGradientEnd] ] for (i, count) in counts.enumerated() { From 0a834ade283fcf2d3a89d2696ad49e92cf2b0534 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 10 Mar 2026 00:54:32 +0900 Subject: [PATCH 12/97] =?UTF-8?q?fix:=20=EB=B0=94=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=EB=86=92=EC=9D=B4=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=90=EC=86=8C=20=EC=98=88=EC=99=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Insight/Components/InsightPhotoStatsView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift index ad1aab45..bf843ef4 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -13,6 +13,7 @@ final class InsightPhotoStatsView: UIView { private enum Constants { static let maxBarHeight: CGFloat = 160 + static let minBarHeight: CGFloat = 30 static let lineCount: Int = 6 } @@ -69,16 +70,15 @@ final class InsightPhotoStatsView: UIView { private func setupChangeRateLabel(photoStats: PhotoStats) { let rate = photoStats.changeRate - let rateColor: UIColor = rate >= 0 ? .primary : .rn500 let prefixLabel = UILabel() prefixLabel.setText("지난 달 대비 기록된 사진이 ", style: .p10, color: .gray050) let valueLabel = UILabel() - valueLabel.setText("\(Int(rate))%", style: .hd16, color: rateColor) + valueLabel.setText("\(Int(rate))%", style: .hd16, color: .primary) let suffixLabel = UILabel() - suffixLabel.setText(" 증가했어요.", style: .p10, color: .gray050) + suffixLabel.setText(rate >= 0 ? " 증가했어요." : " 감소했어요.", style: .p10, color: .gray050) changeRateStackView.axis = .horizontal changeRateStackView.alignment = .center @@ -129,7 +129,7 @@ final class InsightPhotoStatsView: UIView { ] for (i, count) in counts.enumerated() { - let barHeight = total > 0 ? (count / total) * Constants.maxBarHeight : 0 + let barHeight = total > 0 ? max((count / total) * Constants.maxBarHeight, Constants.minBarHeight) : 0 let barView = GradientBarView(colors: gradientColors[i]) let label = UILabel() From cf9e3a062506cbace5dd8fe56e3aef6b2a1da767 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 10 Mar 2026 21:48:05 +0900 Subject: [PATCH 13/97] =?UTF-8?q?fix:=20=EA=B0=90=EC=86=8C,=20=EC=A6=9D?= =?UTF-8?q?=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Insight/Components/InsightPhotoStatsView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift index bf843ef4..5b71fd4b 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -54,7 +54,10 @@ final class InsightPhotoStatsView: UIView { layer.cornerRadius = 16 clipsToBounds = true - descriptionLabel.setText("먹기 전에\n카메라부터 찾았네요.", style: .hd16, color: .gray050) + let descriptionText = photoStats.changeRate >= 0 + ? "먹기 전에\n카메라부터 찾았네요." + : "이번 달엔 음식에 더 집중하셨네요." + descriptionLabel.setText(descriptionText, style: .hd16, color: .gray050) descriptionLabel.numberOfLines = 2 setupChangeRateLabel(photoStats: photoStats) @@ -75,7 +78,7 @@ final class InsightPhotoStatsView: UIView { prefixLabel.setText("지난 달 대비 기록된 사진이 ", style: .p10, color: .gray050) let valueLabel = UILabel() - valueLabel.setText("\(Int(rate))%", style: .hd16, color: .primary) + valueLabel.setText("\(Int(abs(rate)))%", style: .hd16, color: .primary) let suffixLabel = UILabel() suffixLabel.setText(rate >= 0 ? " 증가했어요." : " 감소했어요.", style: .p10, color: .gray050) From 108e83e2de9ed48da26b501842fd1ea846e610cc Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 10 Mar 2026 00:41:32 +0900 Subject: [PATCH 14/97] =?UTF-8?q?feat:=20donut=20chart=20ui=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Insight/Components/DonutChartView.swift | 189 ++++++++++++++++++ .../Components/InsightCategoryStatsView.swift | 58 +++--- 2 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/DonutChartView.swift diff --git a/FoodDiary/Presentation/Sources/Insight/Components/DonutChartView.swift b/FoodDiary/Presentation/Sources/Insight/Components/DonutChartView.swift new file mode 100644 index 00000000..8f3df2e1 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/DonutChartView.swift @@ -0,0 +1,189 @@ +// +// DonutChartView.swift +// Presentation +// + +import UIKit + +final class DonutChartView: UIView { + struct SliceData { + let value: Double + let colors: [UIColor] + let label: String + } + + var data: [SliceData] = [] + var innerRadiusRatio: CGFloat = 0.5 + var animationDuration: CFTimeInterval = 1.2 + var separatorColor: UIColor = .black + + private var total: Double { data.reduce(0) { $0 + $1.value } } + private let sliceContainer = CALayer() + private var labelLayers: [CATextLayer] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + layer.addSublayer(sliceContainer) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + layer.addSublayer(sliceContainer) + } + + override func layoutSubviews() { + super.layoutSubviews() + sliceContainer.frame = bounds + setup() + } + + // MARK: - Setup + + private func setup() { + guard bounds.width > 0, total > 0 else { return } + + sliceContainer.sublayers?.forEach { $0.removeFromSuperlayer() } + labelLayers.forEach { $0.removeFromSuperlayer() } + labelLayers.removeAll() + sliceContainer.mask = nil + + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let outerRadius = min(bounds.width, bounds.height) / 2 + let innerRadius = outerRadius * innerRadiusRatio + let midRadius = (outerRadius + innerRadius) / 2 + let startOffset = -CGFloat.pi / 2 + var currentAngle = startOffset + var boundaryAngles: [CGFloat] = [] + + for item in data { + let sweep = CGFloat(item.value / total) * 2 * .pi + + let path = UIBezierPath() + path.move(to: CGPoint( + x: center.x + outerRadius * cos(currentAngle), + y: center.y + outerRadius * sin(currentAngle) + )) + path.addArc(withCenter: center, radius: outerRadius, + startAngle: currentAngle, endAngle: currentAngle + sweep, clockwise: true) + path.addArc(withCenter: center, radius: innerRadius, + startAngle: currentAngle + sweep, endAngle: currentAngle, clockwise: false) + path.close() + + let shapeMask = CAShapeLayer() + shapeMask.path = path.cgPath + + let gradientLayer = CAGradientLayer() + gradientLayer.frame = bounds + gradientLayer.colors = item.colors.map(\.cgColor) + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + gradientLayer.mask = shapeMask + sliceContainer.addSublayer(gradientLayer) + + let midAngle = currentAngle + sweep / 2 + let labelPoint = CGPoint( + x: center.x + midRadius * cos(midAngle), + y: center.y + midRadius * sin(midAngle) + ) + let textLayer = makeTextLayer(item.label, at: labelPoint) + textLayer.opacity = 0 + layer.addSublayer(textLayer) + labelLayers.append(textLayer) + + boundaryAngles.append(currentAngle) + currentAngle += sweep + } + + // 슬라이스 경계에 배경색 선을 덧그려 균일한 간격 효과 적용 + for angle in boundaryAngles { + let separatorLayer = CAShapeLayer() + let separatorPath = UIBezierPath() + separatorPath.move(to: CGPoint( + x: center.x + innerRadius * cos(angle), + y: center.y + innerRadius * sin(angle) + )) + separatorPath.addLine(to: CGPoint( + x: center.x + outerRadius * cos(angle), + y: center.y + outerRadius * sin(angle) + )) + separatorLayer.path = separatorPath.cgPath + separatorLayer.strokeColor = separatorColor.cgColor + separatorLayer.lineWidth = 3 + separatorLayer.lineCap = .square + sliceContainer.addSublayer(separatorLayer) + } + + let ringWidth = outerRadius - innerRadius + let animMask = CAShapeLayer() + let circlePath = UIBezierPath( + arcCenter: center, + radius: midRadius, + startAngle: startOffset, + endAngle: startOffset + 2 * .pi, + clockwise: true + ) + animMask.path = circlePath.cgPath + animMask.fillColor = UIColor.clear.cgColor + animMask.strokeColor = UIColor.black.cgColor + animMask.lineWidth = ringWidth + 2 + animMask.strokeEnd = 0 + sliceContainer.mask = animMask + + animateMask(animMask) + } + + // MARK: - Animation + + private func animateMask(_ maskLayer: CAShapeLayer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + maskLayer.strokeEnd = 1 + CATransaction.commit() + + let anim = CABasicAnimation(keyPath: "strokeEnd") + anim.fromValue = 0 + anim.toValue = 1 + anim.duration = animationDuration + anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + maskLayer.add(anim, forKey: "drawDonut") + + Task { + try? await Task.sleep(for: .seconds(animationDuration)) + fadeInLabels() + } + } + + private func fadeInLabels() { + for label in labelLayers { + let anim = CABasicAnimation(keyPath: "opacity") + anim.fromValue = 0 + anim.toValue = 1 + anim.duration = 0.3 + label.opacity = 1 + label.add(anim, forKey: "fadeIn") + } + } + + // MARK: - Helper + + private func makeTextLayer(_ text: String, at point: CGPoint) -> CATextLayer { + let attributed = Typography.p12.styled(text, color: .white) + let size = attributed.boundingRect( + with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: .usesLineFragmentOrigin, + context: nil + ).size + + let textLayer = CATextLayer() + textLayer.string = attributed + textLayer.alignmentMode = .center + textLayer.contentsScale = UIScreen.main.scale + textLayer.frame = CGRect( + x: point.x - size.width / 2, + y: point.y - size.height / 2, + width: size.width, + height: size.height + ) + return textLayer + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift index ec52bf85..4388dadf 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift @@ -11,9 +11,9 @@ final class InsightCategoryStatsView: UIView { // MARK: - UI Components - private let titleLabel = UILabel() - private let currentMonthLabel = UILabel() - private let previousMonthLabel = UILabel() + private let descriptionLabel = UILabel() + private let rankLabel = UILabel() + private let donutChartView = DonutChartView() // MARK: - Init @@ -35,40 +35,48 @@ final class InsightCategoryStatsView: UIView { 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) + let isSameCategory = current.topCategory == previous.topCategory + + let descriptionText = isSameCategory ? "왕좌가 유지되었어요." : "왕좌가 바뀌었어요." + descriptionLabel.setText(descriptionText, style: .hd16, color: .gray050) + addSubview(descriptionLabel) + + let attributed = NSMutableAttributedString() + if !isSameCategory { + attributed.append(Typography.hd16.styled(previous.topCategory, color: .blueGradientStart)) + attributed.append(Typography.hd16.styled(" 대신 ", color: .gray050)) + } + attributed.append(Typography.hd16.styled(current.topCategory, color: .primary)) + attributed.append(Typography.hd16.styled(" 이 1등이에요.", color: .gray050)) + rankLabel.attributedText = attributed + addSubview(rankLabel) + + donutChartView.innerRadiusRatio = 0.3 + donutChartView.separatorColor = .sd900 + donutChartView.data = [ + DonutChartView.SliceData(value: Double(current.count), colors: [.primaryGradientStart, .primaryGradientEnd], label: "\(current.count)회"), + DonutChartView.SliceData(value: Double(previous.count), colors: [.blueGradientStart, .blueGradientEnd], label: "\(previous.count)회") + ] + addSubview(donutChartView) } private func setupConstraints() { - titleLabel.snp.makeConstraints { + descriptionLabel.snp.makeConstraints { $0.top.leading.equalToSuperview().inset(20) $0.trailing.lessThanOrEqualToSuperview().inset(20) } - currentMonthLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(12) + rankLabel.snp.makeConstraints { + $0.top.equalTo(descriptionLabel.snp.bottom).offset(8) $0.leading.trailing.equalToSuperview().inset(20) } - previousMonthLabel.snp.makeConstraints { - $0.top.equalTo(currentMonthLabel.snp.bottom).offset(8) - $0.leading.trailing.equalToSuperview().inset(20) + donutChartView.snp.makeConstraints { + $0.top.equalTo(rankLabel.snp.bottom).offset(20) + $0.centerX.equalToSuperview() + $0.width.height.equalTo(220) $0.bottom.equalToSuperview().inset(20) } } From 98007d8de57f57f2c6fd20205f1238383c60185f Mon Sep 17 00:00:00 2001 From: enebin Date: Tue, 10 Mar 2026 21:50:38 +0900 Subject: [PATCH 15/97] =?UTF-8?q?feat:=20InsightDiaryTimeStatsView=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 바 차트 형태에서 큰 시간 텍스트 카드 스타일로 UI 대체 --- .../InsightDiaryTimeStatsView.swift | 96 ++++++++----------- 1 file changed, 41 insertions(+), 55 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift index 66e3e451..f6e1b852 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift @@ -3,25 +3,19 @@ // Presentation // +import DesignSystem 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() + private let subtitleLabel = UILabel() + private let descriptionLabel = UILabel() + private let bigTimeLabel = UILabel() // MARK: - Init @@ -29,7 +23,6 @@ final class InsightDiaryTimeStatsView: UIView { super.init(frame: .zero) setupUI(diaryTimeStats: diaryTimeStats) setupConstraints() - buildChart(distribution: diaryTimeStats.distribution) } @available(*, unavailable) @@ -44,15 +37,37 @@ final class InsightDiaryTimeStatsView: UIView { 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) + let timeText = String(format: "%d:00", hour) + + titleLabel.setText("식사는 늘,", style: .hd18, color: .primary) + addSubview(titleLabel) - addSubview(chartContainerView) + let timeAttr = Typography.hd20.styled(timeText, color: .primary) + let suffixAttr = Typography.hd20.styled(" 이 제일 많았어요", color: .gray200) + let combined = NSMutableAttributedString() + combined.append(timeAttr) + combined.append(suffixAttr) + subtitleLabel.attributedText = combined + addSubview(subtitleLabel) + + descriptionLabel.setText( + "이 시간대에 음식 사진이 가장 많이 남았어요.", + style: .p14, + color: .gray400 + ) + descriptionLabel.numberOfLines = 0 + addSubview(descriptionLabel) + + bigTimeLabel.attributedText = NSAttributedString( + string: timeText, + attributes: [ + .font: DesignSystemFontFamily.Pretendard.bold.font(size: 50), + .foregroundColor: UIColor.primary + ] + ) + bigTimeLabel.textAlignment = .center + addSubview(bigTimeLabel) } private func setupConstraints() { @@ -61,49 +76,20 @@ final class InsightDiaryTimeStatsView: UIView { $0.trailing.lessThanOrEqualToSuperview().inset(20) } - activeHourLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(12) + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(4) $0.leading.trailing.equalToSuperview().inset(20) } - chartContainerView.snp.makeConstraints { - $0.top.equalTo(activeHourLabel.snp.bottom).offset(16) + descriptionLabel.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(12) $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) + bigTimeLabel.snp.makeConstraints { + $0.top.equalTo(descriptionLabel.snp.bottom).offset(24) + $0.centerX.equalToSuperview() + $0.bottom.equalToSuperview().inset(28) } } } From 3c9d9bc57b136029be094666cc35171a87773023 Mon Sep 17 00:00:00 2001 From: enebin Date: Tue, 10 Mar 2026 22:28:38 +0900 Subject: [PATCH 16/97] =?UTF-8?q?feat:=20InsightTopMenuView=20=EB=B0=94=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EC=B9=B4=EB=93=9C=EB=A1=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Core/Typography.swift | 4 + .../Insight/Components/GradientBarView.swift | 33 ++++++ .../InsightDiaryTimeStatsView.swift | 38 ++----- .../Components/InsightPhotoStatsView.swift | 29 ----- .../Components/InsightTopMenuView.swift | 101 ++++++++++++++++-- 5 files changed, 135 insertions(+), 70 deletions(-) create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/GradientBarView.swift diff --git a/FoodDiary/Presentation/Sources/Core/Typography.swift b/FoodDiary/Presentation/Sources/Core/Typography.swift index ede4dd66..f68a8164 100644 --- a/FoodDiary/Presentation/Sources/Core/Typography.swift +++ b/FoodDiary/Presentation/Sources/Core/Typography.swift @@ -26,6 +26,8 @@ public enum Typography { case hd18 /// 페이지 서브 타이틀 2nd - 16pt Bold case hd16 + /// 소형 헤드라인 - 15pt Bold + case hd15 // MARK: - Paragraph (Regular, 120% line height) /// 본문 - 18pt Regular @@ -50,6 +52,8 @@ public enum Typography { return DesignSystemFontFamily.Pretendard.bold.font(size: 18) case .hd16: return DesignSystemFontFamily.Pretendard.bold.font(size: 16) + case .hd15: + return DesignSystemFontFamily.Pretendard.bold.font(size: 15) case .p18: return DesignSystemFontFamily.Pretendard.regular.font(size: 18) case .p15: diff --git a/FoodDiary/Presentation/Sources/Insight/Components/GradientBarView.swift b/FoodDiary/Presentation/Sources/Insight/Components/GradientBarView.swift new file mode 100644 index 00000000..e46423fc --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/GradientBarView.swift @@ -0,0 +1,33 @@ +// +// GradientBarView.swift +// Presentation +// + +import UIKit + +final class GradientBarView: UIView { + + private let gradientLayer = CAGradientLayer() + + init(colors: [UIColor]) { + super.init(frame: .zero) + clipsToBounds = true + layer.cornerRadius = 4 + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + gradientLayer.colors = colors.map(\.cgColor) + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + layer.insertSublayer(gradientLayer, at: 0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift index f6e1b852..cf774d21 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift @@ -13,8 +13,6 @@ final class InsightDiaryTimeStatsView: UIView { // MARK: - UI Components private let titleLabel = UILabel() - private let subtitleLabel = UILabel() - private let descriptionLabel = UILabel() private let bigTimeLabel = UILabel() // MARK: - Init @@ -40,24 +38,14 @@ final class InsightDiaryTimeStatsView: UIView { let hour = diaryTimeStats.mostActiveHour let timeText = String(format: "%d:00", hour) - titleLabel.setText("식사는 늘,", style: .hd18, color: .primary) - addSubview(titleLabel) - - let timeAttr = Typography.hd20.styled(timeText, color: .primary) - let suffixAttr = Typography.hd20.styled(" 이 제일 많았어요", color: .gray200) - let combined = NSMutableAttributedString() - combined.append(timeAttr) - combined.append(suffixAttr) - subtitleLabel.attributedText = combined - addSubview(subtitleLabel) + let attributed = NSMutableAttributedString() + attributed.append(Typography.hd15.styled("식사는 늘,\n", color: .white, lineSpacing: 6)) + attributed.append(Typography.hd15.styled(timeText, color: .primary)) + attributed.append(Typography.hd15.styled(" 이 제일 많았어요", color: .gray200)) - descriptionLabel.setText( - "이 시간대에 음식 사진이 가장 많이 남았어요.", - style: .p14, - color: .gray400 - ) - descriptionLabel.numberOfLines = 0 - addSubview(descriptionLabel) + titleLabel.attributedText = attributed + titleLabel.numberOfLines = 0 + addSubview(titleLabel) bigTimeLabel.attributedText = NSAttributedString( string: timeText, @@ -76,18 +64,8 @@ final class InsightDiaryTimeStatsView: UIView { $0.trailing.lessThanOrEqualToSuperview().inset(20) } - subtitleLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(4) - $0.leading.trailing.equalToSuperview().inset(20) - } - - descriptionLabel.snp.makeConstraints { - $0.top.equalTo(subtitleLabel.snp.bottom).offset(12) - $0.leading.trailing.equalToSuperview().inset(20) - } - bigTimeLabel.snp.makeConstraints { - $0.top.equalTo(descriptionLabel.snp.bottom).offset(24) + $0.top.equalTo(titleLabel.snp.bottom).offset(24) $0.centerX.equalToSuperview() $0.bottom.equalToSuperview().inset(28) } diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift index 5b71fd4b..79915792 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -207,32 +207,3 @@ final class InsightPhotoStatsView: UIView { } } } - -// MARK: - GradientBarView - -private final class GradientBarView: UIView { - - private let gradientLayer = CAGradientLayer() - - init(colors: [UIColor]) { - super.init(frame: .zero) - clipsToBounds = true - layer.cornerRadius = 4 - layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - - gradientLayer.colors = colors.map(\.cgColor) - gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) - gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) - layer.insertSublayer(gradientLayer, at: 0) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - gradientLayer.frame = bounds - } -} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift index 888e4477..f25a38c5 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift @@ -3,20 +3,35 @@ // Presentation // +import DesignSystem import Domain import SnapKit import UIKit final class InsightTopMenuView: UIView { + // MARK: - Constants + + private enum Constants { + static let maxBarHeight: CGFloat = 160 + static let lineCount: Int = 6 + static let barWidth: CGFloat = 60 + } + // MARK: - UI Components private let titleLabel = UILabel() - private let menuLabel = UILabel() + private let separatorView = UIView() + private let chartContainerView = UIView() + private let linesStackView = UIStackView() + private let barView: GradientBarView + private let countLabel = UILabel() + private let nameLabel = UILabel() // MARK: - Init init(topMenu: TopMenu) { + barView = GradientBarView(colors: [.primaryGradientStart, .primaryGradientEnd]) super.init(frame: .zero) setupUI(topMenu: topMenu) setupConstraints() @@ -34,16 +49,53 @@ final class InsightTopMenuView: UIView { layer.cornerRadius = 16 clipsToBounds = true - titleLabel.setText("🏆 최다 메뉴", style: .hd18) + setupTitle(name: topMenu.name) + setupSeparator() + setupChart(count: topMenu.count) + } + + private func setupTitle(name: String) { + let attributed = NSMutableAttributedString() + attributed.append(Typography.hd15.styled("가장 자주 먹은\n음식은 ", color: .white, lineSpacing: 6)) + attributed.append(Typography.hd15.styled(name, color: .primary)) + + titleLabel.attributedText = attributed + titleLabel.numberOfLines = 0 addSubview(titleLabel) + } + + private func setupSeparator() { + separatorView.backgroundColor = .sd800 + addSubview(separatorView) + } + + private func setupChart(count: Int) { + addSubview(chartContainerView) + + // Grid lines + linesStackView.axis = .vertical + linesStackView.distribution = .equalSpacing + chartContainerView.addSubview(linesStackView) + + for _ in 0.. Date: Tue, 10 Mar 2026 23:07:19 +0900 Subject: [PATCH 17/97] =?UTF-8?q?design:=20=EC=BB=AC=EB=9F=AC=20=EC=97=90?= =?UTF-8?q?=EC=85=8B=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Colors/sd850.colorset/Contents.json | 20 +++++++++++++++++++ .../Sources/Core/UIColor+DesignSystem.swift | 1 + 2 files changed, 21 insertions(+) create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/sd850.colorset/Contents.json diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/sd850.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/sd850.colorset/Contents.json new file mode 100644 index 00000000..3bb76f8d --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/sd850.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x33", + "green" : "0x29", + "red" : "0x2A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift index 6ccccea8..0deb0932 100644 --- a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift +++ b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift @@ -39,6 +39,7 @@ public extension UIColor { static let sdBase = DesignSystemAsset.sdBase.color static let sd900 = DesignSystemAsset.sd900.color + static let sd850 = DesignSystemAsset.sd850.color static let sd800 = DesignSystemAsset.sd800.color static let sd700 = DesignSystemAsset.sd700.color static let sd600 = DesignSystemAsset.sd600.color From 130a81487d26fbf05564047b0ef27684aab37ea0 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 10 Mar 2026 23:07:50 +0900 Subject: [PATCH 18/97] =?UTF-8?q?feat:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=ED=86=B5=EA=B3=84=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 --- .../Components/InsightKeywordsView.swift | 88 ++++++------------- 1 file changed, 26 insertions(+), 62 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift index 35e65611..b3e250b5 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift @@ -8,21 +8,11 @@ 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 tagsStackView = UIStackView() private let keywords: [String] - private var didLayoutTags = false // MARK: - Init @@ -45,9 +35,20 @@ final class InsightKeywordsView: UIView { layer.cornerRadius = 16 clipsToBounds = true - titleLabel.setText("💬 키워드", style: .hd18) + titleLabel.setText("나의 입맛과\n가장 잘 어울리는 키워드", style: .hd16, color: .gray050) + titleLabel.numberOfLines = 2 + + tagsStackView.axis = .horizontal + tagsStackView.spacing = 8 + tagsStackView.alignment = .center + tagsStackView.distribution = .equalSpacing + + for keyword in keywords { + tagsStackView.addArrangedSubview(makeTagView(text: keyword)) + } + addSubview(titleLabel) - addSubview(tagsContainerView) + addSubview(tagsStackView) } private func setupConstraints() { @@ -56,73 +57,36 @@ final class InsightKeywordsView: UIView { $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) + tagsStackView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(32) + $0.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) $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) - } - } + // MARK: - Tag private func makeTagView(text: String) -> UIView { let container = UIView() - container.backgroundColor = .sd700 - container.layer.cornerRadius = Constants.tagHeight / 2 + container.backgroundColor = .sd850 let label = UILabel() - label.setText("#\(text)", style: .p14, color: .gray050) + label.setText("#\(text)", style: .p14, color: .gray400) + container.addSubview(label) label.snp.makeConstraints { - $0.leading.trailing.equalToSuperview().inset(Constants.tagHorizontalPadding) + $0.leading.trailing.equalToSuperview().inset(14) $0.centerY.equalToSuperview() } container.snp.makeConstraints { - $0.height.equalTo(Constants.tagHeight) + $0.height.equalTo(36) } + container.layer.cornerRadius = 18 + return container } } From 2fc0505936933b1eb1d5774953877e853258e753 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Thu, 12 Mar 2026 23:02:40 +0900 Subject: [PATCH 19/97] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20FlowRow=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/InsightKeywordsView.swift | 86 +++++++++++++------ 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift index b3e250b5..657a0fc7 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift @@ -8,11 +8,23 @@ import UIKit final class InsightKeywordsView: UIView { + // MARK: - Constants + + private enum Constants { + static let inset: CGFloat = 20 + static let tagHeight: CGFloat = 36 + static let horizontalSpacing: CGFloat = 8 + static let verticalSpacing: CGFloat = 8 + } + // MARK: - UI Components private let titleLabel = UILabel() - private let tagsStackView = UIStackView() + private let tagsContainer = UIView() private let keywords: [String] + private var tagViews: [UIView] = [] + private var tagsContainerHeightConstraint: Constraint? + private var lastTagsHeight: CGFloat = 0 // MARK: - Init @@ -37,43 +49,71 @@ final class InsightKeywordsView: UIView { titleLabel.setText("나의 입맛과\n가장 잘 어울리는 키워드", style: .hd16, color: .gray050) titleLabel.numberOfLines = 2 - - tagsStackView.axis = .horizontal - tagsStackView.spacing = 8 - tagsStackView.alignment = .center - tagsStackView.distribution = .equalSpacing - - for keyword in keywords { - tagsStackView.addArrangedSubview(makeTagView(text: keyword)) - } - addSubview(titleLabel) - addSubview(tagsStackView) + addSubview(tagsContainer) + + tagViews = keywords.map { makeTagView(text: $0) } + tagViews.forEach { tagsContainer.addSubview($0) } } private func setupConstraints() { titleLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview().inset(20) - $0.trailing.lessThanOrEqualToSuperview().inset(20) + $0.top.leading.trailing.equalToSuperview().inset(Constants.inset) } - tagsStackView.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(32) - $0.leading.equalToSuperview().inset(20) - $0.trailing.lessThanOrEqualToSuperview().inset(20) - $0.bottom.equalToSuperview().inset(20) + tagsContainer.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(Constants.inset) + $0.bottom.equalToSuperview().inset(Constants.inset) + tagsContainerHeightConstraint = $0.height.equalTo(Constants.tagHeight).constraint } } + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + updateTagLayout() + } + + private func updateTagLayout() { + let containerWidth = tagsContainer.bounds.width + guard containerWidth > 0 else { return } + + var x: CGFloat = 0 + var y: CGFloat = 0 + + for tagView in tagViews { + let width = tagWidth(for: tagView) + if x + width > containerWidth, x > 0 { + x = 0 + y += Constants.tagHeight + Constants.verticalSpacing + } + tagView.frame = CGRect(x: x, y: y, width: width, height: Constants.tagHeight) + x += width + Constants.horizontalSpacing + } + + let totalHeight = tagViews.isEmpty ? Constants.tagHeight : y + Constants.tagHeight + guard totalHeight != lastTagsHeight else { return } + lastTagsHeight = totalHeight + tagsContainerHeightConstraint?.update(offset: totalHeight) + setNeedsLayout() + } + + private func tagWidth(for tagView: UIView) -> CGFloat { + guard let label = tagView.subviews.first as? UILabel else { return 80 } + return ceil(label.intrinsicContentSize.width) + 28 + } + // MARK: - Tag private func makeTagView(text: String) -> UIView { let container = UIView() container.backgroundColor = .sd850 + container.layer.cornerRadius = Constants.tagHeight / 2 let label = UILabel() label.setText("#\(text)", style: .p14, color: .gray400) - container.addSubview(label) label.snp.makeConstraints { @@ -81,12 +121,6 @@ final class InsightKeywordsView: UIView { $0.centerY.equalToSuperview() } - container.snp.makeConstraints { - $0.height.equalTo(36) - } - - container.layer.cornerRadius = 18 - return container } } From f9c410adec51d64be3b0c57da6317a78075400e2 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Thu, 12 Mar 2026 23:48:12 +0900 Subject: [PATCH 20/97] =?UTF-8?q?feat:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=ED=97=A4=EB=8D=94=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/InsightHeaderView.swift | 57 +++++++++++++++++++ .../Insight/InsightViewController.swift | 1 + 2 files changed, 58 insertions(+) create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/InsightHeaderView.swift diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightHeaderView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightHeaderView.swift new file mode 100644 index 00000000..a767964d --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightHeaderView.swift @@ -0,0 +1,57 @@ +// +// InsightHeaderView.swift +// Presentation +// + +import DesignSystem +import SnapKit +import UIKit + +final class InsightHeaderView: UIView { + + // MARK: - UI Components + + private let dateLabel = UILabel() + private let titleLabel = UILabel() + + // MARK: - Init + + init(date: Date = Date()) { + super.init(frame: .zero) + setupUI(date: date) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(date: Date) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + dateLabel.setText(formatter.string(from: date), style: .p12, color: .gray050) + + let attributed = NSMutableAttributedString() + attributed.append(Typography.hd20.styled("이번 달, ", color: .primary)) + attributed.append(Typography.hd20.styled("잘 먹었습니다.", color: .gray050)) + titleLabel.attributedText = attributed + titleLabel.numberOfLines = 0 + + addSubview(dateLabel) + addSubview(titleLabel) + } + + private func setupConstraints() { + dateLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(dateLabel.snp.bottom).offset(12) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift index a856924d..d20790e3 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift @@ -196,6 +196,7 @@ public final class InsightViewController: UIViewControl scrollView.isHidden = false let sections: [UIView] = [ + InsightHeaderView(), InsightPhotoStatsView(photoStats: insight.photoStats, month: insight.month), InsightCategoryStatsView(categoryStats: insight.categoryStats), InsightTopMenuView(topMenu: insight.topMenu), From 9c964f98078508acacfa4f351db780ca317ce2df Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 11 Mar 2026 20:07:59 +0900 Subject: [PATCH 21/97] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=A1=9C=20=EB=82=A0=EC=A7=9C=20=EB=B3=80=EA=B2=BD=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 --- .../MonthlyCalendarViewController.swift | 23 +++++++++++++++++++ .../ViewModel/MonthlyCalendarViewModel.swift | 6 +++++ 2 files changed, 29 insertions(+) diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index 8930e368..de8c51f5 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -115,6 +115,8 @@ public final class MonthlyCalendarViewController< view.addSubview(containerView) containerView.addSubview(stackView) + setupSwipeGestures() + let mypageButton = UIBarButtonItem( image: DesignSystemAsset.iconMypage.image, style: .plain, @@ -240,6 +242,27 @@ public final class MonthlyCalendarViewController< .store(in: &cancellables) } + private func setupSwipeGestures() { + let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:))) + swipeLeft.direction = .left + containerView.addGestureRecognizer(swipeLeft) + + let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:))) + swipeRight.direction = .right + containerView.addGestureRecognizer(swipeRight) + } + + @objc private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + let calendar = Calendar.current + let offset = gesture.direction == .left ? 1 : -1 + guard let newDate = calendar.date( + byAdding: .month, + value: offset, + to: viewModel.state.currentDisplayDate + ) else { return } + viewModel.input.send(.selectMonth(newDate)) + } + // MARK: - Private Methods private func updateCalendar(days: [MonthlyCalendarDay], numberOfWeeks: Int) { diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index 27c60797..b7ca9bf7 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -98,6 +98,12 @@ public final class MonthlyCalendarViewModel< await loadMonth(for: state.currentDisplayDate) case .selectMonth(let date): + let calendar = Calendar.current + let newComponents = calendar.dateComponents([.year, .month], from: date) + let todayComponents = calendar.dateComponents([.year, .month], from: Date()) + guard let newYearMonth = calendar.date(from: newComponents), + let todayYearMonth = calendar.date(from: todayComponents), + newYearMonth <= todayYearMonth else { return } state.currentDisplayDate = date await loadMonth(for: state.currentDisplayDate) From 07517ff1374c064d17d315c39f81b21efe669df2 Mon Sep 17 00:00:00 2001 From: enebin Date: Thu, 12 Mar 2026 13:33:39 +0900 Subject: [PATCH 22/97] =?UTF-8?q?feat:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=91=EB=8B=B5=20=EB=AA=A8=EB=8D=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(keywordStats,=20locationStats=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=93=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/DTO/InsightResponseDTO.swift | 78 ++++++++++++++++--- FoodDiary/Domain/Sources/Entity/Insight.swift | 70 ++++++++++++++--- .../InsightDiaryTimeStatsView.swift | 3 +- .../Components/InsightPhotoStatsView.swift | 2 +- 4 files changed, 127 insertions(+), 26 deletions(-) diff --git a/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift index c7354a97..7b3caf64 100644 --- a/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift +++ b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift @@ -13,6 +13,8 @@ struct InsightResponseDTO: Decodable { let topMenu: TopMenuDTO let diaryTimeStats: DiaryTimeStatsDTO let keywords: [String] + let keywordStats: [KeywordStatDTO] + let locationStats: [LocationStatDTO] enum CodingKeys: String, CodingKey { case month @@ -21,6 +23,8 @@ struct InsightResponseDTO: Decodable { case topMenu = "top_menu" case diaryTimeStats = "diary_time_stats" case keywords + case keywordStats = "keyword_stats" + case locationStats = "location_stats" } func toInsight() -> Insight { @@ -30,7 +34,9 @@ struct InsightResponseDTO: Decodable { categoryStats: categoryStats.toEntity(), topMenu: topMenu.toEntity(), diaryTimeStats: diaryTimeStats.toEntity(), - keywords: keywords + keywords: keywords, + keywordStats: keywordStats.map { $0.toEntity() }, + locationStats: locationStats.map { $0.toEntity() } ) } } @@ -38,7 +44,7 @@ struct InsightResponseDTO: Decodable { struct PhotoStatsDTO: Decodable { let currentMonthCount: Int let previousMonthCount: Int - let changeRate: Double + let changeRate: Int enum CodingKeys: String, CodingKey { case currentMonthCount = "current_month_count" @@ -58,16 +64,19 @@ struct PhotoStatsDTO: Decodable { struct CategoryStatsDTO: Decodable { let currentMonth: CategoryStatDTO let previousMonth: CategoryStatDTO + let currentMonthCounts: CategoryCountsDTO enum CodingKeys: String, CodingKey { case currentMonth = "current_month" case previousMonth = "previous_month" + case currentMonthCounts = "current_month_counts" } func toEntity() -> CategoryStats { CategoryStats( currentMonth: currentMonth.toEntity(), - previousMonth: previousMonth.toEntity() + previousMonth: previousMonth.toEntity(), + currentMonthCounts: currentMonthCounts.toEntity() ) } } @@ -86,6 +95,35 @@ struct CategoryStatDTO: Decodable { } } +struct CategoryCountsDTO: Decodable { + let chinese: Int + let etc: Int + let homeCooked: Int + let japanese: Int + let korean: Int + let western: Int + + enum CodingKeys: String, CodingKey { + case chinese + case etc + case homeCooked = "home_cooked" + case japanese + case korean + case western + } + + func toEntity() -> CategoryCounts { + CategoryCounts( + chinese: chinese, + etc: etc, + homeCooked: homeCooked, + japanese: japanese, + korean: korean, + western: western + ) + } +} + struct TopMenuDTO: Decodable { let name: String let count: Int @@ -96,27 +134,45 @@ struct TopMenuDTO: Decodable { } struct DiaryTimeStatsDTO: Decodable { - let mostActiveHour: Int - let distribution: [HourCountDTO] + let mostActiveTime: String + let distribution: [TimeCountDTO] enum CodingKeys: String, CodingKey { - case mostActiveHour = "most_active_hour" + case mostActiveTime = "most_active_time" case distribution } func toEntity() -> DiaryTimeStats { DiaryTimeStats( - mostActiveHour: mostActiveHour, + mostActiveTime: mostActiveTime, distribution: distribution.map { $0.toEntity() } ) } } -struct HourCountDTO: Decodable { - let hour: Int +struct TimeCountDTO: Decodable { + let time: String + let count: Int + + func toEntity() -> TimeCount { + TimeCount(time: time, count: count) + } +} + +struct KeywordStatDTO: Decodable { + let keyword: String + let count: Int + + func toEntity() -> KeywordStat { + KeywordStat(keyword: keyword, count: count) + } +} + +struct LocationStatDTO: Decodable { + let dong: String let count: Int - func toEntity() -> HourCount { - HourCount(hour: hour, count: count) + func toEntity() -> LocationStat { + LocationStat(dong: dong, count: count) } } diff --git a/FoodDiary/Domain/Sources/Entity/Insight.swift b/FoodDiary/Domain/Sources/Entity/Insight.swift index e621f399..a8c597e4 100644 --- a/FoodDiary/Domain/Sources/Entity/Insight.swift +++ b/FoodDiary/Domain/Sources/Entity/Insight.swift @@ -12,6 +12,8 @@ public struct Insight: Equatable, Sendable { public let topMenu: TopMenu public let diaryTimeStats: DiaryTimeStats public let keywords: [String] + public let keywordStats: [KeywordStat] + public let locationStats: [LocationStat] public init( month: String, @@ -19,7 +21,9 @@ public struct Insight: Equatable, Sendable { categoryStats: CategoryStats, topMenu: TopMenu, diaryTimeStats: DiaryTimeStats, - keywords: [String] + keywords: [String], + keywordStats: [KeywordStat], + locationStats: [LocationStat] ) { self.month = month self.photoStats = photoStats @@ -27,15 +31,17 @@ public struct Insight: Equatable, Sendable { self.topMenu = topMenu self.diaryTimeStats = diaryTimeStats self.keywords = keywords + self.keywordStats = keywordStats + self.locationStats = locationStats } } public struct PhotoStats: Equatable, Sendable { public let currentMonthCount: Int public let previousMonthCount: Int - public let changeRate: Double + public let changeRate: Int - public init(currentMonthCount: Int, previousMonthCount: Int, changeRate: Double) { + public init(currentMonthCount: Int, previousMonthCount: Int, changeRate: Int) { self.currentMonthCount = currentMonthCount self.previousMonthCount = previousMonthCount self.changeRate = changeRate @@ -45,10 +51,12 @@ public struct PhotoStats: Equatable, Sendable { public struct CategoryStats: Equatable, Sendable { public let currentMonth: CategoryStat public let previousMonth: CategoryStat + public let currentMonthCounts: CategoryCounts - public init(currentMonth: CategoryStat, previousMonth: CategoryStat) { + public init(currentMonth: CategoryStat, previousMonth: CategoryStat, currentMonthCounts: CategoryCounts) { self.currentMonth = currentMonth self.previousMonth = previousMonth + self.currentMonthCounts = currentMonthCounts } } @@ -62,6 +70,24 @@ public struct CategoryStat: Equatable, Sendable { } } +public struct CategoryCounts: Equatable, Sendable { + public let chinese: Int + public let etc: Int + public let homeCooked: Int + public let japanese: Int + public let korean: Int + public let western: Int + + public init(chinese: Int, etc: Int, homeCooked: Int, japanese: Int, korean: Int, western: Int) { + self.chinese = chinese + self.etc = etc + self.homeCooked = homeCooked + self.japanese = japanese + self.korean = korean + self.western = western + } +} + public struct TopMenu: Equatable, Sendable { public let name: String public let count: Int @@ -73,21 +99,41 @@ public struct TopMenu: Equatable, Sendable { } public struct DiaryTimeStats: Equatable, Sendable { - public let mostActiveHour: Int - public let distribution: [HourCount] + public let mostActiveTime: String + public let distribution: [TimeCount] - public init(mostActiveHour: Int, distribution: [HourCount]) { - self.mostActiveHour = mostActiveHour + public init(mostActiveTime: String, distribution: [TimeCount]) { + self.mostActiveTime = mostActiveTime self.distribution = distribution } } -public struct HourCount: Equatable, Sendable { - public let hour: Int +public struct TimeCount: Equatable, Sendable { + public let time: String + public let count: Int + + public init(time: String, count: Int) { + self.time = time + self.count = count + } +} + +public struct KeywordStat: Equatable, Sendable { + public let keyword: String + public let count: Int + + public init(keyword: String, count: Int) { + self.keyword = keyword + self.count = count + } +} + +public struct LocationStat: Equatable, Sendable { + public let dong: String public let count: Int - public init(hour: Int, count: Int) { - self.hour = hour + public init(dong: String, count: Int) { + self.dong = dong self.count = count } } diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift index cf774d21..1629d7d9 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift @@ -35,8 +35,7 @@ final class InsightDiaryTimeStatsView: UIView { layer.cornerRadius = 16 clipsToBounds = true - let hour = diaryTimeStats.mostActiveHour - let timeText = String(format: "%d:00", hour) + let timeText = diaryTimeStats.mostActiveTime let attributed = NSMutableAttributedString() attributed.append(Typography.hd15.styled("식사는 늘,\n", color: .white, lineSpacing: 6)) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift index 79915792..7f521397 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -78,7 +78,7 @@ final class InsightPhotoStatsView: UIView { prefixLabel.setText("지난 달 대비 기록된 사진이 ", style: .p10, color: .gray050) let valueLabel = UILabel() - valueLabel.setText("\(Int(abs(rate)))%", style: .hd16, color: .primary) + valueLabel.setText("\(abs(rate))%", style: .hd16, color: .primary) let suffixLabel = UILabel() suffixLabel.setText(rate >= 0 ? " 증가했어요." : " 감소했어요.", style: .p10, color: .gray050) From 2d3bb1b14274218a70e14f71b3d2d68fc8cd4882 Mon Sep 17 00:00:00 2001 From: enebin Date: Thu, 12 Mar 2026 14:25:59 +0900 Subject: [PATCH 23/97] =?UTF-8?q?feat:=20=EC=9C=84=EC=B9=98=20=EC=9D=B8?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=B2=84=EB=B8=94=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=95=9C=EA=B8=80=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BubbleChartView: CALayer 기반 버블 차트 컴포넌트 구현 - InsightLocationStatsView: 위치 통계 카드 뷰 추가 - InsightCategoryStatsView: FoodGenre.displayName으로 한글 매핑 - PhotoStatsDTO.changeRate: Double 타입으로 변경하여 디코딩 오류 수정 --- .../Data/Sources/DTO/InsightResponseDTO.swift | 4 +- .../Insight/Components/BubbleChartView.swift | 236 ++++++++++++++++++ .../Components/InsightCategoryStatsView.swift | 9 +- .../Components/InsightLocationStatsView.swift | 84 +++++++ .../Insight/InsightViewController.swift | 11 +- 5 files changed, 336 insertions(+), 8 deletions(-) create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift create mode 100644 FoodDiary/Presentation/Sources/Insight/Components/InsightLocationStatsView.swift diff --git a/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift index 7b3caf64..cec840d8 100644 --- a/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift +++ b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift @@ -44,7 +44,7 @@ struct InsightResponseDTO: Decodable { struct PhotoStatsDTO: Decodable { let currentMonthCount: Int let previousMonthCount: Int - let changeRate: Int + let changeRate: Double enum CodingKeys: String, CodingKey { case currentMonthCount = "current_month_count" @@ -56,7 +56,7 @@ struct PhotoStatsDTO: Decodable { PhotoStats( currentMonthCount: currentMonthCount, previousMonthCount: previousMonthCount, - changeRate: changeRate + changeRate: Int(changeRate) ) } } diff --git a/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift b/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift new file mode 100644 index 00000000..22f002fb --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift @@ -0,0 +1,236 @@ +// +// BubbleChartView.swift +// Presentation +// + +import DesignSystem +import UIKit + +final class BubbleChartView: UIView { + + struct BubbleData { + let label: String + let count: Int + let color: UIColor + } + + var data: [BubbleData] = [] + var animationDuration: CFTimeInterval = 0.6 + + private var didLayoutBubbles = false + private let padding: CGFloat = 4 + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0, !data.isEmpty, !didLayoutBubbles else { return } + didLayoutBubbles = true + drawBubbles() + } + + // MARK: - Drawing + + private func drawBubbles() { + layer.sublayers?.forEach { $0.removeFromSuperlayer() } + + let sortedData = data.sorted { $0.count > $1.count } + let topData = Array(sortedData.prefix(3)) + + guard let maxCount = topData.first?.count, maxCount > 0 else { return } + + let maxDiameter = min(bounds.width, bounds.height) * 0.65 + let minDiameter: CGFloat = 50 + + // 순위별 고정 비율: 1등 100%, 2등 65%, 3등 45% + let rankRatios: [CGFloat] = [1.0, 0.65, 0.45] + let diameters = topData.enumerated().map { index, _ -> CGFloat in + max(rankRatios[index] * maxDiameter, minDiameter) + } + + let centers = calculateNonOverlappingCenters(diameters: diameters) + + for (index, item) in topData.enumerated() { + let bubbleLayer = makeBubbleLayer( + center: centers[index], + diameter: diameters[index], + color: item.color, + label: item.label, + count: item.count + ) + layer.addSublayer(bubbleLayer) + + animateBubble(bubbleLayer, delay: Double(index) * 0.15) + } + } + + // MARK: - 겹치지 않는 배치 계산 + + private func calculateNonOverlappingCenters(diameters: [CGFloat]) -> [CGPoint] { + let radii = diameters.map { $0 / 2 } + + switch diameters.count { + case 1: + return [CGPoint(x: bounds.midX, y: bounds.midY)] + + case 2: + let totalWidth = diameters[0] + padding + diameters[1] + let startX = (bounds.width - totalWidth) / 2 + radii[0] + return [ + CGPoint(x: startX, y: bounds.midY), + CGPoint(x: startX + radii[0] + padding + radii[1], y: bounds.midY) + ] + + default: + // 1번(큼): 왼쪽, 2번(중간): 오른쪽 위, 3번(작음): 오른쪽 아래 + let r0 = radii[0] + let r1 = radii[1] + let r2 = radii[2] + + // 원점(0,0) 기준으로 배치 후 중앙 정렬 + let c0 = CGPoint.zero + + // 2번 버블: 1번의 오른쪽 위 + let dist01 = r0 + r1 + padding + let angle01: CGFloat = -.pi / 3 // 60도 위 + let c1 = CGPoint( + x: c0.x + dist01 * cos(angle01), + y: c0.y + dist01 * sin(angle01) + ) + + // 3번 버블: 1번의 오른쪽 + let dist02 = r0 + r2 + padding + let angle02: CGFloat = -.pi / 8 // 살짝 위 + var c2 = CGPoint( + x: c0.x + dist02 * cos(angle02), + y: c0.y + dist02 * sin(angle02) + ) + + // 2번과 겹치면 밀어냄 + let dist12 = hypot(c2.x - c1.x, c2.y - c1.y) + let minDist12 = r1 + r2 + padding + if dist12 < minDist12 { + let pushAngle = atan2(c2.y - c1.y, c2.x - c1.x) + c2 = CGPoint( + x: c1.x + minDist12 * cos(pushAngle), + y: c1.y + minDist12 * sin(pushAngle) + ) + } + + // 전체 바운딩 박스 계산 후 중앙 정렬 + var centers = [c0, c1, c2] + let allLeft = zip(centers, radii).map { $0.0.x - $0.1 }.min()! + let allRight = zip(centers, radii).map { $0.0.x + $0.1 }.max()! + let allTop = zip(centers, radii).map { $0.0.y - $0.1 }.min()! + let allBottom = zip(centers, radii).map { $0.0.y + $0.1 }.max()! + + let groupWidth = allRight - allLeft + let groupHeight = allBottom - allTop + let offsetX = (bounds.width - groupWidth) / 2 - allLeft + let offsetY = (bounds.height - groupHeight) / 2 - allTop + + for i in 0.. CALayer { + let container = CALayer() + container.frame = CGRect( + x: center.x - diameter / 2, + y: center.y - diameter / 2, + width: diameter, + height: diameter + ) + + // 원형 배경 + let circleLayer = CAShapeLayer() + let circlePath = UIBezierPath( + ovalIn: CGRect(x: 0, y: 0, width: diameter, height: diameter) + ) + circleLayer.path = circlePath.cgPath + circleLayer.fillColor = color.cgColor + container.addSublayer(circleLayer) + + // 라벨 텍스트 + let nameFontSize: CGFloat = diameter > 100 ? 16 : (diameter > 70 ? 13 : 11) + let countFontSize: CGFloat = diameter > 100 ? 12 : (diameter > 70 ? 10 : 9) + let nameFont = DesignSystemFontFamily.Pretendard.bold.font(size: nameFontSize) + let countFont = DesignSystemFontFamily.Pretendard.regular.font(size: countFontSize) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + paragraphStyle.lineSpacing = 2 + + let attributed = NSMutableAttributedString() + attributed.append(NSAttributedString( + string: label, + attributes: [.font: nameFont, .foregroundColor: UIColor.white, .paragraphStyle: paragraphStyle] + )) + attributed.append(NSAttributedString( + string: "\n(\(count)회)", + attributes: [.font: countFont, .foregroundColor: UIColor.white, .paragraphStyle: paragraphStyle] + )) + + let textSize = attributed.boundingRect( + with: CGSize(width: diameter - 8, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ).size + + let textLayer = CATextLayer() + textLayer.string = attributed + textLayer.isWrapped = true + textLayer.alignmentMode = .center + textLayer.contentsScale = UIScreen.main.scale + textLayer.frame = CGRect( + x: (diameter - textSize.width) / 2, + y: (diameter - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + container.addSublayer(textLayer) + + return container + } + + // MARK: - Animation + + private func animateBubble(_ bubbleLayer: CALayer, delay: TimeInterval) { + bubbleLayer.opacity = 0 + bubbleLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1) + + CATransaction.begin() + CATransaction.setDisableActions(true) + bubbleLayer.opacity = 1 + bubbleLayer.transform = CATransform3DIdentity + CATransaction.commit() + + let scaleAnim = CABasicAnimation(keyPath: "transform.scale") + scaleAnim.fromValue = 0.3 + scaleAnim.toValue = 1.0 + + let opacityAnim = CABasicAnimation(keyPath: "opacity") + opacityAnim.fromValue = 0 + opacityAnim.toValue = 1 + + let group = CAAnimationGroup() + group.animations = [scaleAnim, opacityAnim] + group.duration = animationDuration + group.beginTime = CACurrentMediaTime() + delay + group.timingFunction = CAMediaTimingFunction(name: .easeOut) + group.fillMode = .backwards + + bubbleLayer.add(group, forKey: "appear") + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift index 4388dadf..3bc2fb95 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift @@ -39,17 +39,20 @@ final class InsightCategoryStatsView: UIView { let previous = categoryStats.previousMonth let isSameCategory = current.topCategory == previous.topCategory + let currentName = FoodGenre(rawValue: current.topCategory)?.displayName ?? current.topCategory + let previousName = FoodGenre(rawValue: previous.topCategory)?.displayName ?? previous.topCategory + let descriptionText = isSameCategory ? "왕좌가 유지되었어요." : "왕좌가 바뀌었어요." descriptionLabel.setText(descriptionText, style: .hd16, color: .gray050) addSubview(descriptionLabel) let attributed = NSMutableAttributedString() if !isSameCategory { - attributed.append(Typography.hd16.styled(previous.topCategory, color: .blueGradientStart)) + attributed.append(Typography.hd16.styled(previousName, color: .blueGradientStart)) attributed.append(Typography.hd16.styled(" 대신 ", color: .gray050)) } - attributed.append(Typography.hd16.styled(current.topCategory, color: .primary)) - attributed.append(Typography.hd16.styled(" 이 1등이에요.", color: .gray050)) + attributed.append(Typography.hd16.styled(currentName, color: .primary)) + attributed.append(Typography.hd16.styled("이 1등이에요.", color: .gray050)) rankLabel.attributedText = attributed addSubview(rankLabel) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightLocationStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightLocationStatsView.swift new file mode 100644 index 00000000..3ce244e8 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightLocationStatsView.swift @@ -0,0 +1,84 @@ +// +// InsightLocationStatsView.swift +// Presentation +// + +import DesignSystem +import Domain +import SnapKit +import UIKit + +final class InsightLocationStatsView: UIView { + + // MARK: - Constants + + private enum Constants { + static let bubbleChartHeight: CGFloat = 250 + } + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let bubbleChartView = BubbleChartView() + + // MARK: - Init + + init(locationStats: [LocationStat]) { + super.init(frame: .zero) + setupUI(locationStats: locationStats) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(locationStats: [LocationStat]) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + let sorted = locationStats.sorted { $0.count > $1.count } + guard let topLocation = sorted.first else { return } + + let attributed = NSMutableAttributedString() + attributed.append(Typography.hd15.styled("이번 달 가장 많이 간 지역은\n", color: .gray050, lineSpacing: 6)) + attributed.append(Typography.hd15.styled("\(topLocation.count)회 ", color: .gray050)) + attributed.append(Typography.hd15.styled(topLocation.dong, color: .primary)) + titleLabel.attributedText = attributed + titleLabel.numberOfLines = 0 + addSubview(titleLabel) + + let colors: [UIColor] = [ + .primary, + .primary.withAlphaComponent(0.5), + .primary.withAlphaComponent(0.25) + ] + + bubbleChartView.data = sorted.prefix(3).enumerated().map { index, stat in + BubbleChartView.BubbleData( + label: stat.dong, + count: stat.count, + color: colors[index] + ) + } + addSubview(bubbleChartView) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + bubbleChartView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(Constants.bubbleChartHeight) + $0.bottom.equalToSuperview().inset(20) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift index d20790e3..8be6a1f9 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift @@ -195,15 +195,20 @@ public final class InsightViewController: UIViewControl descriptionLabel.isHidden = true scrollView.isHidden = false - let sections: [UIView] = [ + var sections: [UIView] = [ InsightHeaderView(), InsightPhotoStatsView(photoStats: insight.photoStats, month: insight.month), InsightCategoryStatsView(categoryStats: insight.categoryStats), InsightTopMenuView(topMenu: insight.topMenu), - InsightDiaryTimeStatsView(diaryTimeStats: insight.diaryTimeStats), - InsightKeywordsView(keywords: insight.keywords) + InsightDiaryTimeStatsView(diaryTimeStats: insight.diaryTimeStats) ] + if !insight.locationStats.isEmpty { + sections.append(InsightLocationStatsView(locationStats: insight.locationStats)) + } + + sections.append(InsightKeywordsView(keywords: insight.keywords)) + sections.forEach { contentStackView.addArrangedSubview($0) } } } From f2f0c442e977d7e2601fdfd2ce28c58bc913f48c Mon Sep 17 00:00:00 2001 From: enebin Date: Thu, 12 Mar 2026 14:27:23 +0900 Subject: [PATCH 24/97] =?UTF-8?q?fix:=20=EB=B2=84=EB=B8=94=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=ED=8F=B0=ED=8A=B8=20=ED=81=AC=EA=B8=B0=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=20(=EB=8F=99=EB=84=A4=2015pt,=20=ED=9A=9F=EC=88=98=20?= =?UTF-8?q?12pt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Insight/Components/BubbleChartView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift b/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift index 22f002fb..e0b5a3a5 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift @@ -163,7 +163,7 @@ final class BubbleChartView: UIView { container.addSublayer(circleLayer) // 라벨 텍스트 - let nameFontSize: CGFloat = diameter > 100 ? 16 : (diameter > 70 ? 13 : 11) + let nameFontSize: CGFloat = diameter > 100 ? 15 : (diameter > 70 ? 13 : 11) let countFontSize: CGFloat = diameter > 100 ? 12 : (diameter > 70 ? 10 : 9) let nameFont = DesignSystemFontFamily.Pretendard.bold.font(size: nameFontSize) let countFont = DesignSystemFontFamily.Pretendard.regular.font(size: countFontSize) From 252c989a9a9f893afc9000facaff6fe24771c608 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Thu, 12 Mar 2026 22:10:21 +0900 Subject: [PATCH 25/97] =?UTF-8?q?API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/DTO/InsightResponseDTO.swift | 52 ++++++++++++------- FoodDiary/Domain/Sources/Entity/Insight.swift | 47 ++++++++++------- 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift index cec840d8..0d5f9923 100644 --- a/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift +++ b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift @@ -10,21 +10,19 @@ struct InsightResponseDTO: Decodable { let month: String let photoStats: PhotoStatsDTO let categoryStats: CategoryStatsDTO - let topMenu: TopMenuDTO let diaryTimeStats: DiaryTimeStatsDTO - let keywords: [String] - let keywordStats: [KeywordStatDTO] let locationStats: [LocationStatDTO] + let tagStats: [KeywordStatDTO] + let weeklyStats: WeeklyStatsDTO 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 - case keywordStats = "keyword_stats" case locationStats = "location_stats" + case tagStats = "tag_stats" + case weeklyStats = "weekly_stats" } func toInsight() -> Insight { @@ -32,11 +30,10 @@ struct InsightResponseDTO: Decodable { month: month, photoStats: photoStats.toEntity(), categoryStats: categoryStats.toEntity(), - topMenu: topMenu.toEntity(), diaryTimeStats: diaryTimeStats.toEntity(), - keywords: keywords, - keywordStats: keywordStats.map { $0.toEntity() }, - locationStats: locationStats.map { $0.toEntity() } + locationStats: locationStats.map { $0.toEntity() }, + tagStats: tagStats.map { $0.toEntity() }, + weeklyStats: weeklyStats.toEntity() ) } } @@ -124,15 +121,6 @@ struct CategoryCountsDTO: Decodable { } } -struct TopMenuDTO: Decodable { - let name: String - let count: Int - - func toEntity() -> TopMenu { - TopMenu(name: name, count: count) - } -} - struct DiaryTimeStatsDTO: Decodable { let mostActiveTime: String let distribution: [TimeCountDTO] @@ -176,3 +164,29 @@ struct LocationStatDTO: Decodable { LocationStat(dong: dong, count: count) } } + +struct WeeklyStatsDTO: Decodable { + let mostActiveWeek: Int + let weeklyCounts: [WeekCountDTO] + + enum CodingKeys: String, CodingKey { + case mostActiveWeek = "most_active_week" + case weeklyCounts = "weekly_counts" + } + + func toEntity() -> WeeklyStats { + WeeklyStats( + mostActiveWeek: mostActiveWeek, + weeklyCounts: weeklyCounts.map { $0.toEntity() } + ) + } +} + +struct WeekCountDTO: Decodable { + let week: Int + let count: Int + + func toEntity() -> WeekCount { + WeekCount(week: week, count: count) + } +} diff --git a/FoodDiary/Domain/Sources/Entity/Insight.swift b/FoodDiary/Domain/Sources/Entity/Insight.swift index a8c597e4..2547061e 100644 --- a/FoodDiary/Domain/Sources/Entity/Insight.swift +++ b/FoodDiary/Domain/Sources/Entity/Insight.swift @@ -9,30 +9,27 @@ 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 let keywordStats: [KeywordStat] public let locationStats: [LocationStat] + public let tagStats: [KeywordStat] + public let weeklyStats: WeeklyStats public init( month: String, photoStats: PhotoStats, categoryStats: CategoryStats, - topMenu: TopMenu, diaryTimeStats: DiaryTimeStats, - keywords: [String], - keywordStats: [KeywordStat], - locationStats: [LocationStat] + locationStats: [LocationStat], + tagStats: [KeywordStat], + weeklyStats: WeeklyStats ) { self.month = month self.photoStats = photoStats self.categoryStats = categoryStats - self.topMenu = topMenu self.diaryTimeStats = diaryTimeStats - self.keywords = keywords - self.keywordStats = keywordStats self.locationStats = locationStats + self.tagStats = tagStats + self.weeklyStats = weeklyStats } } @@ -88,16 +85,6 @@ public struct CategoryCounts: Equatable, Sendable { } } -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 mostActiveTime: String public let distribution: [TimeCount] @@ -137,3 +124,23 @@ public struct LocationStat: Equatable, Sendable { self.count = count } } + +public struct WeeklyStats: Equatable, Sendable { + public let mostActiveWeek: Int + public let weeklyCounts: [WeekCount] + + public init(mostActiveWeek: Int, weeklyCounts: [WeekCount]) { + self.mostActiveWeek = mostActiveWeek + self.weeklyCounts = weeklyCounts + } +} + +public struct WeekCount: Equatable, Sendable { + public let week: Int + public let count: Int + + public init(week: Int, count: Int) { + self.week = week + self.count = count + } +} From 762f9f31b7a0a9917b1f01ecf427b0662a11403c Mon Sep 17 00:00:00 2001 From: enebin Date: Fri, 13 Mar 2026 00:20:02 +0900 Subject: [PATCH 26/97] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=9B=84=20Insight=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20Presentation?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Endpoints/InsightEndpoint.swift | 2 +- .../Components/InsightKeywordsView.swift | 7 ++++--- .../Insight/Components/InsightTopMenuView.swift | 17 +++++++++-------- .../Sources/Insight/InsightViewController.swift | 4 ++-- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift b/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift index 14ce444b..115f12f5 100644 --- a/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift +++ b/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift @@ -35,7 +35,7 @@ extension InsightEndpoint: Requestable { public var queryParameters: Encodable? { switch self { case .fetch: - nil + ["test_mode": true] } } diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift index 657a0fc7..9288344c 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift @@ -3,6 +3,7 @@ // Presentation // +import Domain import SnapKit import UIKit @@ -21,14 +22,14 @@ final class InsightKeywordsView: UIView { private let titleLabel = UILabel() private let tagsContainer = UIView() - private let keywords: [String] + private let keywords: [KeywordStat] private var tagViews: [UIView] = [] private var tagsContainerHeightConstraint: Constraint? private var lastTagsHeight: CGFloat = 0 // MARK: - Init - init(keywords: [String]) { + init(keywords: [KeywordStat]) { self.keywords = keywords super.init(frame: .zero) setupUI() @@ -52,7 +53,7 @@ final class InsightKeywordsView: UIView { addSubview(titleLabel) addSubview(tagsContainer) - tagViews = keywords.map { makeTagView(text: $0) } + tagViews = keywords.map { makeTagView(text: $0.keyword) } tagViews.forEach { tagsContainer.addSubview($0) } } diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift index f25a38c5..a59289cf 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift @@ -30,10 +30,10 @@ final class InsightTopMenuView: UIView { // MARK: - Init - init(topMenu: TopMenu) { + init(weeklyStats: WeeklyStats) { barView = GradientBarView(colors: [.primaryGradientStart, .primaryGradientEnd]) super.init(frame: .zero) - setupUI(topMenu: topMenu) + setupUI(weeklyStats: weeklyStats) setupConstraints() } @@ -44,20 +44,21 @@ final class InsightTopMenuView: UIView { // MARK: - Setup - private func setupUI(topMenu: TopMenu) { + private func setupUI(weeklyStats: WeeklyStats) { backgroundColor = .sd900 layer.cornerRadius = 16 clipsToBounds = true - setupTitle(name: topMenu.name) + setupTitle(week: weeklyStats.mostActiveWeek) setupSeparator() - setupChart(count: topMenu.count) + let totalCount = weeklyStats.weeklyCounts.map(\.count).reduce(0, +) + setupChart(count: totalCount) } - private func setupTitle(name: String) { + private func setupTitle(week: Int) { let attributed = NSMutableAttributedString() - attributed.append(Typography.hd15.styled("가장 자주 먹은\n음식은 ", color: .white, lineSpacing: 6)) - attributed.append(Typography.hd15.styled(name, color: .primary)) + attributed.append(Typography.hd15.styled("가장 활발하게\n기록한 주는 ", color: .white, lineSpacing: 6)) + attributed.append(Typography.hd15.styled("\(week)주차", color: .primary)) titleLabel.attributedText = attributed titleLabel.numberOfLines = 0 diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift index 8be6a1f9..e127df58 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift @@ -199,7 +199,7 @@ public final class InsightViewController: UIViewControl InsightHeaderView(), InsightPhotoStatsView(photoStats: insight.photoStats, month: insight.month), InsightCategoryStatsView(categoryStats: insight.categoryStats), - InsightTopMenuView(topMenu: insight.topMenu), + InsightTopMenuView(weeklyStats: insight.weeklyStats), InsightDiaryTimeStatsView(diaryTimeStats: insight.diaryTimeStats) ] @@ -207,7 +207,7 @@ public final class InsightViewController: UIViewControl sections.append(InsightLocationStatsView(locationStats: insight.locationStats)) } - sections.append(InsightKeywordsView(keywords: insight.keywords)) + sections.append(InsightKeywordsView(keywords: insight.tagStats)) sections.forEach { contentStackView.addArrangedSubview($0) } } From 625cfbccc1f574550676630fb2bd8b71223c1a03 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Fri, 13 Mar 2026 00:41:28 +0900 Subject: [PATCH 27/97] =?UTF-8?q?chore:=20=ED=96=89=EA=B0=84=20=EB=B0=8F?= =?UTF-8?q?=20=ED=8F=B0=ED=8A=B8=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Insight/Components/InsightKeywordsView.swift | 2 +- .../Sources/Insight/Components/InsightPhotoStatsView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift index 9288344c..8d98e9a6 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift @@ -48,7 +48,7 @@ final class InsightKeywordsView: UIView { layer.cornerRadius = 16 clipsToBounds = true - titleLabel.setText("나의 입맛과\n가장 잘 어울리는 키워드", style: .hd16, color: .gray050) + titleLabel.setText("나의 입맛과\n가장 잘 어울리는 키워드", style: .hd15, color: .gray050, lineSpacing: 6) titleLabel.numberOfLines = 2 addSubview(titleLabel) addSubview(tagsContainer) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift index 7f521397..94a62d4d 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -57,7 +57,7 @@ final class InsightPhotoStatsView: UIView { let descriptionText = photoStats.changeRate >= 0 ? "먹기 전에\n카메라부터 찾았네요." : "이번 달엔 음식에 더 집중하셨네요." - descriptionLabel.setText(descriptionText, style: .hd16, color: .gray050) + descriptionLabel.setText(descriptionText, style: .hd15, color: .gray050, lineSpacing: 6) descriptionLabel.numberOfLines = 2 setupChangeRateLabel(photoStats: photoStats) From 307e06a2a81ba684d23e1125cc486bc8e3341924 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Fri, 13 Mar 2026 00:41:37 +0900 Subject: [PATCH 28/97] =?UTF-8?q?chore:=20testMode=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift b/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift index 115f12f5..14ce444b 100644 --- a/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift +++ b/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift @@ -35,7 +35,7 @@ extension InsightEndpoint: Requestable { public var queryParameters: Encodable? { switch self { case .fetch: - ["test_mode": true] + nil } } From f797b5f3f339281196a79e412b58f9dd46e9565d Mon Sep 17 00:00:00 2001 From: enebin Date: Fri, 13 Mar 2026 00:48:23 +0900 Subject: [PATCH 29/97] =?UTF-8?q?feat:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A3=BC=EC=B0=A8=EB=B3=84=20=EB=A7=89=EB=8C=80=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=20=EC=A0=84=EC=B2=B4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/InsightTopMenuView.swift | 93 ++++++++++++------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift index a59289cf..8b6d9002 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift @@ -15,7 +15,8 @@ final class InsightTopMenuView: UIView { private enum Constants { static let maxBarHeight: CGFloat = 160 static let lineCount: Int = 6 - static let barWidth: CGFloat = 60 + static let barSpacing: CGFloat = 8 + static let weekLabelHeight: CGFloat = 20 } // MARK: - UI Components @@ -24,14 +25,11 @@ final class InsightTopMenuView: UIView { private let separatorView = UIView() private let chartContainerView = UIView() private let linesStackView = UIStackView() - private let barView: GradientBarView - private let countLabel = UILabel() - private let nameLabel = UILabel() + private let barsStackView = UIStackView() // MARK: - Init init(weeklyStats: WeeklyStats) { - barView = GradientBarView(colors: [.primaryGradientStart, .primaryGradientEnd]) super.init(frame: .zero) setupUI(weeklyStats: weeklyStats) setupConstraints() @@ -51,8 +49,7 @@ final class InsightTopMenuView: UIView { setupTitle(week: weeklyStats.mostActiveWeek) setupSeparator() - let totalCount = weeklyStats.weeklyCounts.map(\.count).reduce(0, +) - setupChart(count: totalCount) + setupChart(weeklyStats: weeklyStats) } private func setupTitle(week: Int) { @@ -70,7 +67,7 @@ final class InsightTopMenuView: UIView { addSubview(separatorView) } - private func setupChart(count: Int) { + private func setupChart(weeklyStats: WeeklyStats) { addSubview(chartContainerView) // Grid lines @@ -86,17 +83,61 @@ final class InsightTopMenuView: UIView { linesStackView.addArrangedSubview(line) } - // Bar - chartContainerView.addSubview(barView) + // Bars stack + barsStackView.axis = .horizontal + barsStackView.distribution = .fillEqually + barsStackView.spacing = Constants.barSpacing + barsStackView.alignment = .bottom + chartContainerView.addSubview(barsStackView) - countLabel.setText("\(count)회", style: .p12, color: .white) + guard !weeklyStats.weeklyCounts.isEmpty else { return } + + let maxCount = weeklyStats.weeklyCounts.map(\.count).max() ?? 1 + + for weekCount in weeklyStats.weeklyCounts { + let column = makeBarColumn(weekCount: weekCount, maxCount: maxCount) + barsStackView.addArrangedSubview(column) + } + } + + private func makeBarColumn(weekCount: WeekCount, maxCount: Int) -> UIView { + let column = UIView() + + let barView = GradientBarView(colors: [.primaryGradientStart, .primaryGradientEnd]) + column.addSubview(barView) + + let countLabel = UILabel() + countLabel.setText("\(weekCount.count)회", style: .p12, color: .white) countLabel.textAlignment = .center barView.addSubview(countLabel) - // Name label below bar - nameLabel.setText("총 기록", style: .p10, color: .gray200) - nameLabel.textAlignment = .center - addSubview(nameLabel) + let weekLabel = UILabel() + weekLabel.setText("\(weekCount.week)주차", style: .p10, color: .gray200) + weekLabel.textAlignment = .center + column.addSubview(weekLabel) + + let ratio = weekCount.count > 0 + ? CGFloat(weekCount.count) / CGFloat(maxCount) + : 0 + let barHeight = max(Constants.maxBarHeight * ratio, weekCount.count > 0 ? 24 : 0) + + barView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(weekLabel.snp.top).offset(-8) + $0.height.equalTo(barHeight) + } + + countLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(6) + $0.centerX.equalToSuperview() + } + + weekLabel.snp.makeConstraints { + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(Constants.weekLabelHeight) + } + + return column } private func setupConstraints() { @@ -114,28 +155,18 @@ final class InsightTopMenuView: UIView { chartContainerView.snp.makeConstraints { $0.top.equalTo(separatorView.snp.bottom).offset(20) $0.leading.trailing.equalToSuperview().inset(20) - $0.height.equalTo(Constants.maxBarHeight) + $0.height.equalTo(Constants.maxBarHeight + Constants.weekLabelHeight + 8) + $0.bottom.equalToSuperview().inset(20) } linesStackView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - barView.snp.makeConstraints { - $0.centerX.bottom.equalToSuperview() - $0.width.equalTo(Constants.barWidth) + $0.top.leading.trailing.equalToSuperview() $0.height.equalTo(Constants.maxBarHeight) } - countLabel.snp.makeConstraints { - $0.top.equalToSuperview().inset(6) - $0.centerX.equalToSuperview() - } - - nameLabel.snp.makeConstraints { - $0.top.equalTo(chartContainerView.snp.bottom).offset(8) - $0.centerX.equalTo(barView) - $0.bottom.equalToSuperview().inset(20) + barsStackView.snp.makeConstraints { + $0.leading.trailing.bottom.equalToSuperview() + $0.top.equalToSuperview() } } } From badfb738d691e0618eb4b7bed063e58ef7cf5034 Mon Sep 17 00:00:00 2001 From: enebin Date: Fri, 13 Mar 2026 00:52:18 +0900 Subject: [PATCH 30/97] =?UTF-8?q?chore:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A3=BC=EC=B0=A8=20=ED=86=B5=EA=B3=84=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=ED=8B=80=20=EC=9B=8C=EB=94=A9=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/InsightTopMenuView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift index 8b6d9002..caae2ed1 100644 --- a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift @@ -54,7 +54,7 @@ final class InsightTopMenuView: UIView { private func setupTitle(week: Int) { let attributed = NSMutableAttributedString() - attributed.append(Typography.hd15.styled("가장 활발하게\n기록한 주는 ", color: .white, lineSpacing: 6)) + attributed.append(Typography.hd15.styled("이번달 가장 자주먹은\n주차는 ", color: .white, lineSpacing: 6)) attributed.append(Typography.hd15.styled("\(week)주차", color: .primary)) titleLabel.attributedText = attributed From cc7f285f19ff897580b18d22dc5b0515bbb7aa41 Mon Sep 17 00:00:00 2001 From: enebin Date: Sat, 21 Mar 2026 09:40:52 +0900 Subject: [PATCH 31/97] =?UTF-8?q?feat:=20readme=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/appicon.png | Bin 0 -> 43964 bytes Assets/logo.png | Bin 0 -> 18011 bytes README.md | 64 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 Assets/appicon.png create mode 100644 Assets/logo.png diff --git a/Assets/appicon.png b/Assets/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..be18e1fc3427055422cc5c501821b53077e9d6e0 GIT binary patch literal 43964 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%0D#hA<&+FZ0B-58wLA_^wB5M`IMR@F;pfO*K z5Pn4>^Q}|Opf~2s>SGCCO>D9Ig~~J8iL$$4pfBld8ggoLN}Zm;j`Pr^U_Vb_-1nSb zJUfiXU)~#z_|_?SS6*@bC=R2*OP^ZHdkb^<(gn>+{AEF7^*8$0Zd0G>?afFa zlIjCI53hfqljJ*mB=mq_MH~Q}oZxpa*pv%K+rwx4T(Fbt?ye$ifYW4QYDTp@F=)Q~ z&Ne&c7Y;WE2u=zANgqbuaZYu{&l3+`VPr6;nHO!Us@ZavAx2=vHn1O_9LI-GY`TR` zQu0ATc}$;t%jg;41v(4SsfW}LPoEW*SLx@ag>D+y?x(yZ(Qja%=Z-w7ux%|-ScjvMMz^hVDP@l*9W{<`>snPfx1(|Ee`~!xuBvZLgh*%L5jq01 z!$b9a9c$^2XP=wS$WW3^@wR-$L05!ntuSxZ8$*1tYi?nAwO=^MsBhgkdFPbF7g7+A z#PB7}o!_mdd67>VY{basM5S6Qdt0MhSy&_uZS&FXi)(Nng60KOZ^%7y!uK_F2@E%> zp74dP6*Bh2x^Ux;wK#l$JukoFSN8C!Z)9{!8lyF z*26A|hX30yWH?cf^){r&S=ZUG&$Dlq2DwZ~HZOr?F)PyN{EqMoS|;0(jR>?obq~x5xIRzUS7nT!nkg6Im-3 z;Vn`?)v}ZG0qw9!o~+s3Nh3#qa3cFWa#qo`0}pg;TfXhb2D@;ky8!M*=!!FDl+ARQ z#5~D0Cbb>0{SC}|P9^IUrb|@FdEgva#MtI?(CyRt<*_*iwwIv)n$twZKh( z-=U}Pj6jMyp7P_^I#;gI^W-OJzfO7NuL4&ispeaQZGr=5-Np|}T^YwY0JKXH1f*Pq zZ`<<#Z0SO$_r|ctfrv9$GJl1fY7YRV+&*~wzM22{we1PN4rWBb*>b>y6n!R8v&-?v z8tK9mMn{Ky?83;|md!3=&8Q#b%Z)X8x^NI9wlBLaSL&l_PGF5Sr?vhB{!4T8+fp?1cMY zLR>=?a7{jL2>_txCy zhj@Ix*H))h8G@r3kEU&KIM3)jTHA8l$!=4ZuH?6TjP8moNrJyb^SWPwZpzbDaS%%i3us# zrm~ou81GJ#&CotQ^e9oJ*H%sKb7p_OB?PO#u*m#t@6>ZPWzCm)rN%=|pYTr8RH}|6 zaliySfuRPfQgMX^^;X%L7f{Bn1QIWvjRSuAa;M&<^QSZv!=-^i^ znJ|2Em%lyrG~p+TQeS)btS2|{r+mvuE08--o z9|J>Mtk!W0>h!!wKJ<8COX>D{qf9&aAtbbLNr4|0pAXtGZ7M^^*k*5TX$b&8cj_$} zfq75r6L+E@#MCWlOi~lL3Rl7#5kNqHy*U9dsvMb*9%MV6gvYj&E8WzJ)5agc&%#H4 z8qAbt+HDS9YXuvj(61wUnTMbu$b;dU!Pw8o$k9Mr)yB{44}}2GjeBJ!Mcj$E9Vp|K zgV(95wfIX_w++cecsMM^lPneY=1-OUGAs} zKfX34UW(Nuv`YC5wB6jgkXGTBaO55$pj0#PjJfGT>`L~Z5Chv>TeNOH5TIapi9w8S zwlj_KENpm%IYZg-+7{IGNej&9Djc1bmg^Z{rvA6@7|?ghVph=dVzvQ=L#+pdxt($~ zY|CHg^332_7C-&#$J+V#ccl&l-&7d=lKVwuKEo~2%aDSGV?dS;i@WILtqR*3WhTQr zH0gKg5<4A%$o5xHFES@W=41)kW}Mo%A}lZNml1^szk?(mmbvKM#fI(;F6D`S zTKCAJ{?T&VZS{Z@Nr~@%H4#{xlqz|t8a1;ownyycXFi5Gt2X zXcQ$GD|bZc?RI~9hCBh<^6_Kf{dtj>+XdKxw??0-`^<; z89LOeb9`At%?i3sskf6;UYgJ4i*XwW`xToP6j6}H$idIagj!|T!M=!^hY$sqps3ag zqch)+ZW*&tJ^ArD@vzLAcy@ijfk=os0OwBTyh!StVo7a$@V#Il+d(vp?Xuc;#qS+va?-OLL~7 z)Na6{y!fd7^deU)AFxMh%uw-eHAhRNm2bn3GrP7)G;hw${j!!xJ|V*^rJR=ZpgZQ7 z(MBM6I!rA!Ua%5c;R}>6z)?BPG%X1B*4}nWU*(T409(K#_y>xbl#29}&^{3A*^Omn zhbH^{;)%dj*3o_EDJbXmgMu^Cg^A>@38iRE$dUeHXPl-9g#u$oe|TJT$upK=&}eXahNeor*0 zsU0a)-UxV=F4VMbb}g&TtJ<$aq0p~LP@D7WMqb}qwW{m>Fg&04lu&;JA9>et0k<6d zOo9M1$8Et~-xvOQw#jP_eBK;9_FEVH@B2NGICDTs1o@9(93EIRm$i3W3n$zI6kURf z%0s8qr1snnvzzAmAO5s#ea}f6xTyhE0T2Y#T-Ck!xGPhQE}O18G-7&GI+Lt1?Fu*#V4LY_Hgw;* zX!er3w6f+!9P*_?cmz#*#-7xuIvuK?kW@kX(Wd+J?T~i%+c#XF^hTV-_stz_$IAxM zvbR(+AOd=9wpo~I+VbGSq3%}+xW@p5XWqSAz0sQ}^;3}zk$_wNIb4AwaImHtl|t6R zdJbS2+owvCNUvX2R5!tOxO+A}YoT$no9)^2JM6}*>{ET&vJ@Zdu7$TC5Z%b77NT7N zJ`R}-6BLL%nW&=c4=ZYzz2kZ-j(R(;UI`k}=%&V;6Fo+RvuyMxWi`JA7fnEOy3mynEji zH6ICl?_O_v$nBOGA_2$BbLfke2K%lvVZWLhM}A)VLbxpdI}8+VQep!PwDD-i1T_oZPUaJE(&SW?(p2AHJPKn zit5!*Etu+eKec2isTeVNn0HQn`XrO}4QFcyG*!VjD(Ys9?K^RL5G=Zwr3a3b_I?7n*oltE zo`7C^TeXI9I%nWoxVdPNbTwzp`*s;NVE%SJA zGx~$gcojj$;3oJB=e&=K7Qlfq7YhNV_g{yX8&Gun(>-#=!b3;15BgoJo$$@s8y&+C zht0d{ty9(LBk{_PLuObdZb4VjfjJc-768b7h-S{{?8bMQ{B(&xvteH4a)Zn$JB6%t z)AXV)W~}~I7|n?R>Na}RX|fs;pUphv^s9F5ONF~r7s&{m%h*?(BoYQ?h)ltPg?H2Q zTYhY#SKxrAb%Nm4!jJoA=v3myZk#{NY;`ngshPZ5LR7XDo{?(4MHcptY!V5Zu<-00 zmySb2H#xFM%aKilRQKF5xT|jZFJX(A`9*+7n3%HLPX}noSrKj@?qM@6)_ScSe4)I8 z$r!CzyNBT(Q624>O6CSpcCR=^g7E0k0#T|*$;u<>n= z)t>-IK;U&5x0lkeea|}wl{NJ?Z=r97E%db@n*}jxch9f(ZZBKAG!A8+g!A8c%f{NC z>b{faDG3<2l{uN0$bYz7V{Bb?!OgHL|ByALI6A{Q@WW6JGb}c~g#8an^%Qtc57jV@ zd2D7JN=rrP>+v1C9gRQqJ;&ug3uNru9n!tw^TC2aeBWlB422mHy}LXHaAP1{xkzL~ zA?e;2UwXQbTB#7d0>8y_L{+^w|$x>G?QV5a| zXA<|qoN$^kh?wG&-aktKhOwa=-k)l|$ds=3Z>L2?GEc*0B5fo0sv4m7IK@%)vvv>d zA$L0${SR7|m}ErC`DIa@;VHA0=e9HDL=od??VCD<_fBu`K2obgp&@~quk+@5s-KET zrixqkhLE4x;X@P33}1~b1Nk8Ug>>y9k%JzLyY4Y!O$)B`GNQzj9SMhHsfX@~Q$9U! z+9FL)@iGh(%q4dexdeZ^d$YX1_oh21Hv8s!CFDK^G!fcmef#meGX{Z_0~RxCsDG@IFz%74R?X z=wmEsyxV6DJf3DvL)6ji^KXP9>bEu1-xSD^z`-a_Xo<;dj=Z5uLOqgo%Xg^*h!^Os z?ChG9?&Sh$>sup|DO=<|MpnTQCnZv>wEXfxsV#$^!1j{Y5xKke*lfhFcE3AiSQNO9C1I;~wbS;K8*v{j~v%fJ*Su3U_ZNUz7Gaug<|R6|fdkq4kM5A@Rt z(xpd}JJ*)-IT;)qN!uqx&3iO8et&H#U_)i>#xAJ54KXW#XMo+X!=`&&Clr2_TOA_M zjD}>xa$3v7#bs~LxPv|qUS#wkDqQE;zlAj&O_gpoH7{@pQj{);DRugizL#Hr`hs?~ z@2>^XDz$R;{*S9xq69woqzmEcXA-WS24k_6L<2Z$k{<5h;-()!Wj&W3#_msQ zHg!q)`i+)UWy*;Z?PTBrG<=dCY_Zs!>=ZN>?I2je^Iw2s6EVq-v}#q|p;N(=_vgov zJ*2qnpcm0u;?h793i^eT_zmb#_7EfaAcewtPr`!>1*Sf3NSA)CT!~P*sn)*T)YK=U z(kWV&=Nyf38~Gxon7+?zjsI)X@*3kYumri#JY7KPa=l|DAM;$OBL%4QeFf-u^{cXE z3xl)C#+TggbvY8X)P#%l(|tX5ZTNIlW-8uOs%}5^>Y>@Ip3*-xS6wY|Aptp_F72C` z9=2JIz8S8QuZ0CHAbgpWFk3$qp5Sc&wADX>nduWyOW#?h;t;#r&p4r9eqCvkiDGDb z&2|TV$f_b)$!rK17RwJIsysMz?rHgSpr+VT?>L#~lvb}Xkc_0GBUjRrK5!}mVZwni2(5UBXh0b&H5a?<}lAg8@JS@{3@7P`c z>ZdI1VcVqNHHAI!?R_}4HT@_jUVi9?M&qpv-W45D)BBNi`kjZSqY<6KmT=v*@g>)* z4{IcRf71SW`>px+V+Nb#aJwu>`_{D>cSD{Mbcd>=V|e@72a?G-V9W2so+~5xlZaQC zWkY6cChkBaYYjfi6UJ2qyd2UzV*%pYa)J+detef6S4r;vF3+)|$ZYnwb1$hQHoQ;X zhRqk~nYdt*E@(~Med@*(G9f>~T|zOw0f%vVnQQ59-W)RlSVT(OgPp%)Rc-)2j9EEc zRT*VNyYtvy-|3!YTt4W?;-fVXmhjQS1P9Jp>3~lQ2o~YF?-$IIY|Jdzk;; zhg@su`MAFSAS1v)j@HmploK$==`!JzYbt-+FzezL`ehhi%XK0}Tm$AkkIo2Oee>pr zNXczE2-HFA#iN2L&Lln}d)2{l{tp&cKhu$3Chn8Be4xbU?h?V98+%>tci3e9E2?N8 z*@87A8US@DX=sc~1iXcP+fdAk>Do7fXb2F*;Y0)Y-}&cJ-kJ9rzc}c1=p2iZdQqaF zVM5OdjB~EX?{wuWHaDa5LYU>gpiE`_Evbn6xJHJb^Puk(IXZKgJERaIlnDc`XJR& zEDsEKpx>erT;MMwC9Ft4(R0nEqfPd=#LX8NIr0mW%Q|&)J7AZEnOJ^Gau-yQcei@5 zjTYAHvB;SXltsWj>HOrDTkEP62!|5}%d`0AJYd1tdK#QYS5YY;w2~-4*>{~PqIxTN z82`Y4#0g#6aCUbb07D|bW9rzVr#`qU?Lq09;d6dIpxNshR;p3zI6FVX-@Kjv2c*h9 zU_;t&o;tm-I%G(ve{s%ej5)+2^TN)sIY%w#hY3MrvOaDj+)@^^z5!2F+3n^Wd5+1K z5FoiY6NzM}Lbb+ube~6`)AAS(pQv4K% zdu8I5P^IR`Ja}dQQ-7jdGufT84pkipWOqPa1^U6uvf;AZn7R_Vk>Ei4yT1G;$w=4(AfGATL*J+z3pR=AY6bPKyv)znV6 z@mhAtQxQ8QFY9cA{sc!4{-P3w$tEI zhBE^CV|WGc3XrW?-JYAiC@MJ(vGA{*OrQN`y#0F~-i-7IyNO%Q7x^7KrMywLyb=8} zb0>Xj()N{B3teCf{RK?z#1z_a+FnCB&aWB<#a+qRw&`) z65ukCK|o*~$l}g57b`BZGm?uCm9J{kO(%k&Sq+Ne0qp;L0f2)#Ys|ns=8Ck!_MLjB zUi}??Lx)E{DI)Xc)wwU*Z}!Q=LC69*+zsgLvj}zdn6wOaj%ri1;!I^gRP0=Cy#}`t zZa57-&8Lka?_Al=vE&h^Pu}n^qeaEKC!jpt6iz6R?!nT9LHYfJRaJNdEhxu-Io+=~ z5ne~|H(C5{9B7oAj&%h@b+q8_q4oK_1wtSv`V-Mv+y3jc{#g2A4pAV4{d&jW0@on# z_iA`Uj{}exfi7+JzAmj?!Du(tf~*FcW|RqwRc=J7Bb`Q-OtYVpoWvT%DQl5r_S`0e>dIV~!VMad9ARa7MVR*BUK7iFde3|YpXzIZ9`E8rk+{%_y2qSK zH!yevuQfWjaTGckIJnD{Bt+lTO}**h7$TJ%g!^rjAM?WT=P5HuqWM_&RhhUV82iVk zWc(31_S6$S7o2&Ov$0%@l*i}9DdXB4vzck%&XNCs1KA~k{iMMn)J%2*gHGRf;Nq#^BFk6c>G5l#Ms+5^@cV&HOkcs;W#Yl#)z-i5S~Hrsb3aI-Y?x#Pjt zCzaXvqjq(kXhwqwcVq1}h+RUXGWQ`x9Yd|lRx6a;uinT<+(MrNF~&u8 zgop;Vq}a&0-s;J6Ey_I!No92X+wVdh-A*G1xyrgGxwPpBEG!PeU2;SN2a;ms$F7FU z`UYO;d?{aG^j`?ejcV!o{O(D~0Ybw`pta+`NS(VbOpsJ0>S9XSj6>s2>!YIbuUaypkCR53k??l3^E)bg}8ro&~^o$-q|*m;2k$h?7Uc#5ou_<{qiO)eQqI}9&{H(B9LR~1?hy7%5(Uhot&oiz4k&3p$vhdtXOv)E zudJLI{(9mJSp~kVUy;2FC5IBgpOcUe)>YVp4%6)&B<~-A-{=un%SK&SPCxSnt|lJY zCA1qmJr9yAyb=8n;_Bvb!1h0Bd-w^j647qvLd%T~&HYgNZ!1Tu~3Q)*vhO5eW%x~1g)N@LpzDRcC~vpY*p01@J-W6QT^C(0umSGYpZ z0mw`vr(WX6f1Pq6Z?i-h_;Jx-U1dGgrF2MUkY|Rk9oMkwgvBEFawDQRk@#a^C0FHk zuv{=)Z9&h@l1mU@J6b{73E9pmyeT?e)9ht$UBlhB1l4VmG)F0V_AXm50_0InWurTP zOu3nTNU6-Fy^^2^KF~165yGyJWMUpx5^3y&0B8@-06EaX! z$qg?8O#r9nvJKS~RDfoQbQDAgVw3@Dy$g|EfU zuaZMEVeFm{VGRxONBu;4gZ7Eq9aO zgw%5Ym$SwLZV6a>PDqOsb1W=h_#De6LW?bl-^_dCz(HBKaN6&SF+MYhZRauU9sL z_`hg;-^d(T<@UNW3w^%zyp|)tqYZq((D6`OwmvwkrpN>Sv^{!`={Q0wEb*zBtOZcI z_RA$s`rOS5^gnLMI_ zs}xa}7BVuO^|`NJ0Cil!sLBHC|D0>pQ0&1sXBIftyb-qKl7NgN00$Ix6XL#Y*#kyb z4zb=kehG411=4KqtJA4ke*x?N!|Lx`0m%|C_A+SyH{Qhc<1t# z-z}tM>trs6{c??-Lo9ARzxt2)L!g||-57u>z!4Q{+m~dx+A|GB0N{|XX4ui>ou_t z*sW0`x|q<7eiK;h;GA&!B)WR_`0bsw+Usl<`aPHu5!XWy9xPMLJO-5LcRn|1L_o1h zdAeEXxv7lVVnQLp3TYzG0(xl*o-4Yw{JWJtU3!DqTI^)~eU&vEJkY8Z>TQ{|BgtUApMOnYBb!hj`2N5v(q~;=W zURlNDy3)z-NjArvIS$8=bz!=Gr*$hsAB|w`^I_X-`yeY4EWDkh*a>Fa(S?BfB@=!E zFWpa(m5?=3=IEi-Bul{#QhKo)$6&zhc!j*=K^rJc0+Py%4yFdKU@uxj^b4n5cW$L`B#%Id^gOc*3UHcWUs1 zWhNEcWJ0dOc`RX>jA)D+bo86+>aijZI})EQhqRiC#Cl8pd3h?&$F!OO zc54WtT1xlkfa`0-L}Tt7yaRCI9#}~?j?H_D%v+8pu+e8=YQC*K20Z=BStu9j>e6AN zvP9BL*zWX5`HAE8ox_LwM)s@jHhQ3m72>*$LU$kSNCd?4ea^aZ+dx9n`{xO8CRPC5 zbL$grHAthGcw^5DrlO`F`!M&V(w(lZQkRfL|oJ^lEcldnYaCgLL9FcVwEv@NRqJ z+=eDeBz~!U45NT`BsP2F2>TbkxZp1D)cmQzz@J?n(b`l_M z*9>gEaS(d1jH(!V*||EU!B1^k{@q?5o`RZ{z{UaQ5+1yG&`lupaO~Oi}OU=4| zSg2hkiVq{Ank$5CB}85%zk&5)v%2;p_<3|lu{Y}3JWDwSb?j1LC?z0!pZy(w^Z>7Z z#9e6j>}PqBA95VRd0Mp=?#ZCZ_S7r*OF7Q1?$A1Z%>mkYAqDFl5>gsw%i>-X-#-sT zZKT!66%>iIusm)%B1eW)#VM{jhg9qA@v&01FZP% zc@Yv7a=`#|i?_w1d7iBCM-9YFq$Os~8Qv;B-_nvc&!ttdTf@QEeBKcQOAk0II2JAR zV>ztO;qRv~bobs@x(ihT1TX$k$SS^scv>M#1a_y1((uZtlMLGXZ_yY!#0r%&;VkQU zuS+``bLEDD2}Dl6g#tY+BW3Z>&k2bsqA~E0%r@*02nxQTu;N7i^xf{cp@VO_6D~zK zcpDKQh0yuO9h`i;3zQX<{ zBpG^H=Iu`lThAxi>kANJD0tv|_ESvsz!OU66mThALz&wP4>~u1`-1AEBdI96Mjmr% zm8}mbGm!%eIkPx0YgaA6HIy!i%Uf=+PY}0*w3R;9iKbe#(==5iW4XYAMXen*O0Pb5ji1sXrCiTkDF1sF0vWjldQQg5` zrURel6h-52K-GUyP$UPs`WkiT<-+pIF0HhtfaZeFXRcqE37fS$deyHhfTs0P38iv#x?0u_(72HIJDO8JB6l`o+fx1 zuUiQ6%lFZ2l<~XBecO>_`ZbDku0D7HT$|=){jv6R#sU*;cZq!1NoP-z(E`-N8Nj*Pq;5ez~zN1 z-j_$#l2KRNu=k2JKLR%<3Yu69C60l;-wlm}|JEuB>`=0}{hDz*m3G3Xk$W%iYD6Hi zl=8piZ)6e`9?E(=O*tE=$_L`oXi-#@a%w03hAxc}0E#vwoeo!HxGDMWp?zP*yD61> zw-cjIdko9%as(NNeUc465;50A)%P{-<{4Vqcu?LdSq1HB@ESAfsK#MoJa0`HKhWem z5b7Hr{RQ0>APZ}-rXsK48QM;(%lzo=e2y)vjSDiA5AL8bVqtIry^EqBKA~Z^vbefC zSVdm@VfE*E+Vm-r6LVunRlUEoXA~u{Z;+>eF$sY8*|+cN@59i_!qGtG766o{d=n^v zC>G%$kNn~5K_xSXzN7%zhV%D5keE&fYXWE?IN&f8mD9oX2PvRAu*K&dXbSKxX^Oy+ zC*hF5Tq+ZGr2yEA-md{c5YQD@?Pk2aetoB(S7F>n90Ozp; z9YS^EIHZ!+HeDIH+sv5O_hp;Ze&;N5Rd;`wwpOinc>b#$jllIwge6o7syOJ~&j)M7 z8X^NSzSbkP0pG4khv%=^HLlB9_j*D0L_HTXsQU>A?K)3_FdSv~>(LnNV4y;Jg3$E4 z5DNnCyK7NV7QD#5>kr2AnA{DH;I;RCp#7m!WqR^MV~D1%8AXRd1?&oh0aBDi3Q%t{ zXtD(%GRo38scYxxwl&&tXXcrISvq#VLMrPa&wBbi4j610>1adaK^M`60=OgDTkgo0 z66dbAEDI1HciM-vWX)SJqLoU=jJ#;4buxjo7miopJo%mY4Kzawcb$l)KL&Pxq_5%I zx|EP8Kr4==pJQkKydFOGqi4rJ9Ezp|UkJy@O$y7= zcrFv+DCY+Xvs`c27Y4r;)t~Ku6#l`@@yeWW<~nRLyn+2MR~+*hCIq& zm5%KCq=S+jz&RS#(hY`?d;qFI z`JtefJaB{81KSF>b7hIkaH@py7pALkteW;leD|xzazkQ;pd+cbe^x}2jR)OMGK;OK zU3K@u-O3}cqc5R~Jwj+6On^h#JPUx8Mv6Pl_?-1vCF-0F!h!5w@v?+eVmjsKPCfVL z45!kTXtmBVO)wVMkqH2R9Rw?se8pI-#`Xr-s za?(Fl_*0a+4n1=1Gk6|FK7ojflPRc~dw16hPKj7Vh+u3-=t3P1D%kVa$-Hbg1uk3h z+J_Tw#m~!D)%h)?rMjhj3VDsd^iA*;g03v#kruOZHkgs5ucurvKAN8Q*zc#k9I@a@ z!1RIkqjh_^@mIiVr*`G5C_XNGqm=MNKIf;#sy$$e(Tg~mwQUihgk~H9|Hr<4YvUKx zPm0Jx<|T3-Z{*V>nz4__Rs5ZGxU$F_lLq3_qZWjyREf!#JoD<=eXV!2z59xbixbmq ziz2cT!D+FBoQR&-U?v0-&oBC@_tTRIPi|;SSE_Ku?jsx1m|tp{wWj%dM75uMRCkFv z0?$Z$6OHdrl6WrUl= z5L9+||G!4u?rM36hUqcKitWz1fM0|xgxmvNbpbU9uJ`&MejVzaLd2oK8w$#w4#L#H zq@!w$=BJ;5sSAgK>6fr${VhOWYRG@kP)pB?i@!)hX5eUTC}4*IsnJ33o&2vL4w-=^BLoEpeh$Ks z@_z+B$O$NrgM#d-LBLl3ufQCc5hGK!LcyoG!QXEjzXwqnf#j1El*L*KJGAJTT#^d% zW5BRGpl#rXSmuMwL{30~JK6@jD$9J3yO9nU_F%M)mBp+DwFs=S%w_mZYk~QXq~MFj&wk-2n{G0-cZb1P>Sq<0v#wobGfm!k%)A_ zamEM=6jh5^3jz`JoMQzArdw56v;?p<9T3KjKGk?@6|)woA$y>}4GLm+sIsUJSpew( z$F(OE*K*6v{F>8SzG6AEDo*7Ywp}+lLY(yqt z5SKs~tOb3@1dNpc{KCavl~ofL4S(jJJZLOc?L#J@=Q8k%bc!mgii#r>&_VRUv1WfU zYrz~c0sZ)s*Nmkq)U81NgI-`&>QJNv`k{~Z1K4?3exZq!KtGnC2do8yND1^~30lCa z?Rf~AAH)@^3J)u0E$~8M2mXT*U|9xe`bp@=QM4bB4U47m1q2e-W(Gn5n%at`ARKXl zeo&!c;ruVwf)5B}=B2>(nTCx!&aw=^q=ISu6NS%OFpRjss$B}RXIUQ%ZzLVo;Ga}a zER6%z6SP5sHqi7rN zo?1)65UA1cfoxZ(OCJi*97tfImwr;{z;FYi2{iK5(h(&d`BMbEXOSr3#qVTBe~cCU z3bGbL6MrJZ|9lSTSuqI8Agll{jIwgsFky~4_;&ZDwQR&?va|(#5u;#|%qJV4Bli}$ZT|FU7XzdGXgE6%FZq|B2DX=TG%V%ud&dHf z1{MthiVW;B2C=IB18>~#$cClPSRu=4a=|0jvZO`sqCZ`Hj9Y*TNzau0pJ``<)ZgW%6bJ_X_= z(Id;B;bY{1;BDQV1%w8cC5)l(1;Kr=E>s<|A*(3HoPwB}YeyG2k&XXU?gW*)(3j2N zC6^*ie(x7mP^h^@Hbd=lE&bN z-X%zPy^q)MsD{Z*e9*U~gbic??|_x}keOJ=+M_ zmhWxo^7E_Fa2K9~3Owa%wK|L1cn`24HMlrs&H)*;5d~O4u7YhYSag;4y75@n137>oB`r4<{V*dHu4n8kko0!dB_B(iJ zNthV;Jm$IYo&=ibKc73m=j9@$QtV~FBSDrv&xOx(zxz7H(v1H3+z~#n6m8EYl*O}Z z)pPj#Ij5h?S(^MmpF6|n)nb!UgtEme^&i>4!{^`S{d&T&0{<+CT9|CAzG9bR&vuqs zBH%#75JSH{Z)`iOij#IQFmcV7)&d*Yc~)V#K*J)h3YXh44zTJ*11yyNKR-UZiwv`> zH93-oCEbucG|cM*X~qBkz!Ee}&ZPWU7A@tE*k~b9f`W;>^X%Fz&WYvRf}smzmyu2F z6yHnttYfK!fCCLX`ONQ|BDQ^LWEWc??FP|X6CBmp%g(ThZULg-t`M%&XGr~(1l(w_ zIQIYg)S-Y3v#K>Y2}I9R{xVGT!Ly3~8bo(LUmkdhw)P*R!?VLdptUdJu z8Oz_7&%HrD{9`LAAdFA2DfKR`^dF4@ZIA0p?{t;xasH=zer3$A_AgWI>_PvSwtGkU}=KyJR&+pVUW5lyg zR5R?w0|OS(huDMQ8Sa0L;tWAMq;#Xlgt2CU4-yEDv*q4v+n!(n1z&?e^s$J9Dj;SE8Zvg@ONsbIVRu|T;Xe6d6McJUq!mPU!U zLFrp<<=@WYLr<})O*IJr!CNY%fY$Vn@b)0QQ~u!*o>A~vum}&QkE@+mhP06F{|N5^ z!XK}GRPD@=`A2vK5PsOMqLgDwm{s^^Ap9qM%;Xkq#y`SWg7DRQr$31IY+`AYcpG%S z)ow#iH$HUn<^7L5^C0{}tkhfzt(#T&9vThXLw+j0;J`S_Dh&XrFh17~2r(sl;Kl0i zVp9j@R=2nxS1T~OmbJ58VC1_79G-QvPeE?uzrTouwG+#$@VNj#cbRo8?LeRGt|t7j zL&Ej`p6@X9O(5nb8_t>gw2Z%vlE^)=Af{>ir3Kj@2=d2B*o23?#j}i1X_ozElU{vNG>7klfKE+`w@|SiH z8i2^L$|;(wJeoT1|P!y{6O@OJgLk_f0c;(0BPK~#7F?VU@I zUB`Lfze~}wML$4TtE6P%!V79+27r@=m68iHQdP1b9Rf*3Rbdu0$i|@-8H`p`?8*#W zmZcQ|cp+wG3{qt-B$qLWq(v6u9h6j-7fAq^t1wlb3rXc{BmpQrY)ck9{lDil=FGu) z^f~?dx=;VAnwJ<1X3nD@|NdS*qs$}_Ld@mInrvredtSD)<>$Qit&e0|mY?@zyN}P= zY<7<_nHsWRp%1N5(T}Qqi>SYEJt#lXzaCL0DMs{-YX7)Hxo>0|DErmfvennK=WE}- zq@zBCwd{a$pXy{rnItI(&L$Ou<^nxEScbBsVnE%N18Sc#lceld=uhYAXu-F^fo!*B z+n4=oNtx(y*f$^!52%=X4k$A@WZzn&qkgqU<{Zd&6Q6m$$s`M`fwJw$wh(SPlQL**0n7WcP8Th{B4p zCEH~(cMaR%nrv5ctVZ0Fi|h{B2|2LEQnu@|Z(XCvD97oXj>kNmD*w731GOwa5o>uY z{*uGED*FcJoK51>Hvsy@_3Q?Wm&&}eN#Q|w)OPMqnP?D&W$;k&<4_c_ADPGy#SZ<3 z;LV|6Y#9>8GgR)>Tf*)bh2>-++iv+gyos{%zOD-19Exn^i4JjO8RW_Hq~ zgr09{YUe+%eKxVIe5&lrrA~Hlc`Y}{PN1zzcIEwCp-dFWeg*5#F7dMdbit;73Ht%? zLvWse5nL<#}3xBIq~H{wo6m88YV9aE23^rPm8uTG1rvZ?t=p=CiQB*$!g8`w?QHqwITZp=#qU(VCswfNBenr)^H_j( zDL$5z_+YB^M=-B_*8jh{38`C~$FQ<8Q&~%rirBNI&Oembjj?l!c<<8tSdypyC5}ow zy+Zs&^inohQuoJf9?H$ag5vHQxbO8`UzHc-IJ!zaVh4vXnf}C56qb_`7A<_9qNfA3 zGx4ybNQ7aBRoS+fb4jmDF+!_z7fW*3zXZR8t|2U`=nGc{ZWbU2Q<-Ns<-Omej1diM za@_!d=Vj--vBn!eYK`~fo zmx{Q=9HN_ODZPoQHyFM*^o9^QeidRQ(utx zen@HG_uz%RV!TjCh0e(jN-(NVRku?8nJRzov_#et*yeQHQ*yTQwahSyE;!I>4%945(@`~#e z)?yoO(@eTpGjedksVK~1Fw=szD<`t7&kPT=Hy#nz;j;|{(vJOzHo4Gu<9eKZ>auJop;bb z9QTbU3wfSr;zsqS>U`sI9Ro?n7g|9MvI`>2Vb+-r9zJ|17K??reEG7FgGJy~>9z|O zF0B5oWv+Jgp&gO?5PQ(4u3ft(q`({8r&N;@wbcr7i*MZ0Zi!I+uI~NKiLE(c{xC7V`GCXu!`>~`_B#)E}RTQ z(4VefzrK3+_A82z0a-Tv~8%J$Ue7^;vbBR^97BxxH}zRd@TD zGiO%M24CX}`iB&6&iV(Q1Nw$Tw{nIVF8h=;a?sVQS8a_cT;S;xvylfQvMYuiunuB3 zL>4VM)Ub26#ARHv$gssnf1=t>9>3EPi*laY5uT{JO>I9nxWMppr8sioavue}2evK8 zp5~qmQ_!~&w&Y{3vFQEUrKuybAY=zRxJ|bW<;i){K%DvNPt}IsX;T;q$GCE<_ci=J zxWXL7-D!c}>vO}9=bmYR&5y6XWKkGmmll1p(rEXwNWTJpBQRqtQ?|-h~$CuAx(>PCd!Ow8`nyr=N03xdiTMORnS$GtiIL*Vn!Frz#jLi^YPqyAaO?E-s7% zmu__(;10uBv^}S^5xRfCt+=zZLz%R=a^=eE-TDM;vD}2(a3!NC{4Pa|i73Pwnb8Dy zD3PX`n#d661L|U~D(ad9o=q@`V?sAgn@7AC;s||$L4?8NDBXW@boDjg&-l`%OV(pj zhk|>CFyz+1PznQ!7e2XxXN5)8^=1uo4d$GXpl|pR!5Q~p9fn~e1kzo-idi)z`q;uz zqEHlBeZi9gl#o+3ieRm)TJO~VhuX=G0pbw5-!Y4zZ!4h!mK+GfsnfKPsEfSnCZxVW z(Rp|ET7O7DaIq+coSf_6{;>FSz*Pd53tWmrF8w2KO4B0rMgzZlYio-PiP~B?SQLul z1OP8@O%=q&1f&X{HWkH9k;|7auf(CQIU5!WxN<^~k}IdasX&96s|&q0;pZDA5XWvN znt)3beUJ;5CTk;1G9gtFU5I!k>XpE+Qtx}!V&G%vJQv~+q8OHB4shBuOo3`PeRYmR ztM=5&(-J_mJ0$#DGx2?z-Um zki3A*P4qJatD~;y{HdrGfNoPVKP73tTXB(GjvFE zP+UJFiZvO17=3w6?Md`g{9B!Vhu_7aFX28dutt$G$Wkb8^afcz77DwIn>TOL{{BA8 z_?*z_##CLrcyaX%Lju>gT(l@8=uhZJ^_p<0Tb8TXunftaE^)(B%+DZ(S_hTI)<>Kh z`nj4rb*~SW5ZsTc)itz5rw;nO6dvS*wmGH^m0(z}L5XBTbrc(i`jg5+t&Xe2J=%K5 zxMz4Cy82ycZ&wuyv8wxW4!uYzBL(56un2Yqu+)SocM)6Du^4i(?D)3kRFCh=*5%`E zY}pRHL$3Ga(;|dErE^V~!=w-lRyR<2Scdd}%oiqOm1EyY_W~;{ei!Zs z?-jor4e!gOkKx}|y(RE2;rHP8`I^HtJ`72sJWVIUVm(5<{JekFGWHzjskJc+C8x=`-YmIxI>9*8t&=7#A8d+9gCXK@>~UXQ+qJwbFxAHCx)H|E_N+Nwr-cn@x+ioAi&jG2-FI$Jw=TiOW#D6CcDYEZ zb5%LqYb(Og=ib&D~ZTH0JQL>vz7ADk`8uz=YB*0em&xNA*o88=aAVFS9L zR1p@egb)gOkeRo(-3zB@>%xtL{*yNp_WMl}+(n@)H=&Lo#HQ!!)vF>F;MUf8rkFo; zH*=gtp$$titf$!n;l9=9r2YMU_SZOq^jygDrp%q|1KcsWb+@)jkCQ0GyS9n%vv*T$ zec=aHxQjv;ZbBVFX!)E}Z|GV-b?=X}C`2=PN7!(1zPFTGeNMtW&+Qs5ghOpoYRH?q zAq+tb0Wo{oF}J4O3%6%%DkylZ#Ubj4H*&)?LA+!sHVq9Cl&=T1g!aGPbB+leOi1`m}k z+&0uxYgMa1$9`qOg&b9BnrC|c{ydEYmC<83G(J}9r@!D#Rc_q5bBD%o<;s;woj~Ff zbs9q-98Os1_G#1QFIN75OoJ!L5_bJeKUO7&)z*ua;)*0Kb7;-umPKC*Yqx!Bm z_AA{vvx8SC1*Ez)^3UX0LbnZ;oI2dxtz%<*INd{vO(z~N1}pzqw@zRD7z$EPi9U;A zQ|&+Q^SiD^JjX5bdRJVnwuUkCX~Y!@!+646g2sKT$IWni=3~t6Ipo04d+8&VP2Cu` zPFFmgj}uck(j^R=#xeJ@MQA#4g?YZ=(G*;b0!>Dkft;mC=yD&Az6o`xcE&CBXN-f@ zIRUf#kbaOu4*a0=Ubwpl6+`El>({Thx`J@e(o#Xr`0-<+cfUd6i?5UTudkB$y%$M* z`okptcre%P!*G~)SKSRlOs!Ph2qK{j6Spg!jCXO8a$W5Zy6;1~4Y zLYF8!MI*_JyC3QUhof}|eW5Fuy-E)F_D3p76hjsdBu%u5UG!1w;|Idd)Zz!B_|3OS z$VM-GjAk2e5`FN)Twy$%guW#gJHXhW+9gG_sMIqL*u1!V{Nw|+k z+i>&l%MbNQjbjF~ZeOsF;gy*ny8R1%t1UDEi;`85EQQ?4MJ(OGR$aglnQ%FO>Gf`l zqXDqGUcMYv1&vnv`bPF>@u2Q5<{G+UxIQHz3gH~96*IJaJg9rPI43a~8ZV|R3iDv|NBvoI98fv~DO(CNW9gf3x(YB` z6F(4y5NVd&y0e!*L$f!&RkXG^LWja|YilbBYhm7O@&;qDZYkv4?+p7>^rA2m0d=b^ z*K~dx-;>ESQP0A!f;gZqE1{L~0&(`{tM&xpCqGa0^6N$BG1^C8rP*iSq1nIv{L|lz z_(cdq%hpLemkSpzQ0!!{6>pj<2XHM*0}PE9bBcg03L__mxI&$Qlqc`ZSh_Y8T~@-) z%}p}Kg(&;2SDU~7iPu)*@F}aIx(3V1CkC08Z0PD6vDD&BnYKQbs3?r0EKL@Lme~2H zKTqG^{N+`<`q5t?U(~H69D{Uqtbj3!!uc;hLx27+o_q4%|77h0tFLAJ($>H5^|p*b z-7-mitV+)Kh#^!+K{)&LSDzM!uvUKcb7T{QqQve(?wVJ)CNxY1SP9!|jB<@DH1udi zF_`iSW`V2!{1<5FpZz>N|MRm|+xjQ(Ux`E?#1K;m`UX>s?&G(A=NIY1>1Ut3_sx&| z+)5Z`c!1MOny_Xigcx*?Wh~zQLiaZ)VF=f9m9M>NwFC|XVGJjibkQ}h#C~tF)3q_< zYQ@#9rqKe<7o8Jxx5%pINP}iSSM_Zk6ufj4d9->`KeCp zA4=EMS)H?NiPS|?3K`b1fuj`r#vtd-h~Zi`C+2jC!X=GlDkga>{!K1K@W=1`gnZCc zwV&feo>F#ko9kHd{QGCwg&df+1QTl_;Qm+18(r>XSPJXb!oPgWS|)Prb9vtaj_lX$ zLiPtNggW6HoZpF|C}f}AbTNMYFeJI}s!{LlD4O9ib{_xhw#k1+-y2zAdG z$9v(bBMe31Nn%cyC>%ZOO~HMP%+ogh;D7x+`J^tmxSoNW0@w`bxQ8#ypUvfI0@1bs z!MLztMHlaWoxFouge}&>*_oH0`kGA_a_ZPBPn2nAh#*vO)e%#+6fS8bsn>r(37R(d z;oiGHm6k$3@q~)UkB{s3SZft>2W4lvHUoA3{9it|`ZpQBv;~4&3#)LMVA4ff`5V+M ztk*64zQ@{$kz@6^!^|;Y?L!RVP(cW|#Fx9&*aSl9f+5u?s}q-Me>3*97J2K5mNRAHbZL<0PgqRZHQjNOeb1>MPQ1 zy!^p?5-Ir~@xWhL6`NFIRO_`0Wz<2o@U znt)gq7>GA)0C05O+rRVNDn8H@oOXhrl?9@E+WMKbMJAM`5VResS3lk4$5|8(1(xI- zUuXuNA;jPqcX6E)Cr%JoQXUswE7RmpG+7;4_Z#nDiNUVeFjxma{TF{np=c6?Xxx%J zx(Zlx=ra)ig0)_yDoA|H)-7h^k)3^<_ zup^!@6ivI9*q%R!kORDPmx8bk1mYXgBAGFx-q+hip|y?2b9^lrPq1{2u^&%4W*&qv z%oamEAOaVQ#j4p9gdpM4c5`A(pAQSO>!>%w!BPb=cVS+X4}o|?d-M}=Ou%7b#`m55 z;QQ#z2cMDcecHl+v!w?MAU6{?cawXFDJcPDr4K_*yHSRoT~d6j24BVfyhG)m#dN>2mkM9^soO%SPP+|NqooV!q1t1 znXz~mg~;^%UZ7-?HZfZN`UmV-3S(dVO!@|dAzYZ-+uM_ht!x6%5n05qyz+{5i(nTV zjT;tVt*QFbDWi=m{FC)xa3%=1|KjiHVJvQwts z*W3EOwZ+9z{GJv~N7&)d^<($yN8gPA&ntFIp(!}+1g_DsWmRHE#R=m6A3l6YSFc|6 zDhNTMNt4qQg`t6^&^8bazxiiB3YKL&glu^m4w8kIKkz5?(Vs%pwH%5nF%iqc*tU?^ z3TK&}eVKxRlTF@u>8M`eyWc4OT(exxUifV?rR}J|n6NPq;%A^-4YF!|iy~iO%;4VO z5>_G*pRksOyyryMG1j8cSrAp|mwE4d?~SS}*MwTUeV^ZI?sWS{a{rt z21nVQ|3}XbDpsuntjjn5?@#IW;XknNPjH=WY;3H=AQp|iy}ecAM!oOgx|??LIO)Vn zMkCzE&jpMtl!8$FVTUCU*Ji;|c<0U?iXEHt3i=L&62$V&n>SgG1syP!@mwJGP}3Io z#WmBHa{+4uSZAvBi9(~i>yW_egP6jxz+!as-Jj5%zxi2IVo(v|`Nv$!=-_>;cdi8D z#tZ%D9d`jY@gGYIGxxsK6+6V$N>cCZZG9iEP>5V(Ai93*)%IiOLUZONvcdlTzSj3u z`^@_KI&s2AxZia9T~-lFP#6< zGf!E5V7Ynp6Id31Mh|}Y9^L=$d#isNd+oRw&F&m)D=L9_MOuo79&-m^u|`4?=l+D} zuB?KHp=w)5x%R30^>&FuTfl`Zn2N&LCq6}{zzWtk79orv1eGNja7zqudAbws759#Y zX9Zyh>ni$L3a)n!Bzij9oEYOw6ml^8Vj;c~LClsZu?WDJv&hhwe)G?MNN@kEU(^mu zC`uObNl0pAh~KY#vwD{xKqbC5>>>sJ$a|L7}S zn+Hz#x<>pSJQFkp#39BC8j2m`QweSL^$^5lFgSVgBynWwC22;HaN$XIG(GEBErpyx zjZ0Yla8#WRp^KVNg?hh%)w9j&46%b6Yc`m@{Fx%jgAub0bL%A*yf#Z0GEl3+r|I8v zB4n&wU##Bg)2An2>ZT9QN8P1Lvf0N|A~ttl8!>DoVEw!Opg%4UXJMy!d>zMFnx=)f zEmK-IPEHG0 z3&4^@2M$}V|5blUqW|`PeoCoAF*ytzyfA7-V4^kpmhrsxQ3TsCsQC)+Olf< z5{I0Q`Ff@1aP zvgc0F?9*QjdmgeBgto4s>)G_houcFqCpcRQLnflcB($<9^iQ%UnoNnd3_ETEs!vqJ zGy&Xvx7`vL;$##bAXuIoT?;=FXqvEXEwm}h1_M$0+FMqvm0easxa0fgbZ(YHmzl?= zgcODO2vl=Yjs?x4Fag>Y#@KdQF7q{*E@>pWHw?|s9XAcTdmEeS`*N(hm| zsehF2Ug>kbKs-oLnz{1tGsNh^z>q16}HHTTr$TD924ol(_0=X@gP@Q|-2e-rt z&Sw;7*YPkC6ostOslvOYk#G+5O@*#wyJE`#k8SY7DtJi>!(ZL|!K!gTxugx9%>zf* zvF`kG^oM@Abx`USu0EACqHL3ei6^0Ek+71c%5;{t$%P8 zh3e#!P^e@1GMP4XMB(V*hb#)+GA|D`&ty!{8K>MbOM!0R$M?0>*Mp_dYBq9}G67}w z`_iiS(O1U_Is6-GbzJ=%2tu18lZtHPHj#q)1=ds@xP=z9ooW*r4i;5}RB#LhX(M~Q z{GxNr9ae);>W*{vR|<3$k_B9k2x=qOw)Btg#*L$;aLB@slQ`M}{2_c$*U;G}7O{rv zq>*>OLE_!77suoM_%YG_G>Q^{pyET=PR#b1|4NJBZja`^eq+((uI@A zfSBcKkg!@jeIytI>+9>Q_Zx%ZCW}I52q7+uksKZ#qL^E5jvAhC7jn?EyXG#Wn~&Y~ zrrtI;H-!#zp=EX5a-k4Ik;1U`=i&YyK71&q0_F}p!?x$Kv$Lc9v+mx#%c^ha_URCC z8b`R6zBPmc*REY-^USKv1Y(Si`*`#gaNdy_n=zv+fL;;X4lDN+|L9oTU|C4vOk23k z+FZ`Kzpj!jDS>;1rKqhe3Z9X!+Zb@GU)gq=O=v5_8&qCMUSYS>L{W zo4A2@ck$vymS>u0!q6)UXR{f4%K?o9*=rfQbS-Q#lVeBxg2huuh{v2{Qm0S!V6(+B ze$obgf)L{mab`m|XPiVKj1XMs5f1T$O<>apVaSziEbxntr4Wo>6)-?^hO9l{Hr9Qf zF`j6WCjfuwTH82Uf=v1e612K3?N}gFJH`qN|G9JLI`0d~IAfkGr)S00b&O}kIpY@_ zAt&XTGHD`&SQ7?tOn8W~4GI3P1hleheaHo$-8HA!h9p)<5yk4-k&t8x?)~i9vz^Z; zh9G<$bOQCq%qE3qWe?e6XhZg5LvQq2TP0eK%rsd;i|&ho{^(xe++jDg#I zlK*etjrrp+!lVaTO{LGW<4?4tjK zem+ARtjk=;qL3-V(1i@Rnt{1COmtYZOyDNO`^dE|-M66@6;6G~#lqiJzYr}pVTc92 z%L1yJ$!4<(2T^E4+`1yxjqa^mw*&{WC}fPhmxVASTY~x3 z3i4j8%*xn-=!|oog92YLC7)4>uR(JF4$4>4XEFHd% zF5B4H=)4Xr@Z939bltZ|R&z1OgdDG&GmgRj{(i7$WAhn@4xixoObqWQ9fZMI$W6d9 zHZ`ju?iUNKDIYLVM5Lw9kcp7q${2hR_#dsGer6E?x^b39F$4DO(H2H5Y;)ge8bWkUO5E?%d-- z9Af^#LOf)1=gT@+$5$VTK@fsivyS`bmJk`q+@1q8gaL=dm|5R9$Q#&_AJ>Qhj~+dG za(*Pvl6>&sLF@OPI(2IGiL*R$rY_fWb#YJXzVP|@@#EE}0{5ZrL3TtM%KG~H>Rd|> z;LKVn^alMKDcb4El`E9dV{2<`^~`LadyVmS`t)h?1^PVt3WqU^d&fA^J>wVsTnfb1 zIlN=ugPIR+-n>cs`}@b{gD&v9a5j3^xz4U}J>TH|(7(Ckd%XB0#Mqq8dZmh2#;j!( zmhyh$>>N@MhV$Jd2zKwVNMc8gUF1;LE_P|K0_^SWX$ZcNeu`aHZ5@Iv68>YfL@Gkjgm3uC<^p{#>-aDOi592iX z=g+U^3fpn9zCfRdA@0!zct>~d-gO>d$StN2<4gnut>((uT)tiRT3%og;6C? z>GU<+H{60L2%IJ$cx(tnB?R$YQxZ(6Gu1L$uIm?ayI;e#>;^IBm`zq*?56y%a=~yb z!DRq9lMNSBrXQH{?qZLcymhBZQJdsvMGPDWk$c@QLzD z2;)%Yl+-hs3V04E&w}t1RdPy&o+wn=SCr=V5 z5FUW?NF&*k?&C|ulxJ&y9(|WpusCCIWHIO$Jn!4LZ?9sGIO0kWU?PIpM8l$sg&5C4 z2^3SR3%3$@Rk6pl24)DRAbfQ}f63x*I$*w;q96oVdoEtQxDtjges;zmPeh@#5?+uW zb24N~LAD%$AgYQ&E>=QcK#(8^xEhFGx6;6Z)r7KYRmEO4fhm*8WZK|zNI)ns&tm;I zu%JbQ;hON67*n+rZjm8b5Qc~I#1u+gWou0)lW|Qxd8w#M0HZ|e-U7q5jJ4@=B?_gL za4tXA$bc*edBEA!o-JBCcphA1+iifDas!sc7$pjg-X3-anM@zqQs|GW;6n_h5_zEtuUADT648Jv zR*V>=Y+XE#N|?HH=gyOC?lxhLX9r=0g3p{5VqE|I`}bF$>K@uqeF4`W+h$?P2_|iz z(gcd_=Q%%8gaN4^{2g0E9XJJQ-*LLORBO}u26gN3ZmY*DqHY%6eXNCZb4~5NxgiL7id(DJfBR$t5u6? zax0`7<2k0GsGj^#6@5O>)%M-`&hbtWB?N81R}^*k^(<8pdE568!$tu@IIn@t4gJAJ zAH>F&+r1gy*BUVf?&Hy^nK`(YA28Wx;-G%+ceU{I6v2J3udlbBbisVipkE*X)!>ZaCqIW9Nr$^g)jHbOfE_L|Q}9!6*s)gOW_F(<%GIkZ zesHbfJ*8~%v(b-Jv>1jsmeKaPv?1=n^$}_&WGi8RGYuQ!s54Bv4RJyYmW#v@8%;NHgX=wn z%BpUHyOofekizD9EZpwJbb~_N5e5*W7d7R!v$NCqelQTZTqDPwKExkH2V7%W`0;G9 zSePNCVkk|bO4iifv1%-EnltJRV-wfO0nS!JJSUDByJO!Sw`Xl4a3zPt9brITK*P7K zt*zGg!)b?QIFO8~3c&30jjM4^G*B+qn4YIfSVawfv{uV>&kKF7x^le{Y%;j(d z-pyz7S}b!;-95|kh`1pxWR4}043pkAi97NljjIa7HAu`}TT)}3F098KkBRGR@TN4ub<@)~jyc9d8ip$zQcR%l2#ZYJ z9fNyUB2XtdKIM9Qrg1+xUNyIBvz2U;V^q^O?BTm=D1(8Z1k?6Z#Gx)z zDScrY^OHqkZq;Tg0J0!Vui0`T#~M!1<~mkzTxa@{O*obxGxSoxIszfMBkX9cg(w8& zoLnIsSqqQdg%k)Fz$ptv9aqMbVwp_ceOVOdR&6HX2ssoX&1S=e9Ag}zYJJ`*jsmMs z$aA}1G(%h#)m#WmH4vYv{$Vzo9mo%-=cD1+9l5I^*@;OCM7nzQDoHc3^<&UN(s%3{ zj!VMvY*RrUyOhb;VC=Dm7ezF?a=qxz%5TB~!;P0x70ltox zRDV%waNrZ?V{!v$s7^@`Hoc>vWKo%^GDZ|)E|}7|n<6+Hd+b@i+uPe-&r-I@#>PhP z_i&s;yPh}4Gmkk6>jvhsA@*=jeqND=z9kLhDN!hY$Rqg!FX0E1XKWtejYWW?D7-7`Uz#Iza5fQrO3-Q>Q2<#=c9JMCQ%hlnvp45QcjL z{@ro+u|ob3WXU*nBfBQ#BG9%2!o(BnZgea*2GaTS=P8pJ3*3USj5~}M6;=}~xQCsc z9WAk96L^`1T9ISlFyJ}saPs6yiV2SH;|PrtbJ=c4VQ6PE5k;1a`_tzg5V|f?Tej^K z>o(dtn{*0U+PUd9og=aov7J^@ojYRe^O)mQ(nE;EjiKd=V$MXzv(`h_VC`qCJSuMZ+mhJ~4Pp(6~R z?fOduDas#$l&l36rbzZcd~u9NKoIm4IdkR=+2EB|UdfrGj=&-gF`^c8?qLqPlQTAl zv24Wf?CY*TjtgTKxc=&fIK=f2aXTWpBv$a8hisH%5;Po>Oh5v$?Q<%G;knZ2Hy)@< z(OhYp9USU0HJS9MT5Hed@l&~8p}hw83{D$rt8d2nS?joQVyiia<1k~s{w7yoh?aaSx}7u@ax$JjN&aYNd5j9{c# zOFltwTnMIc80c+6DD-uOXf6g)wib?m7ekI4r)|iUQF!S`$s0K#ZYmh%scB!_OstDA&|G!o{=DEy0i}K&&8w z|IuvK01XLZqxuT*ssi}2&=ebnB#2mO8k5(rhpTAT7n64L=1qzf^p#YS?%EPEIbtg| zo*6r$A@O98C{##cxGO&(4wu9LEC5`x$l3yWQ#!^^7Z+9Zt0&-Onn7PfjkuJ=ph9P( zF(d;uG+5y4;{-`*u_T!^B|PBTLyFq2WD~3vT!lv;P02_x(DX)JSnKTbE+KYmQ~op(sw`wG#!-yr(_ zw~DWU=S~oP=*3l=z3|)ep}jZpui5Jv?Ytd(&cq$GR2y`0egeGE%hiogJ96Ii|D-k2sdAO~w41v@Aw8fiqy2ie1 zHReZ;#Cu=iFcZ`n;cyMhMS1lcY(H_~xIIC05o# z^#?1WP*D?ys0_L!hai}l(*%mn)8@9r1Ue;rh+xDPPAR@n3E{)TL+7!A92ZY4jynA+ zhA@q$SVD0B{(Xvpw&KYg`c2oG5L?Izo-@XJYBA{ubE(7ifQP1QH9>swb)qkRW|$bP z195osQzX9hI$7e$4%MnCRFvZInrskunGbgVLdDb;yEfTh{mk1uM;==#K~03sVMiR zFOYejk}wP*1gmfrCb}C9?pBpHDSo2xm_J9z%Kp4-Yo z_|3P-I`U^)vKX%1Zq7wv6)6m{6#+k3qn|QAPi#_-O7p#t3W6r zScRh-N0r&p{a(&=_0t$cVVji@aKHXotZwKdwnJk$jElAn1=o$JEn~wf{?Dd`unsJS zZ-0RdIgkH%6@?{6Pno0J80ZQi4EB`VU`_aP9oIp>h{fH4K7hWUl0|UcHcH9Zfa3(M zD+mGSq^>S2A>eLli(zBiHHKoC8!#`pc*Y#YMcammYuJI6wYp3Wan8`Bm8V&`kiCjR zQB=>?pWPea=r#sJtW>vd-AXzedB_588!WmITHN9{RncuF0`ZB7pW6ljf{>#y8M0&N zy7221ALcGvVz~Y;QOJGX#pckJ=nnmEy21{)#xUD>W-BWpgrRGsAhcjDT=Q)yTq6VE zBIMY4`gZ?yFlpVrdpF6O@RXRRpRSA>! z(2OdfEY*E%ZEf}TMb3SrYf)ElaZ$FxwQu0Lx!%l%7KJc|w81gS#9ExXi#WnI;G#CO z7S2bXXfhI`5y`D4p<-+-8$<$5{ooiQITi~834R2qWfat$lL1N`0$9Ls<|!s0n-h>K zlY)kKiO*Q#mYU$`IvzT{RB0$q7EkmEolPLFoof~*L`xssErn`R#;tGQ*}8sq5X7Mj z5!euQS!ua>Cv-MII;>79@nI!Yl&7Q63m>DAobfFR*JvcLV5DFrZ2I?Tu!2Ig#C#0X zP>W6r`zUE7gg~5tu`pENy41B{0#f4e!Gi~@ex)X*Y9GVmQEj-tQ>RX?{y%o{*Mxd5 zoES4XtHhma*RBy4bOaSRfBrn1bKI-w9>G>R)^=-QJvg?`GZ3P)@uKkTj65M1d2AEz(nu~|yvXiMP!~Hq3=S;J2)xB-eFs$) zC-{V@;o^o?$r6T~hA71UlrgB*gkh!|Y)H7p?2heIobF{v;sD0`>C>m#S+Wp{p$ndS z$jxnEVRt;+bLY+l6(Y)u;X9@nUMZh~Sa*$-b3Snb{oP7D;Ttdc7lyy~BF%np^zU5y z6otq>xJ4s5dGaJX6SE}|WCW}Vmo8nR&&TXSWA7N;v{vFdpL*V$yUbvM+QkZ>l7ePw+dDUqD^P_n`0SW_xq# z?>ZF%2H{%}TKJtlMd9eT&M^p;%K_y`ZU~L7pj=67e1H^&yHp&RxTH)K42~sPfRiY+ z(WkH=g|t|W4K2}4N^#BD0?Jt6Llu%A@_S*e!#wUt_=KQHVlL%$Xffj_V11ExJMUJshZ5!Zyky6LDPP)UAd~*{)L|lJPvO#1C-Vd|B#K zgRB*{z<7kq*ko}DUF06tK9krph)LhqKPKr9*9q&LZLg0K#&Bi##W?y#^-mYz`Qv#G z9djJSvPI*@5z>|G9mC)0Qxu+}k#JG&HYR%)gd9*7cUCubzp=C$o*wdVB4;Vv9u?y6 zB!sbSmrIe%%ZzRT+q-q2V|GX!RUe_|8VEugF^xV?7cz1C&1-c{$8NHfYgW}Jg{bCg zbwd9{|FUUNK&&JL;h1|4Et-|F$2!0IVe-z0UL*s&CYoSta)Cw?I?=@DWN({Lf!%l( zm;@|<7khe)z$p_^cFFMgL}uKR?N0gGYe6;5>{oC|a2S)Ap#xmwNGx#aA~$gvx8dWu zt6*WbS<1)9^{c}GY(00ZGe|_abLY;J(p61s&%CTriZVP9H8Gx2W#j#*_)HUF=Q9DO z3CuYzPT~?@eBJGt$4z|vHJW{L^s#yeJ}ret(T^cxY{r_zeGwwC8_&ez;=MoC;#DT1 zY|@b%^&Z8=l8RW$Z7STsumqkfErEM|g0RHOZYgyCb{VVdLa z0Nb@4U0=W2tlNCLmDj@`-^?vzz&zAdF-t$8;jN64p1ohiO;99gPFn?g2*s_SK*fAfQR$Q}-(~o@3 zakj5Nmbuvhm@^PMGkQI3L)FQJl@wzIe^$?TUe?F8btZ7r@5cP&bB!NTLv$24kNEdr z^p}PF!4K2yv+s}r{^IrJqIFpqA}0WWALXOT$zI5vqKFA;F?9Z1XEj+8fY;xVw z`sMw)0p>x|Yo~H7<7b7qev^KNe=9c-Vxk<1B3uMxejySAYp$<($JhG)-k1MKy!X4m z;-!f%e@+;(7ESSL1ysH3GmvB5fntY+lV4mF?8S4j9h^*-t%SOWVq(ROs86&LmH)5v zh>qN}56aIa9a#XEA(KAj{p^v2xJuvFLSGibHgHN3j+fLuElC}rhuFK&^Mkr}jCWn@ ziaMe(bapdYGO6Sz`-Db7<4p;St#O9_8xZbUYJnAcxiHIc?*1?0OzbwZ+ zDi6#lF|dZAvIb&NO$0-+w@4@Pv$)o}%=_^ z`Ao(FP&rrN!lMz`j4RGKMp_HSSEQKx-nVEJ&z+#z%b!^ls5fFqBhu{2DeQ^je2i2S=Ulm*1PRTXj1NLO+zd zKJ!{ld|6Uqao#1L$1-J7;HK<1C#jfg56B-#9zkC{xvB-{P!NLhO!(8~@3dxx%&dTQ zH-%@qq=xx3h@}uDo<^@F2%AW>80qNekr_wAZsaA^LJ(ZXQ91%50q}oOZ`i62N zg)y^6g_u!))?rD72)t8%-kSwH_OgkJIK;ip$qffoh(VM%d6by(2k&)Vz1PSMIE^Rd zfS{rfBxD&wN7@Zv>tFD zToeNNaQ7(gQY}j=GI;KhB@z&ZSr9TIQuk_=11iQ>@50M?+$^d4xUx+u#1&VI%92{< zIL6Q1+D1KP%J+A+Y^UhxJYYm5hvG1-0SoBcY+VNmfkr+6yu%aJh&_Lt{<^h zhk^$SQHY%k6GYKJVxxEd5%H|kha$EyA$U#XeL(z-c1a9)CNT-aLs7)BWf&sg%>_~P z+Ze!gu8{%q-=7s>$Ijg`_aKV*bST2J5CazxQOs8mk%uA-qn>%j46o==6 z4!290Xc2riU#q?(Jnd%1kritp%vp#cUenjLD}iOhRdpyzQRVUa&z8^Y)`)vJ6or=t z|2~JQB!DQ!;zESyhOq0M2pu215F@RfEV7TgC~pi|c^Ak(`&5)OI3Q+-Fa=40DoSeg zyv|HGMB(a0cJiG361*>oquO+6XQ+4v&(l$1X_avamSscsC9-gX)}pIj`<#mFCwlrl z;u@DUGIP{5;&{oIdF8LvvtLG$EtwgOD2y(cgVx9&=v&AvzUHcbiK6rm^vj(5a6pca zP-KHB0#p~mJqzJ+=WmMe6T1UT4tDlylt~tGWQB6gBKM_CCR0Zog-W=df4r{oqL^D3 z!W|1S)j3tzk55s8CmJExhUI5-C_KBCGCUAR8;x7SPNrZoV=R-&xJTFx@uGhETS62uH&wTodOr0Iw4}ln zrk+c1w^)W3Eq$perfW$@FCVq$HaX85yy{Jtgg}X+P5z0 i=)P3=$APOg`u_v8G=gZyPQm5?0000 + icon
+ 뭐먹었지 + + +## 🍳 뭐먹었지? + +식사를 특별하게. 오늘 뭐 먹었는지 기록하고, 나만의 식사 패턴을 발견해보세요. + +#### 📸 음식 기록하기 + +사진과 함께 먹은 장소, 메모를 남겨 나만의 푸드 다이어리를 완성해보세요. + +#### 📅 캘린더로 보기 + +주간, 월간 캘린더로 한눈에 내 식사 기록을 확인할 수 있어요. + +#### 📊 인사이트 + +주차별 통계와 위치 기반 분석으로 나의 식습관을 되돌아보세요. + +## 💻 Environment + +- iOS 18.0 ~ +- Xcode 16 ~ +- Swift 5.9 ~ + +## ⚒️ Tech Spec + +### Architecture + +- Clean Architecture +- MVVM + +### DI + +- Swinject + +### UI + +- UIKit +- SnapKit +- Combine + +### Async Programming + +- Swift Concurrency + +### Image + +- Kingfisher +- TensorFlowLite + +### Build + +- Tuist +- SPM + +### ETC + +- Firebase (Core, Messaging) +- SwiftLog From 3d75401bc2a5d6364180b6e6b425b8e31fe37e02 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 28 Mar 2026 14:41:36 +0900 Subject: [PATCH 32/97] =?UTF-8?q?feat:=20Sentry=20Crashlytics=20&=20dSYM?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Project.swift | 23 +++++++++++++++++++ FoodDiary/App/Sources/AppDelegate.swift | 11 +++++++++ Tuist/Package.swift | 1 + .../InfoPlist+Templates.swift | 1 + .../Project+Templates.swift | 11 +++++---- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/FoodDiary/App/Project.swift b/FoodDiary/App/Project.swift index a34be8ce..4a00cf41 100644 --- a/FoodDiary/App/Project.swift +++ b/FoodDiary/App/Project.swift @@ -22,6 +22,27 @@ let project = Project( sources: ["Sources/**"], resources: ["Resources/**"], entitlements: "App.entitlements", + scripts: [ + .post( + script: """ + if [[ "$(uname -m)" == arm64 ]]; then + export PATH="/opt/homebrew/bin:$PATH" + fi + if which sentry-cli >/dev/null; then + export SENTRY_ORG=mumuk-cs + export SENTRY_PROJECT=mumuk-ios + export SENTRY_AUTH_TOKEN="${SENTRY_AUTH_TOKEN}" + ERROR=$(sentry-cli debug-files upload "$DWARF_DSYM_FOLDER_PATH" 2>&1 >/dev/null) + if [ ! $? -eq 0 ]; then + echo "warning: sentry-cli - $ERROR" + fi + else + echo "warning: sentry-cli not installed, download from https://github.com/getsentry/sentry-cli/releases" + fi + """, + name: "Upload dSYM to Sentry" + ) + ], dependencies: [ .project(target: "Data", path: "../Data"), .project(target: "DesignSystem", path: "../DesignSystem"), @@ -30,11 +51,13 @@ let project = Project( .project(target: "Presentation", path: "../Presentation"), .external(name: "FirebaseCore"), .external(name: "FirebaseMessaging"), + .external(name: "Sentry"), ], settings: .settings( base: [ "MARKETING_VERSION": "1.0.0", "BASE_URL": "$(BASE_URL)", + "SENTRY_DSN": "$(SENTRY_DSN)", "TARGETED_DEVICE_FAMILY": "1" ], configurations: [] diff --git a/FoodDiary/App/Sources/AppDelegate.swift b/FoodDiary/App/Sources/AppDelegate.swift index 1d373598..ee9be486 100644 --- a/FoodDiary/App/Sources/AppDelegate.swift +++ b/FoodDiary/App/Sources/AppDelegate.swift @@ -9,6 +9,7 @@ import UIKit import UserNotifications import FirebaseCore import FirebaseMessaging +import Sentry import Domain import Data import DI @@ -18,6 +19,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { logBuildConfiguration() + configureSentry() FirebaseApp.configure() Messaging.messaging().delegate = self setupAppearance() @@ -25,6 +27,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + private func configureSentry() { + guard let dsn = Bundle.main.infoDictionary?["SENTRY_DSN"] as? String, !dsn.isEmpty else { return } + SentrySDK.start { options in + options.dsn = dsn + options.debug = false + options.tracesSampleRate = 0.2 + } + } + private func logBuildConfiguration() { #if DEBUG print("[App] 빌드 설정: DEBUG") diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 7b191ec5..e2dc6cb3 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -18,5 +18,6 @@ let package = Package( .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.6.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.0.0"), + .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.0.0"), ] ) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift index 23ae62aa..5bfee72f 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift @@ -32,6 +32,7 @@ extension InfoPlist { "NSPhotoLibraryUsageDescription": "음식 사진을 분류하기 위해 사진 라이브러리 접근 권한이 필요합니다.", "NSUserNotificationsUsageDescription": "AI 분석 완료 알림을 받기 위해선 알림 권한이 필요합니다.", "BASE_URL": "$(BASE_URL)", + "SENTRY_DSN": "$(SENTRY_DSN)", "NSAppTransportSecurity": [ "NSAllowsArbitraryLoads": true ], diff --git a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift index 13b5f707..6feba42c 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift @@ -9,10 +9,13 @@ import ProjectDescription public extension ProjectDescription.Settings { static func makeSettings() -> Self { - return .settings(configurations: [ - .debug(name: "Debug", xcconfig: "../../Configs/debug.xcconfig"), - .release(name: "Release", xcconfig: "../../Configs/release.xcconfig"), - ]) + return .settings( + base: ["DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym"], + configurations: [ + .debug(name: "Debug", xcconfig: "../../Configs/debug.xcconfig"), + .release(name: "Release", xcconfig: "../../Configs/release.xcconfig"), + ] + ) } } From 1bace1d3c67b02d7d360872365960ff4d150b5da Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 15 Mar 2026 00:07:57 +0900 Subject: [PATCH 33/97] =?UTF-8?q?feat:=20FoodRecordRepository=EC=97=90=20p?= =?UTF-8?q?hotoURL=20=EC=9D=B8=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/FoodRecordRepositoryImpl.swift | 43 ++++++++++++++++++- .../Repository/FoodRecordRepository.swift | 4 ++ .../UseCase/InvalidateMonthCacheUseCase.swift | 20 +++++++++ .../PrefetchAdjacentMonthsUseCase.swift | 34 +++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 FoodDiary/Domain/Sources/UseCase/InvalidateMonthCacheUseCase.swift create mode 100644 FoodDiary/Domain/Sources/UseCase/PrefetchAdjacentMonthsUseCase.swift diff --git a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift index 7b5e121a..db0a344a 100644 --- a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift @@ -8,6 +8,32 @@ import Foundation import Photos import UIKit +// MARK: - In-memory Cache + +private final class PhotoURLCache: @unchecked Sendable { + private var storage: [String: [Date: [URL]]] = [:] + private let lock = NSLock() + + func get(for dateRange: ClosedRange) -> [Date: [URL]]? { + lock.lock(); defer { lock.unlock() } + return storage[key(for: dateRange)] + } + + func set(_ value: [Date: [URL]], for dateRange: ClosedRange) { + lock.lock(); defer { lock.unlock() } + storage[key(for: dateRange)] = value + } + + func remove(for dateRange: ClosedRange) { + lock.lock(); defer { lock.unlock() } + storage.removeValue(forKey: key(for: dateRange)) + } + + private func key(for dateRange: ClosedRange) -> String { + "\(Int(dateRange.lowerBound.timeIntervalSince1970))-\(Int(dateRange.upperBound.timeIntervalSince1970))" + } +} + /// FoodRecordRepository 구현체 public struct FoodRecordRepositoryImpl< Client: HTTPClienting & Sendable, @@ -18,6 +44,7 @@ public struct FoodRecordRepositoryImpl< private let deviceId: String private let imageConverter: PHAssetConverter private let calendar = Calendar.current + private let photoURLCache = PhotoURLCache() #if DEBUG let testMode: Bool = true @@ -98,6 +125,12 @@ public struct FoodRecordRepositoryImpl< } public func fetchPhotoURLs(in dateRange: ClosedRange) async throws -> [Date: [URL]] { + if let cached = photoURLCache.get(for: dateRange) { + print("[FoodRecordRepository] Cache HIT: \(formatDate(dateRange.lowerBound)) ~ \(formatDate(dateRange.upperBound))") + return cached + } + print("[FoodRecordRepository] Cache MISS: \(formatDate(dateRange.lowerBound)) ~ \(formatDate(dateRange.upperBound)) — fetching...") + guard let accessToken = tokenStorage.get() else { throw FoodRecordError.noAccessToken } @@ -119,10 +152,18 @@ public struct FoodRecordRepositoryImpl< let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" - return response.reduce(into: [Date: [URL]]()) { result, entry in + let result = response.reduce(into: [Date: [URL]]()) { result, entry in guard let date = formatter.date(from: entry.key) else { return } result[date] = entry.value.photos.compactMap { URL(string: $0.url) } } + + photoURLCache.set(result, for: dateRange) + print("[FoodRecordRepository] Cache STORED: \(formatDate(dateRange.lowerBound)) ~ \(formatDate(dateRange.upperBound))") + return result + } + + public func invalidatePhotoURLCache(in dateRange: ClosedRange) { + photoURLCache.remove(for: dateRange) } // MARK: - 수정/삭제 API diff --git a/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift b/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift index e48a5dbd..29c30f26 100644 --- a/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift +++ b/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift @@ -35,4 +35,8 @@ public protocol FoodRecordRepository: Sendable { /// 음식 기록 삭제 /// - Parameter id: 삭제할 기록 ID func deleteRecord(id: String) async throws + + /// 특정 날짜 범위의 사진 URL 캐시 무효화 + /// - Parameter dateRange: 무효화할 날짜 범위 + func invalidatePhotoURLCache(in dateRange: ClosedRange) } diff --git a/FoodDiary/Domain/Sources/UseCase/InvalidateMonthCacheUseCase.swift b/FoodDiary/Domain/Sources/UseCase/InvalidateMonthCacheUseCase.swift new file mode 100644 index 00000000..699dbb55 --- /dev/null +++ b/FoodDiary/Domain/Sources/UseCase/InvalidateMonthCacheUseCase.swift @@ -0,0 +1,20 @@ +// +// InvalidateMonthCacheUseCase.swift +// Domain +// + +import Foundation + +/// 월간 캘린더 캐시 무효화 UseCase +public struct InvalidateMonthCacheUseCase: Sendable { + private let repository: Repository + + public init(repository: Repository) { + self.repository = repository + } + + public func execute(for date: Date) { + let period = Calendar.current.monthlyCalendarPeriod(for: date) + repository.invalidatePhotoURLCache(in: period.start...period.end) + } +} diff --git a/FoodDiary/Domain/Sources/UseCase/PrefetchAdjacentMonthsUseCase.swift b/FoodDiary/Domain/Sources/UseCase/PrefetchAdjacentMonthsUseCase.swift new file mode 100644 index 00000000..34703c66 --- /dev/null +++ b/FoodDiary/Domain/Sources/UseCase/PrefetchAdjacentMonthsUseCase.swift @@ -0,0 +1,34 @@ +// +// PrefetchAdjacentMonthsUseCase.swift +// Domain +// + +import Foundation + +/// 인접 월 데이터 프리패치 UseCase +public struct PrefetchAdjacentMonthsUseCase: Sendable { + private let repository: Repository + + public init(repository: Repository) { + self.repository = repository + } + + public func execute(for date: Date) { + let calendar = Calendar.current + let neighbors = [-1, 1].compactMap { calendar.date(byAdding: .month, value: $0, to: date) } + for neighbor in neighbors { + Task { await prefetch(neighbor) } + } + } + + // MARK: - Private Methods + + private func prefetch(_ date: Date) async { + let period = Calendar.current.monthlyCalendarPeriod(for: date) + do { + _ = try await repository.fetchPhotoURLs(in: period.start...period.end) + } catch { + print("Failed to prefetch month \(date): \(error)") + } + } +} From 460bc504962ab50ebbeb83cc6f351ffed80c193a Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 15 Mar 2026 00:08:09 +0900 Subject: [PATCH 34/97] =?UTF-8?q?refactor:=20MonthlyCalendarViewModel?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=BA=90=EC=8B=9C=20=EC=B1=85=EC=9E=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20UseCase=20=EC=9C=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/MonthlyCalendarViewModel.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index b7ca9bf7..531ffc63 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -41,6 +41,8 @@ public final class MonthlyCalendarViewModel< // MARK: - Dependencies private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase + private let prefetchAdjacentMonthsUseCase: PrefetchAdjacentMonthsUseCase + private let invalidateMonthCacheUseCase: InvalidateMonthCacheUseCase private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase private let getNicknameUseCase: GetNicknameUseCase @@ -49,11 +51,15 @@ public final class MonthlyCalendarViewModel< public init( fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, + prefetchAdjacentMonthsUseCase: PrefetchAdjacentMonthsUseCase, + invalidateMonthCacheUseCase: InvalidateMonthCacheUseCase, requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, fetchFoodRecordsUseCase: FetchFoodRecordsUseCase, getNicknameUseCase: GetNicknameUseCase ) { self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase + self.prefetchAdjacentMonthsUseCase = prefetchAdjacentMonthsUseCase + self.invalidateMonthCacheUseCase = invalidateMonthCacheUseCase self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase self.fetchFoodRecordsUseCase = fetchFoodRecordsUseCase self.getNicknameUseCase = getNicknameUseCase @@ -116,6 +122,7 @@ public final class MonthlyCalendarViewModel< } case .refreshCurrentMonth: + invalidateMonthCacheUseCase.execute(for: state.currentDisplayDate) await loadMonth(for: state.currentDisplayDate) case .updateMonth(let date): @@ -137,14 +144,16 @@ public final class MonthlyCalendarViewModel< state.monthYearText = date.formatMonthText() let period = Calendar.current.monthlyCalendarPeriod(for: date) - do { let monthDays = try await fetchMonthlyCalendarDaysUseCase.execute(for: period, currentMonth: date) state.monthDays = monthDays state.numberOfWeeks = monthDays.count / 7 } catch { print("Failed to load monthly calendar: \(error)") + return } + + prefetchAdjacentMonthsUseCase.execute(for: date) } private static func generatePlaceholderDays( From 5631b928a61aa88f8d8f59d87962ccac5c5da999 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 15 Mar 2026 00:08:12 +0900 Subject: [PATCH 35/97] =?UTF-8?q?chore:=20PrefetchAdjacentMonthsUseCase,?= =?UTF-8?q?=20InvalidateMonthCacheUseCase=20DI=20=EB=93=B1=EB=A1=9D=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 --- FoodDiary/App/Sources/SceneDelegate.swift | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 4f95c449..dafde7a3 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -285,6 +285,34 @@ extension SceneDelegate { return FetchMonthlyCalendarDaysUseCase(repository: repository) } + container.register( + PrefetchAdjacentMonthsUseCase< + FoodRecordRepositoryImpl> + >.self + ) { resolver in + guard + let repository = resolver.resolve( + FoodRecordRepositoryImpl>.self) + else { + fatalError("FoodRecordRepositoryImpl not registered") + } + return PrefetchAdjacentMonthsUseCase(repository: repository) + } + + container.register( + InvalidateMonthCacheUseCase< + FoodRecordRepositoryImpl> + >.self + ) { resolver in + guard + let repository = resolver.resolve( + FoodRecordRepositoryImpl>.self) + else { + fatalError("FoodRecordRepositoryImpl not registered") + } + return InvalidateMonthCacheUseCase(repository: repository) + } + container.register( FetchFoodRecordsUseCase< FoodRecordRepositoryImpl> @@ -620,6 +648,16 @@ extension SceneDelegate { FoodRecordRepositoryImpl> >.self ), + let prefetchAdjacentMonthsUseCase = resolver.resolve( + PrefetchAdjacentMonthsUseCase< + FoodRecordRepositoryImpl> + >.self + ), + let invalidateMonthCacheUseCase = resolver.resolve( + InvalidateMonthCacheUseCase< + FoodRecordRepositoryImpl> + >.self + ), let requestPhotoAuthUseCase = resolver.resolve( RequestPhotoAuthorizationUseCase.self ), @@ -635,6 +673,8 @@ extension SceneDelegate { return MonthlyCalendarViewModel( fetchMonthlyCalendarDaysUseCase: fetchMonthlyUseCase, + prefetchAdjacentMonthsUseCase: prefetchAdjacentMonthsUseCase, + invalidateMonthCacheUseCase: invalidateMonthCacheUseCase, requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, fetchFoodRecordsUseCase: fetchFoodRecordsUseCase, getNicknameUseCase: getNicknameUseCase From 415e88f3ea2a917739c32564ef16e17a72cda7e4 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 18 Mar 2026 21:56:13 +0900 Subject: [PATCH 36/97] =?UTF-8?q?refactor:=20PrefetchUseCase=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20FetchMonthlyCalendarDaysUseCase=20?= =?UTF-8?q?=EC=97=90=20Prefetch=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/SceneDelegate.swift | 20 ----------- .../FetchMonthlyCalendarDaysUseCase.swift | 22 ++++++++++++ .../PrefetchAdjacentMonthsUseCase.swift | 34 ------------------- .../ViewModel/MonthlyCalendarViewModel.swift | 4 --- 4 files changed, 22 insertions(+), 58 deletions(-) delete mode 100644 FoodDiary/Domain/Sources/UseCase/PrefetchAdjacentMonthsUseCase.swift diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index dafde7a3..b3b56a7a 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -285,20 +285,6 @@ extension SceneDelegate { return FetchMonthlyCalendarDaysUseCase(repository: repository) } - container.register( - PrefetchAdjacentMonthsUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { - fatalError("FoodRecordRepositoryImpl not registered") - } - return PrefetchAdjacentMonthsUseCase(repository: repository) - } - container.register( InvalidateMonthCacheUseCase< FoodRecordRepositoryImpl> @@ -648,11 +634,6 @@ extension SceneDelegate { FoodRecordRepositoryImpl> >.self ), - let prefetchAdjacentMonthsUseCase = resolver.resolve( - PrefetchAdjacentMonthsUseCase< - FoodRecordRepositoryImpl> - >.self - ), let invalidateMonthCacheUseCase = resolver.resolve( InvalidateMonthCacheUseCase< FoodRecordRepositoryImpl> @@ -673,7 +654,6 @@ extension SceneDelegate { return MonthlyCalendarViewModel( fetchMonthlyCalendarDaysUseCase: fetchMonthlyUseCase, - prefetchAdjacentMonthsUseCase: prefetchAdjacentMonthsUseCase, invalidateMonthCacheUseCase: invalidateMonthCacheUseCase, requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, fetchFoodRecordsUseCase: fetchFoodRecordsUseCase, diff --git a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift index 80fca9ec..d74bdb65 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift @@ -17,6 +17,8 @@ public struct FetchMonthlyCalendarDaysUseCase: let calendar = Calendar.seoul let recordsByDate = try await repository.fetchPhotoURLs(in: period.start...period.end) + prefetchAdjacentMonths(for: currentMonth) + // 캘린더 날짜 배열 생성 (records 포함) return generateCalendarDays( for: period, @@ -26,6 +28,26 @@ public struct FetchMonthlyCalendarDaysUseCase: ) } + private func prefetchAdjacentMonths(for date: Date) { + let calendar = Calendar.current + let neighbors = [-1, 1].compactMap { calendar.date(byAdding: .month, value: $0, to: date) } + for neighbor in neighbors { + Task { await prefetch(neighbor) } + } + } + + private func prefetch(_ date: Date) async { + let label = date.formatMonthText() + let period = Calendar.current.monthlyCalendarPeriod(for: date) + print("[Prefetch] 시작: \(label)") + do { + let urls = try await repository.fetchPhotoURLs(in: period.start...period.end) + print("[Prefetch] 완료: \(label) — \(urls.count)개 URL") + } catch { + print("[Prefetch] 실패: \(label) — \(error)") + } + } + // MARK: - Private Methods private func generateCalendarDays( diff --git a/FoodDiary/Domain/Sources/UseCase/PrefetchAdjacentMonthsUseCase.swift b/FoodDiary/Domain/Sources/UseCase/PrefetchAdjacentMonthsUseCase.swift deleted file mode 100644 index 34703c66..00000000 --- a/FoodDiary/Domain/Sources/UseCase/PrefetchAdjacentMonthsUseCase.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// PrefetchAdjacentMonthsUseCase.swift -// Domain -// - -import Foundation - -/// 인접 월 데이터 프리패치 UseCase -public struct PrefetchAdjacentMonthsUseCase: Sendable { - private let repository: Repository - - public init(repository: Repository) { - self.repository = repository - } - - public func execute(for date: Date) { - let calendar = Calendar.current - let neighbors = [-1, 1].compactMap { calendar.date(byAdding: .month, value: $0, to: date) } - for neighbor in neighbors { - Task { await prefetch(neighbor) } - } - } - - // MARK: - Private Methods - - private func prefetch(_ date: Date) async { - let period = Calendar.current.monthlyCalendarPeriod(for: date) - do { - _ = try await repository.fetchPhotoURLs(in: period.start...period.end) - } catch { - print("Failed to prefetch month \(date): \(error)") - } - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index 531ffc63..d9898fd5 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -41,7 +41,6 @@ public final class MonthlyCalendarViewModel< // MARK: - Dependencies private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase - private let prefetchAdjacentMonthsUseCase: PrefetchAdjacentMonthsUseCase private let invalidateMonthCacheUseCase: InvalidateMonthCacheUseCase private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase @@ -51,14 +50,12 @@ public final class MonthlyCalendarViewModel< public init( fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, - prefetchAdjacentMonthsUseCase: PrefetchAdjacentMonthsUseCase, invalidateMonthCacheUseCase: InvalidateMonthCacheUseCase, requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, fetchFoodRecordsUseCase: FetchFoodRecordsUseCase, getNicknameUseCase: GetNicknameUseCase ) { self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase - self.prefetchAdjacentMonthsUseCase = prefetchAdjacentMonthsUseCase self.invalidateMonthCacheUseCase = invalidateMonthCacheUseCase self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase self.fetchFoodRecordsUseCase = fetchFoodRecordsUseCase @@ -153,7 +150,6 @@ public final class MonthlyCalendarViewModel< return } - prefetchAdjacentMonthsUseCase.execute(for: date) } private static func generatePlaceholderDays( From 190602d2076af3dc45d24f2ca2fea11b1167ea9b Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 24 Mar 2026 00:32:01 +0900 Subject: [PATCH 37/97] =?UTF-8?q?refactor:=20PhotoURLCache=EB=A5=BC=20NSLo?= =?UTF-8?q?ck=20=EA=B8=B0=EB=B0=98=EC=97=90=EC=84=9C=20actor=20LRU=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20=EB=B0=8F=20?= =?UTF-8?q?SWR=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PhotoURLCache를 NSLock 제거 후 actor 기반 LRU 캐시(최대 12개)로 교체 - fetchPhotoURLs를 AsyncThrowingStream으로 변경하여 stale-while-revalidate 패턴 적용 - 캐시 HIT 시 즉시 방출 후 서버 검증, 데이터 변경 시 재방출 - 캐시 MISS 시 서버 응답만 방출 - FetchMonthlyCalendarDaysUseCase도 AsyncThrowingStream 반환으로 변경 - InvalidateMonthCacheUseCase 및 invalidatePhotoURLCache 제거 (LRU 자동 evict로 대체) - PrefetchAdjacentMonthsUseCase 제거 및 FetchMonthlyCalendarDaysUseCase 내부로 통합 - MockFoodRecordRepository 시그니처 업데이트 --- FoodDiary/App/Sources/SceneDelegate.swift | 20 ---- .../Repository/FoodRecordRepositoryImpl.swift | 109 +++++++++++------- .../Repository/FoodRecordRepository.swift | 11 +- .../FetchMonthlyCalendarDaysUseCase.swift | 40 ++++--- .../UseCase/InvalidateMonthCacheUseCase.swift | 20 ---- .../Tests/Mock/MockFoodRecordRepository.swift | 15 ++- .../ViewModel/MonthlyCalendarViewModel.swift | 13 +-- 7 files changed, 112 insertions(+), 116 deletions(-) delete mode 100644 FoodDiary/Domain/Sources/UseCase/InvalidateMonthCacheUseCase.swift diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index b3b56a7a..4f95c449 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -285,20 +285,6 @@ extension SceneDelegate { return FetchMonthlyCalendarDaysUseCase(repository: repository) } - container.register( - InvalidateMonthCacheUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { - fatalError("FoodRecordRepositoryImpl not registered") - } - return InvalidateMonthCacheUseCase(repository: repository) - } - container.register( FetchFoodRecordsUseCase< FoodRecordRepositoryImpl> @@ -634,11 +620,6 @@ extension SceneDelegate { FoodRecordRepositoryImpl> >.self ), - let invalidateMonthCacheUseCase = resolver.resolve( - InvalidateMonthCacheUseCase< - FoodRecordRepositoryImpl> - >.self - ), let requestPhotoAuthUseCase = resolver.resolve( RequestPhotoAuthorizationUseCase.self ), @@ -654,7 +635,6 @@ extension SceneDelegate { return MonthlyCalendarViewModel( fetchMonthlyCalendarDaysUseCase: fetchMonthlyUseCase, - invalidateMonthCacheUseCase: invalidateMonthCacheUseCase, requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, fetchFoodRecordsUseCase: fetchFoodRecordsUseCase, getNicknameUseCase: getNicknameUseCase diff --git a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift index db0a344a..4e7aa584 100644 --- a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift @@ -8,28 +8,36 @@ import Foundation import Photos import UIKit -// MARK: - In-memory Cache +// MARK: - In-memory LRU Cache -private final class PhotoURLCache: @unchecked Sendable { - private var storage: [String: [Date: [URL]]] = [:] - private let lock = NSLock() +private actor PhotoURLCache { + private var entries: [(key: String, value: [Date: [URL]])] = [] + private let capacity = 12 func get(for dateRange: ClosedRange) -> [Date: [URL]]? { - lock.lock(); defer { lock.unlock() } - return storage[key(for: dateRange)] + let k = cacheKey(for: dateRange) + guard let index = entries.firstIndex(where: { $0.key == k }) else { return nil } + let entry = entries.remove(at: index) + entries.insert(entry, at: 0) + return entry.value } func set(_ value: [Date: [URL]], for dateRange: ClosedRange) { - lock.lock(); defer { lock.unlock() } - storage[key(for: dateRange)] = value + let k = cacheKey(for: dateRange) + entries.removeAll { $0.key == k } + entries.insert((key: k, value: value), at: 0) + if entries.count > capacity { + let evicted = entries.removeLast() + print("[PhotoURLCache] 캐시 용량 초과 — 삭제: \(evicted.key)") + } } func remove(for dateRange: ClosedRange) { - lock.lock(); defer { lock.unlock() } - storage.removeValue(forKey: key(for: dateRange)) + let k = cacheKey(for: dateRange) + entries.removeAll { $0.key == k } } - private func key(for dateRange: ClosedRange) -> String { + private func cacheKey(for dateRange: ClosedRange) -> String { "\(Int(dateRange.lowerBound.timeIntervalSince1970))-\(Int(dateRange.upperBound.timeIntervalSince1970))" } } @@ -124,46 +132,59 @@ public struct FoodRecordRepositoryImpl< return records[startOfDay] ?? [] } - public func fetchPhotoURLs(in dateRange: ClosedRange) async throws -> [Date: [URL]] { - if let cached = photoURLCache.get(for: dateRange) { - print("[FoodRecordRepository] Cache HIT: \(formatDate(dateRange.lowerBound)) ~ \(formatDate(dateRange.upperBound))") - return cached - } - print("[FoodRecordRepository] Cache MISS: \(formatDate(dateRange.lowerBound)) ~ \(formatDate(dateRange.upperBound)) — fetching...") - - guard let accessToken = tokenStorage.get() else { - throw FoodRecordError.noAccessToken - } + public func fetchPhotoURLs(in dateRange: ClosedRange) -> AsyncThrowingStream<[Date: [URL]], Error> { + AsyncThrowingStream { continuation in + Task { + let rangeLabel = "\(formatDate(dateRange.lowerBound)) ~ \(formatDate(dateRange.upperBound))" + + // 1. 캐시 HIT 시 즉시 방출 + if let cached = await photoURLCache.get(for: dateRange) { + print("[FoodRecordRepository] Cache HIT: \(rangeLabel)") + continuation.yield(cached) + } else { + print("[FoodRecordRepository] Cache MISS: \(rangeLabel) — fetching...") + } - let startDateString = formatDate(dateRange.lowerBound) - let endDateString = formatDate(dateRange.upperBound) + // 2. 항상 서버 검증 + do { + guard let accessToken = tokenStorage.get() else { + throw FoodRecordError.noAccessToken + } - let endpoint = DiaryEndpoint.byDateRangeSummary( - startDate: startDateString, - endDate: endDateString, - testMode: testMode - ) + let endpoint = DiaryEndpoint.byDateRangeSummary( + startDate: formatDate(dateRange.lowerBound), + endDate: formatDate(dateRange.upperBound), + testMode: testMode + ) - let response: DiariesByDateRangeSummaryResponseDTO = try await httpClient.request( - endpoint, - accessToken: accessToken - ) + let response: DiariesByDateRangeSummaryResponseDTO = try await httpClient.request( + endpoint, + accessToken: accessToken + ) - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" - let result = response.reduce(into: [Date: [URL]]()) { result, entry in - guard let date = formatter.date(from: entry.key) else { return } - result[date] = entry.value.photos.compactMap { URL(string: $0.url) } - } + let fresh = response.reduce(into: [Date: [URL]]()) { result, entry in + guard let date = formatter.date(from: entry.key) else { return } + result[date] = entry.value.photos.compactMap { URL(string: $0.url) } + } - photoURLCache.set(result, for: dateRange) - print("[FoodRecordRepository] Cache STORED: \(formatDate(dateRange.lowerBound)) ~ \(formatDate(dateRange.upperBound))") - return result - } + let current = await photoURLCache.get(for: dateRange) + if current != fresh { + await photoURLCache.set(fresh, for: dateRange) + print("[FoodRecordRepository] Cache UPDATED: \(rangeLabel)") + continuation.yield(fresh) + } else { + print("[FoodRecordRepository] Cache VALID: \(rangeLabel)") + } - public func invalidatePhotoURLCache(in dateRange: ClosedRange) { - photoURLCache.remove(for: dateRange) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } } // MARK: - 수정/삭제 API diff --git a/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift b/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift index 29c30f26..2d833abc 100644 --- a/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift +++ b/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift @@ -7,10 +7,11 @@ import Foundation /// 음식 기록 Repository 프로토콜 (서버 API) public protocol FoodRecordRepository: Sendable { - /// 특정 날짜 범위 내의 사진 URL 조회 + /// 특정 날짜 범위 내의 사진 URL 조회 (stale-while-revalidate) + /// - 캐시가 있으면 즉시 방출 후 서버 검증, 데이터가 다를 경우 갱신 값 재방출 + /// - 캐시가 없으면 서버 응답만 방출 /// - Parameter dateRange: 조회할 날짜 범위 - /// - Returns: 날짜별 기록된 이미지 URL - func fetchPhotoURLs(in dateRange: ClosedRange) async throws -> [Date: [URL]] + func fetchPhotoURLs(in dateRange: ClosedRange) -> AsyncThrowingStream<[Date: [URL]], Error> /// 특정 날짜 범위 내의 기록 조회 /// - Parameter dateRange: 조회할 날짜 범위 @@ -35,8 +36,4 @@ public protocol FoodRecordRepository: Sendable { /// 음식 기록 삭제 /// - Parameter id: 삭제할 기록 ID func deleteRecord(id: String) async throws - - /// 특정 날짜 범위의 사진 URL 캐시 무효화 - /// - Parameter dateRange: 무효화할 날짜 범위 - func invalidatePhotoURLCache(in dateRange: ClosedRange) } diff --git a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift index d74bdb65..eb9d7495 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift @@ -13,19 +13,26 @@ public struct FetchMonthlyCalendarDaysUseCase: self.repository = repository } - public func execute(for period: DateInterval, currentMonth: Date) async throws -> [MonthlyCalendarDay] { - let calendar = Calendar.seoul - let recordsByDate = try await repository.fetchPhotoURLs(in: period.start...period.end) - - prefetchAdjacentMonths(for: currentMonth) - - // 캘린더 날짜 배열 생성 (records 포함) - return generateCalendarDays( - for: period, - currentMonth: currentMonth, - calendar: calendar, - recordsByDate: recordsByDate - ) + public func execute(for period: DateInterval, currentMonth: Date) -> AsyncThrowingStream<[MonthlyCalendarDay], Error> { + AsyncThrowingStream { continuation in + Task { + do { + for try await recordsByDate in repository.fetchPhotoURLs(in: period.start...period.end) { + let days = generateCalendarDays( + for: period, + currentMonth: currentMonth, + calendar: .seoul, + recordsByDate: recordsByDate + ) + continuation.yield(days) + } + prefetchAdjacentMonths(for: currentMonth) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } } private func prefetchAdjacentMonths(for date: Date) { @@ -41,8 +48,11 @@ public struct FetchMonthlyCalendarDaysUseCase: let period = Calendar.current.monthlyCalendarPeriod(for: date) print("[Prefetch] 시작: \(label)") do { - let urls = try await repository.fetchPhotoURLs(in: period.start...period.end) - print("[Prefetch] 완료: \(label) — \(urls.count)개 URL") + var urlCount = 0 + for try await recordsByDate in repository.fetchPhotoURLs(in: period.start...period.end) { + urlCount = recordsByDate.values.reduce(0) { $0 + $1.count } + } + print("[Prefetch] 완료: \(label) — \(urlCount)개 URL") } catch { print("[Prefetch] 실패: \(label) — \(error)") } diff --git a/FoodDiary/Domain/Sources/UseCase/InvalidateMonthCacheUseCase.swift b/FoodDiary/Domain/Sources/UseCase/InvalidateMonthCacheUseCase.swift deleted file mode 100644 index 699dbb55..00000000 --- a/FoodDiary/Domain/Sources/UseCase/InvalidateMonthCacheUseCase.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// InvalidateMonthCacheUseCase.swift -// Domain -// - -import Foundation - -/// 월간 캘린더 캐시 무효화 UseCase -public struct InvalidateMonthCacheUseCase: Sendable { - private let repository: Repository - - public init(repository: Repository) { - self.repository = repository - } - - public func execute(for date: Date) { - let period = Calendar.current.monthlyCalendarPeriod(for: date) - repository.invalidatePhotoURLCache(in: period.start...period.end) - } -} diff --git a/FoodDiary/Domain/Tests/Mock/MockFoodRecordRepository.swift b/FoodDiary/Domain/Tests/Mock/MockFoodRecordRepository.swift index 4bbcbeb3..b3d7be45 100644 --- a/FoodDiary/Domain/Tests/Mock/MockFoodRecordRepository.swift +++ b/FoodDiary/Domain/Tests/Mock/MockFoodRecordRepository.swift @@ -14,8 +14,19 @@ final class MockFoodRecordRepository: FoodRecordRepository, @unchecked Sendable ] var shouldThrowError: Bool = false - func fetchPhotoURLs(in dateRange: ClosedRange) async throws -> [Date : [URL]] { - return [:] + var photoURLsToReturn: [Date: [URL]] = [:] + + func fetchPhotoURLs(in dateRange: ClosedRange) -> AsyncThrowingStream<[Date: [URL]], Error> { + let result = photoURLsToReturn + let shouldThrow = shouldThrowError + return AsyncThrowingStream { continuation in + if shouldThrow { + continuation.finish(throwing: MockError.testError) + } else { + continuation.yield(result) + continuation.finish() + } + } } func fetchRecords(in dateRange: ClosedRange) async throws -> [Date: [FoodRecord]] { diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index d9898fd5..8d765b5a 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -41,7 +41,6 @@ public final class MonthlyCalendarViewModel< // MARK: - Dependencies private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase - private let invalidateMonthCacheUseCase: InvalidateMonthCacheUseCase private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase private let getNicknameUseCase: GetNicknameUseCase @@ -50,13 +49,11 @@ public final class MonthlyCalendarViewModel< public init( fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, - invalidateMonthCacheUseCase: InvalidateMonthCacheUseCase, requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, fetchFoodRecordsUseCase: FetchFoodRecordsUseCase, getNicknameUseCase: GetNicknameUseCase ) { self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase - self.invalidateMonthCacheUseCase = invalidateMonthCacheUseCase self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase self.fetchFoodRecordsUseCase = fetchFoodRecordsUseCase self.getNicknameUseCase = getNicknameUseCase @@ -119,7 +116,6 @@ public final class MonthlyCalendarViewModel< } case .refreshCurrentMonth: - invalidateMonthCacheUseCase.execute(for: state.currentDisplayDate) await loadMonth(for: state.currentDisplayDate) case .updateMonth(let date): @@ -141,13 +137,14 @@ public final class MonthlyCalendarViewModel< state.monthYearText = date.formatMonthText() let period = Calendar.current.monthlyCalendarPeriod(for: date) + do { - let monthDays = try await fetchMonthlyCalendarDaysUseCase.execute(for: period, currentMonth: date) - state.monthDays = monthDays - state.numberOfWeeks = monthDays.count / 7 + for try await monthDays in fetchMonthlyCalendarDaysUseCase.execute(for: period, currentMonth: date) { + state.monthDays = monthDays + state.numberOfWeeks = monthDays.count / 7 + } } catch { print("Failed to load monthly calendar: \(error)") - return } } From 7f97c014691eca57a7392fc87bb8e9786f871276 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 25 Mar 2026 20:35:47 +0900 Subject: [PATCH 38/97] =?UTF-8?q?chore:=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Sources/Cache/PhotoURLCache.swift | 37 ++++++++++++++++ .../Repository/FoodRecordRepositoryImpl.swift | 42 ------------------- .../FetchMonthlyCalendarDaysUseCase.swift | 12 +----- 3 files changed, 39 insertions(+), 52 deletions(-) create mode 100644 FoodDiary/Data/Sources/Cache/PhotoURLCache.swift diff --git a/FoodDiary/Data/Sources/Cache/PhotoURLCache.swift b/FoodDiary/Data/Sources/Cache/PhotoURLCache.swift new file mode 100644 index 00000000..205bc36b --- /dev/null +++ b/FoodDiary/Data/Sources/Cache/PhotoURLCache.swift @@ -0,0 +1,37 @@ +// +// PhotoURLCache.swift +// Data +// + +import Foundation + +actor PhotoURLCache { + private var entries: [(key: String, value: [Date: [URL]])] = [] + private let capacity = 12 + + func get(for dateRange: ClosedRange) -> [Date: [URL]]? { + let k = cacheKey(for: dateRange) + guard let index = entries.firstIndex(where: { $0.key == k }) else { return nil } + let entry = entries.remove(at: index) + entries.insert(entry, at: 0) + return entry.value + } + + func set(_ value: [Date: [URL]], for dateRange: ClosedRange) { + let k = cacheKey(for: dateRange) + entries.removeAll { $0.key == k } + entries.insert((key: k, value: value), at: 0) + if entries.count > capacity { + entries.removeLast() + } + } + + func remove(for dateRange: ClosedRange) { + let k = cacheKey(for: dateRange) + entries.removeAll { $0.key == k } + } + + private func cacheKey(for dateRange: ClosedRange) -> String { + "\(Int(dateRange.lowerBound.timeIntervalSince1970))-\(Int(dateRange.upperBound.timeIntervalSince1970))" + } +} diff --git a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift index 4e7aa584..35202819 100644 --- a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift @@ -8,40 +8,6 @@ import Foundation import Photos import UIKit -// MARK: - In-memory LRU Cache - -private actor PhotoURLCache { - private var entries: [(key: String, value: [Date: [URL]])] = [] - private let capacity = 12 - - func get(for dateRange: ClosedRange) -> [Date: [URL]]? { - let k = cacheKey(for: dateRange) - guard let index = entries.firstIndex(where: { $0.key == k }) else { return nil } - let entry = entries.remove(at: index) - entries.insert(entry, at: 0) - return entry.value - } - - func set(_ value: [Date: [URL]], for dateRange: ClosedRange) { - let k = cacheKey(for: dateRange) - entries.removeAll { $0.key == k } - entries.insert((key: k, value: value), at: 0) - if entries.count > capacity { - let evicted = entries.removeLast() - print("[PhotoURLCache] 캐시 용량 초과 — 삭제: \(evicted.key)") - } - } - - func remove(for dateRange: ClosedRange) { - let k = cacheKey(for: dateRange) - entries.removeAll { $0.key == k } - } - - private func cacheKey(for dateRange: ClosedRange) -> String { - "\(Int(dateRange.lowerBound.timeIntervalSince1970))-\(Int(dateRange.upperBound.timeIntervalSince1970))" - } -} - /// FoodRecordRepository 구현체 public struct FoodRecordRepositoryImpl< Client: HTTPClienting & Sendable, @@ -135,14 +101,9 @@ public struct FoodRecordRepositoryImpl< public func fetchPhotoURLs(in dateRange: ClosedRange) -> AsyncThrowingStream<[Date: [URL]], Error> { AsyncThrowingStream { continuation in Task { - let rangeLabel = "\(formatDate(dateRange.lowerBound)) ~ \(formatDate(dateRange.upperBound))" - // 1. 캐시 HIT 시 즉시 방출 if let cached = await photoURLCache.get(for: dateRange) { - print("[FoodRecordRepository] Cache HIT: \(rangeLabel)") continuation.yield(cached) - } else { - print("[FoodRecordRepository] Cache MISS: \(rangeLabel) — fetching...") } // 2. 항상 서버 검증 @@ -173,10 +134,7 @@ public struct FoodRecordRepositoryImpl< let current = await photoURLCache.get(for: dateRange) if current != fresh { await photoURLCache.set(fresh, for: dateRange) - print("[FoodRecordRepository] Cache UPDATED: \(rangeLabel)") continuation.yield(fresh) - } else { - print("[FoodRecordRepository] Cache VALID: \(rangeLabel)") } continuation.finish() diff --git a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift index eb9d7495..ccfa3958 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift @@ -44,18 +44,10 @@ public struct FetchMonthlyCalendarDaysUseCase: } private func prefetch(_ date: Date) async { - let label = date.formatMonthText() let period = Calendar.current.monthlyCalendarPeriod(for: date) - print("[Prefetch] 시작: \(label)") do { - var urlCount = 0 - for try await recordsByDate in repository.fetchPhotoURLs(in: period.start...period.end) { - urlCount = recordsByDate.values.reduce(0) { $0 + $1.count } - } - print("[Prefetch] 완료: \(label) — \(urlCount)개 URL") - } catch { - print("[Prefetch] 실패: \(label) — \(error)") - } + for try await _ in repository.fetchPhotoURLs(in: period.start...period.end) {} + } catch {} } // MARK: - Private Methods From 191e650f89ce6a73d7d4ddda1473ec6ab88f25ab Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 28 Mar 2026 20:28:32 +0900 Subject: [PATCH 39/97] =?UTF-8?q?refactor:=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/MonthlyCalendarViewModel.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index 8d765b5a..97e274ca 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -37,6 +37,7 @@ public final class MonthlyCalendarViewModel< private let stateSubject: CurrentValueSubject private let eventSubject = PassthroughSubject() private var cancellables = Set() + @MainActor private var currentLoadTask: Task? // MARK: - Dependencies @@ -95,7 +96,7 @@ public final class MonthlyCalendarViewModel< case .loadInitialData: await requestPhotoAuthorizationIfNeeded() - await loadMonth(for: state.currentDisplayDate) + startLoadMonth(for: state.currentDisplayDate) case .selectMonth(let date): let calendar = Calendar.current @@ -105,7 +106,7 @@ public final class MonthlyCalendarViewModel< let todayYearMonth = calendar.date(from: todayComponents), newYearMonth <= todayYearMonth else { return } state.currentDisplayDate = date - await loadMonth(for: state.currentDisplayDate) + startLoadMonth(for: state.currentDisplayDate) case .selectDay(let date): do { @@ -116,21 +117,30 @@ public final class MonthlyCalendarViewModel< } case .refreshCurrentMonth: - await loadMonth(for: state.currentDisplayDate) + startLoadMonth(for: state.currentDisplayDate) case .updateMonth(let date): let calendar = Calendar.current if calendar.component(.year, from: date) != calendar.component(.year, from: state.currentDisplayDate) || calendar.component(.month, from: date) != calendar.component(.month, from: state.currentDisplayDate) { - await loadMonth(for: date) + startLoadMonth(for: date) } else { - await loadMonth(for: state.currentDisplayDate) + startLoadMonth(for: state.currentDisplayDate) } } } // MARK: - Private Methods + @MainActor + private func startLoadMonth(for date: Date) { + currentLoadTask?.cancel() + currentLoadTask = nil + currentLoadTask = Task { + await loadMonth(for: date) + } + } + @MainActor private func loadMonth(for date: Date) async { state.currentDisplayDate = date @@ -140,13 +150,15 @@ public final class MonthlyCalendarViewModel< do { for try await monthDays in fetchMonthlyCalendarDaysUseCase.execute(for: period, currentMonth: date) { + try await Task.sleep(for: .seconds(1)) state.monthDays = monthDays state.numberOfWeeks = monthDays.count / 7 } + } catch is CancellationError { + print("Task Cancelled") } catch { print("Failed to load monthly calendar: \(error)") } - } private static func generatePlaceholderDays( From bc4bc34d975569728bce7fdc9c479a0f8245a4b4 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 28 Mar 2026 20:29:48 +0900 Subject: [PATCH 40/97] =?UTF-8?q?chore:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift index ccfa3958..e303c5fa 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift @@ -21,7 +21,7 @@ public struct FetchMonthlyCalendarDaysUseCase: let days = generateCalendarDays( for: period, currentMonth: currentMonth, - calendar: .seoul, + calendar: .current, recordsByDate: recordsByDate ) continuation.yield(days) From cfedd786daca28e9ce5482145498d76d9ce6f708 Mon Sep 17 00:00:00 2001 From: KangKang <165460077+kanghun1121@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:12:53 +0900 Subject: [PATCH 41/97] =?UTF-8?q?TestFlight=20CI=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated code signing identity from 'iPhone Distribution' to 'APPLE Distribution'. --- .github/workflows/deploy-testflight.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy-testflight.yml b/.github/workflows/deploy-testflight.yml index e09687ef..21fa10b3 100644 --- a/.github/workflows/deploy-testflight.yml +++ b/.github/workflows/deploy-testflight.yml @@ -165,8 +165,7 @@ jobs: CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM=W35T2QAC67 \ - CODE_SIGN_IDENTITY="iPhone Distribution" \ - OTHER_CODE_SIGN_FLAGS="--keychain ${{ env.KEYCHAIN_PATH }}" \ + CODE_SIGN_IDENTITY="APPLE Distribution" \ 2>&1 | tee "$RUNNER_TEMP/archive.log" | xcpretty --color # 아카이브에서 버전 추출 From f380ddbdafc7849cc7368e422d2586be491e691b Mon Sep 17 00:00:00 2001 From: KangKang <165460077+kanghun1121@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:25:24 +0900 Subject: [PATCH 42/97] =?UTF-8?q?chore:=20archive=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-testflight.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-testflight.yml b/.github/workflows/deploy-testflight.yml index 21fa10b3..a2ada123 100644 --- a/.github/workflows/deploy-testflight.yml +++ b/.github/workflows/deploy-testflight.yml @@ -151,10 +151,17 @@ jobs: - name: Archive run: | set -o pipefail + BUILD_NUMBER=$(date -u +'%y%m%d')${{ github.run_number }} + MARKETING_VERSION=$(grep 'MARKETING_VERSION' FoodDiary/App/Project.swift | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ -z "$MARKETING_VERSION" ]; then + echo "::error::MARKETING_VERSION 추출 실패" + exit 1 + fi + ARCHIVE_PATH=$RUNNER_TEMP/App.xcarchive - + xcodebuild archive \ -workspace Workspace.xcworkspace \ -scheme release \ @@ -165,12 +172,17 @@ jobs: CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM=W35T2QAC67 \ - CODE_SIGN_IDENTITY="APPLE Distribution" \ + CODE_SIGN_IDENTITY="Apple Distribution" \ + PROVISIONING_PROFILE_SPECIFIER="FoodDiary-Distribution" \ 2>&1 | tee "$RUNNER_TEMP/archive.log" | xcpretty --color - - # 아카이브에서 버전 추출 + exit ${PIPESTATUS[0]} + VERSION=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleShortVersionString" "$ARCHIVE_PATH/Info.plist") - + if [ -z "$VERSION" ]; then + echo "::error::VERSION 추출 실패" + exit 1 + fi + echo "ARCHIVE_PATH=$ARCHIVE_PATH" >> $GITHUB_ENV echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV echo "VERSION=$VERSION" >> $GITHUB_ENV From b63e3e34eadf5781b4c47b85127a6191ea1bdc66 Mon Sep 17 00:00:00 2001 From: enebin Date: Wed, 1 Apr 2026 22:55:53 +0900 Subject: [PATCH 43/97] =?UTF-8?q?fix:=20PROVISIONING=5FPROFILE=5FSPECIFIER?= =?UTF-8?q?=20=EC=A0=84=EC=97=AD=20=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20Archive=20=EC=8B=A4=ED=8C=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-testflight.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-testflight.yml b/.github/workflows/deploy-testflight.yml index a2ada123..a953ef11 100644 --- a/.github/workflows/deploy-testflight.yml +++ b/.github/workflows/deploy-testflight.yml @@ -172,17 +172,16 @@ jobs: CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM=W35T2QAC67 \ - CODE_SIGN_IDENTITY="Apple Distribution" \ - PROVISIONING_PROFILE_SPECIFIER="FoodDiary-Distribution" \ + CODE_SIGN_IDENTITY="iPhone Distribution" \ + OTHER_CODE_SIGN_FLAGS="--keychain ${{ env.KEYCHAIN_PATH }}" \ 2>&1 | tee "$RUNNER_TEMP/archive.log" | xcpretty --color - exit ${PIPESTATUS[0]} - + VERSION=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleShortVersionString" "$ARCHIVE_PATH/Info.plist") if [ -z "$VERSION" ]; then echo "::error::VERSION 추출 실패" exit 1 fi - + echo "ARCHIVE_PATH=$ARCHIVE_PATH" >> $GITHUB_ENV echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV echo "VERSION=$VERSION" >> $GITHUB_ENV From aef396d005b3fe3253720b584247996ee4eb3b1e Mon Sep 17 00:00:00 2001 From: enebin Date: Wed, 1 Apr 2026 23:02:42 +0900 Subject: [PATCH 44/97] =?UTF-8?q?fix:=20App=20=ED=83=80=EA=B2=9F=20provisi?= =?UTF-8?q?oning=20profile=EC=9D=84=20xcconfig=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-testflight.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-testflight.yml b/.github/workflows/deploy-testflight.yml index a953ef11..3661cb46 100644 --- a/.github/workflows/deploy-testflight.yml +++ b/.github/workflows/deploy-testflight.yml @@ -113,7 +113,9 @@ jobs: RELEASE_XCCONFIG: ${{ secrets.RELEASE_XCCONFIG }} run: | mkdir -p Configs - echo "$RELEASE_XCCONFIG" | grep -v -E 'CURRENT_PROJECT_VERSION|MARKETING_VERSION' > Configs/release.xcconfig + echo "$RELEASE_XCCONFIG" | grep -v -E 'CURRENT_PROJECT_VERSION|MARKETING_VERSION|PROVISIONING_PROFILE_SPECIFIER|CODE_SIGN_IDENTITY' > Configs/release.xcconfig + echo "PROVISIONING_PROFILE_SPECIFIER = FoodDiary-Distribution" >> Configs/release.xcconfig + echo "CODE_SIGN_IDENTITY = iPhone Distribution" >> Configs/release.xcconfig echo "// Debug Configuration (CI)" > Configs/debug.xcconfig - name: Create GoogleService-Info.plist From 6d1e53023be83b8239724288126407b32583e743 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:25 +0900 Subject: [PATCH 45/97] =?UTF-8?q?chore:=20.mcp.json=EC=9D=84=20.gitignore?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a4b940de..3d0546db 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ buildServer.json .cursor/rules/ .claude/ .codex/ +.mcp.json From d29c378bc8d82201ea6b7308014f35fef17d57ff Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:30 +0900 Subject: [PATCH 46/97] =?UTF-8?q?feat:=20Coordinator=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=EB=B3=84=20?= =?UTF-8?q?Coordinator=20=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 --- FoodDiary/App/Sources/AppCoordinator.swift | 101 ++++++++++++++++++ .../App/Sources/CalendarCoordinator.swift | 34 ++++++ FoodDiary/App/Sources/Coordinator.swift | 28 +++++ .../App/Sources/InsightCoordinator.swift | 26 +++++ FoodDiary/App/Sources/LoginCoordinator.swift | 26 +++++ FoodDiary/App/Sources/MainCoordinator.swift | 79 ++++++++++++++ FoodDiary/App/Sources/MainSceneProducer.swift | 95 ++++++++++++++++ FoodDiary/App/Sources/MyPageCoordinator.swift | 28 +++++ 8 files changed, 417 insertions(+) create mode 100644 FoodDiary/App/Sources/AppCoordinator.swift create mode 100644 FoodDiary/App/Sources/CalendarCoordinator.swift create mode 100644 FoodDiary/App/Sources/Coordinator.swift create mode 100644 FoodDiary/App/Sources/InsightCoordinator.swift create mode 100644 FoodDiary/App/Sources/LoginCoordinator.swift create mode 100644 FoodDiary/App/Sources/MainCoordinator.swift create mode 100644 FoodDiary/App/Sources/MainSceneProducer.swift create mode 100644 FoodDiary/App/Sources/MyPageCoordinator.swift diff --git a/FoodDiary/App/Sources/AppCoordinator.swift b/FoodDiary/App/Sources/AppCoordinator.swift new file mode 100644 index 00000000..d2ef07fe --- /dev/null +++ b/FoodDiary/App/Sources/AppCoordinator.swift @@ -0,0 +1,101 @@ +// +// AppCoordinator.swift +// App +// +// Created by 강대훈 on 4/8/26. +// + +import Combine +import DI +import Data +import Domain +import Presentation +import UIKit + +final class AppCoordinator: Coordinator { + var childCoordinators: [Coordinator] = [] + + weak var sceneTransitioner: SceneTransitioning? + private let container: DIContainer + private let sceneProducer: MainSceneProducer + + var currentNavigationController: UINavigationController? { + childCoordinators.compactMap { $0 as? MainCoordinator }.last?.navigationController + } + + init( + container: DIContainer, + sceneProducer: MainSceneProducer + ) { + self.container = container + self.sceneProducer = sceneProducer + } + + func start() {} +} + +// MARK: - Screen Routing + +extension AppCoordinator { + func pushLoginVC() { + let loginCoordinator = LoginCoordinator(sceneProducer: sceneProducer) + loginCoordinator.sceneTransitioner = sceneTransitioner + loginCoordinator.parentCoordinator = self + addChild(loginCoordinator) + + loginCoordinator.start() + } + + func pushMainVC() { + let mainCoordinator = MainCoordinator(sceneProducer: sceneProducer) + mainCoordinator.sceneTransitioner = sceneTransitioner + mainCoordinator.parentCoordinator = self + addChild(mainCoordinator) + + mainCoordinator.start() + } + + func navigateToDetail(diaryDateString: String) { + performDeepLinkNavigation(diaryDateString: diaryDateString) + } +} + +// MARK: - Deep Link Navigation + +extension AppCoordinator { + private static let deepLinkDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + private func performDeepLinkNavigation(diaryDateString: String) { + guard let date = Self.deepLinkDateFormatter.date(from: diaryDateString) else { + print("[DeepLink] 날짜 파싱 실패: \(diaryDateString)") + return + } + + guard let navController = currentNavigationController else { + print("[DeepLink] NavigationController를 찾을 수 없음") + return + } + + typealias DeepLinkDetailVM = DetailViewModel< + FoodRecordRepositoryImpl>, + PushNotificationObserver + > + guard let detailVM = try? container.resolve(DeepLinkDetailVM.self, argument: (date, [FoodRecord]())) else { + fatalError("DetailViewModel not registered") + } + let detailVC = DetailViewController(viewModel: detailVM) + + if let presented = navController.presentedViewController { + presented.dismiss(animated: false) { + navController.pushViewController(detailVC, animated: true) + } + } else { + navController.pushViewController(detailVC, animated: true) + } + } +} diff --git a/FoodDiary/App/Sources/CalendarCoordinator.swift b/FoodDiary/App/Sources/CalendarCoordinator.swift new file mode 100644 index 00000000..14052d94 --- /dev/null +++ b/FoodDiary/App/Sources/CalendarCoordinator.swift @@ -0,0 +1,34 @@ +// +// CalendarCoordinator.swift +// App +// +// Created by 강대훈 on 4/11/26. +// + +import Presentation +import UIKit + +final class CalendarCoordinator: Coordinator { + var childCoordinators: [Coordinator] = [] + weak var parentCoordinator: MainCoordinator? + + private let sceneProducer: MainSceneProducer + private(set) var navigationController: UINavigationController? + + init(sceneProducer: MainSceneProducer) { + self.sceneProducer = sceneProducer + } + + func start() {} + + func makeViewController() -> CalendarViewController { + CalendarViewController( + weeklyVC: sceneProducer.makeWeeklyCalendarVC(), + monthlyVC: sceneProducer.makeMonthlyCalendarVC() + ) + } + + func configure(navigationController: UINavigationController) { + self.navigationController = navigationController + } +} diff --git a/FoodDiary/App/Sources/Coordinator.swift b/FoodDiary/App/Sources/Coordinator.swift new file mode 100644 index 00000000..a73a7705 --- /dev/null +++ b/FoodDiary/App/Sources/Coordinator.swift @@ -0,0 +1,28 @@ +// +// Coordinator.swift +// App +// +// Created by 강대훈 on 4/8/26. +// + +import Foundation +import UIKit + +protocol Coordinator: AnyObject { + var childCoordinators: [Coordinator] { get set } + func start() +} + +extension Coordinator { + func addChild(_ coordinator: Coordinator) { + childCoordinators.append(coordinator) + } + + func removeChild(_ coordinator: Coordinator) { + childCoordinators = childCoordinators.filter { $0 !== coordinator } + } +} + +protocol SceneTransitioning: AnyObject { + func transition(to viewController: UIViewController) +} diff --git a/FoodDiary/App/Sources/InsightCoordinator.swift b/FoodDiary/App/Sources/InsightCoordinator.swift new file mode 100644 index 00000000..09bf6228 --- /dev/null +++ b/FoodDiary/App/Sources/InsightCoordinator.swift @@ -0,0 +1,26 @@ +// +// InsightCoordinator.swift +// App +// +// Created by 강대훈 on 4/11/26. +// + +import Presentation +import UIKit + +final class InsightCoordinator: Coordinator { + var childCoordinators: [Coordinator] = [] + weak var parentCoordinator: MainCoordinator? + + private let sceneProducer: MainSceneProducer + + init(sceneProducer: MainSceneProducer) { + self.sceneProducer = sceneProducer + } + + func start() {} + + func makeViewController() -> UIViewController { + sceneProducer.makeInsightScene() + } +} diff --git a/FoodDiary/App/Sources/LoginCoordinator.swift b/FoodDiary/App/Sources/LoginCoordinator.swift new file mode 100644 index 00000000..ee472dbc --- /dev/null +++ b/FoodDiary/App/Sources/LoginCoordinator.swift @@ -0,0 +1,26 @@ +// +// LoginCoordinator.swift +// App +// +// Created by 강대훈 on 4/10/26. +// + +import Presentation +import UIKit + +final class LoginCoordinator: Coordinator { + var childCoordinators: [Coordinator] = [] + weak var parentCoordinator: AppCoordinator? + weak var sceneTransitioner: SceneTransitioning? + + private let sceneProducer: MainSceneProducer + + init(sceneProducer: MainSceneProducer) { + self.sceneProducer = sceneProducer + } + + func start() { + let loginVC = sceneProducer.makeLoginScene() + sceneTransitioner?.transition(to: loginVC) + } +} diff --git a/FoodDiary/App/Sources/MainCoordinator.swift b/FoodDiary/App/Sources/MainCoordinator.swift new file mode 100644 index 00000000..5d75db05 --- /dev/null +++ b/FoodDiary/App/Sources/MainCoordinator.swift @@ -0,0 +1,79 @@ +// +// MainCoordinator.swift +// App +// +// Created by 강대훈 on 4/10/26. +// + +import Combine +import Presentation +import UIKit + +final class MainCoordinator: Coordinator { + var childCoordinators: [Coordinator] = [] + weak var parentCoordinator: AppCoordinator? + weak var sceneTransitioner: SceneTransitioning? + + private let sceneProducer: MainSceneProducer + private var cancellables = Set() + private let calendarCoordinator: CalendarCoordinator + private let insightCoordinator: InsightCoordinator + + var navigationController: UINavigationController? { + calendarCoordinator.navigationController + } + + init(sceneProducer: MainSceneProducer) { + self.sceneProducer = sceneProducer + self.calendarCoordinator = CalendarCoordinator(sceneProducer: sceneProducer) + self.insightCoordinator = InsightCoordinator(sceneProducer: sceneProducer) + } + + func start() { + calendarCoordinator.parentCoordinator = self + insightCoordinator.parentCoordinator = self + addChild(calendarCoordinator) + addChild(insightCoordinator) + + let calendarVC = calendarCoordinator.makeViewController() + let insightVC = insightCoordinator.makeViewController() + + let tabBarController = RootTabBarController(calendarVC: calendarVC, insightVC: insightVC) + + tabBarController.mypageButtonTapPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.pushMyPageVC() + } + .store(in: &cancellables) + + let navController = makeNavigationController(root: tabBarController) + calendarCoordinator.configure(navigationController: navController) + sceneTransitioner?.transition(to: navController) + } +} + +extension MainCoordinator { + func pushMyPageVC() { + guard let navController = navigationController else { return } + let myPageCoordinator = MyPageCoordinator(sceneProducer: sceneProducer, navigationController: navController) + myPageCoordinator.parentCoordinator = self + addChild(myPageCoordinator) + myPageCoordinator.start() + } +} + +private extension MainCoordinator { + func makeNavigationController(root: UIViewController) -> UINavigationController { + let navController = UINavigationController(rootViewController: root) + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.titleTextAttributes = [.foregroundColor: UIColor(white: 1, alpha: 1)] + appearance.shadowColor = .clear + navController.navigationBar.standardAppearance = appearance + navController.navigationBar.scrollEdgeAppearance = appearance + navController.navigationBar.tintColor = UIColor(white: 1, alpha: 1) + return navController + } +} diff --git a/FoodDiary/App/Sources/MainSceneProducer.swift b/FoodDiary/App/Sources/MainSceneProducer.swift new file mode 100644 index 00000000..0601f964 --- /dev/null +++ b/FoodDiary/App/Sources/MainSceneProducer.swift @@ -0,0 +1,95 @@ +// +// MainSceneProducer.swift +// App +// +// Created by 강대훈 on 4/8/26. +// + +import DI +import Data +import Domain +import Presentation +import UIKit + +final class MainSceneProducer { + private let container: DIContainer + + init(container: DIContainer) { + self.container = container + } +} + +// MARK: - Scene Factory + +extension MainSceneProducer { + func makeLoginScene() -> LoginViewController { + guard let viewModel = try? container.resolve(LoginViewModel.self) else { + fatalError("LoginViewModel Failed Resolve") + } + return LoginViewController(viewModel: viewModel) + } + + func makeInsightScene() -> UIViewController { + typealias InsightVM = InsightViewModel< + InsightRepositoryImpl> + > + guard let insightVM = try? container.resolve(InsightVM.self) else { + fatalError("InsightViewModel not registered") + } + return InsightViewController(viewModel: insightVM) + } + + func makeOnboardingScene() -> OnboardingViewController { + return OnboardingViewController() + } + + func makeMyPageScene() -> MyPageViewController { + guard let vm = try? container.resolve(MyPageViewModel.self) else { + fatalError("MyPageViewModel not registered") + } + return MyPageViewController(viewModel: vm) + } +} + +// MARK: - Calendar Scene Helpers + +extension MainSceneProducer { + func makeWeeklyCalendarVC() -> UIViewController { + guard let imageProvider = try? container.resolve(UIImageLoader.self) else { + fatalError("WeeklyCalendarViewController dependencies not registered") + } + + typealias WeeklyVM = WeeklyCalendarViewModel< + FoodRecordRepositoryImpl>, + FoodImageAssetFetcher, + PhotoAuthorizationFetcher, + PushNotificationObserver + > + + guard let weeklyViewModel = try? container.resolve(WeeklyVM.self) else { + fatalError("WeeklyCalendarViewModel not registered") + } + + return WeeklyCalendarViewController( + viewModel: weeklyViewModel, + imageProvider: imageProvider, + container: container + ) + } + + func makeMonthlyCalendarVC() -> UIViewController { + typealias MonthlyVM = MonthlyCalendarViewModel< + FoodRecordRepositoryImpl>, + PhotoAuthorizationFetcher + > + + guard let monthlyViewModel = try? container.resolve(MonthlyVM.self) else { + fatalError("MonthlyCalendarViewModel not registered") + } + + return MonthlyCalendarViewController( + viewModel: monthlyViewModel, + container: container + ) + } +} diff --git a/FoodDiary/App/Sources/MyPageCoordinator.swift b/FoodDiary/App/Sources/MyPageCoordinator.swift new file mode 100644 index 00000000..5c13a2c5 --- /dev/null +++ b/FoodDiary/App/Sources/MyPageCoordinator.swift @@ -0,0 +1,28 @@ +// +// MyPageCoordinator.swift +// App +// +// Created by 강대훈 on 4/11/26. +// + +import Presentation +import UIKit + +final class MyPageCoordinator: Coordinator { + var childCoordinators: [Coordinator] = [] + weak var parentCoordinator: MainCoordinator? + + private let sceneProducer: MainSceneProducer + private weak var navigationController: UINavigationController? + + init(sceneProducer: MainSceneProducer, navigationController: UINavigationController) { + self.sceneProducer = sceneProducer + self.navigationController = navigationController + } + + func start() { + let myPageVC = sceneProducer.makeMyPageScene() + myPageVC.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(myPageVC, animated: true) + } +} From 1a5ea09bb6cabcd13a09ad3935bf12714844525c Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:34 +0900 Subject: [PATCH 47/97] =?UTF-8?q?feat:=20LoginSession=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=20=E2=80=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B2=84?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/LoginSession/LoginSession.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 FoodDiary/Domain/Sources/Core/LoginSession/LoginSession.swift diff --git a/FoodDiary/Domain/Sources/Core/LoginSession/LoginSession.swift b/FoodDiary/Domain/Sources/Core/LoginSession/LoginSession.swift new file mode 100644 index 00000000..938a5164 --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/LoginSession/LoginSession.swift @@ -0,0 +1,33 @@ +// +// LoginSession.swift +// Domain +// +// Created by 강대훈 on 4/10/26. +// + +// TODO: 추후 적절한 레이어로 이동 예정 + +import Combine + +public final class LoginSession { + private let loginResultSubject = PassthroughSubject() + private let logoutSubject = PassthroughSubject() + + public var loginResultPublisher: AnyPublisher { + loginResultSubject.eraseToAnyPublisher() + } + + public var logoutPublisher: AnyPublisher { + logoutSubject.eraseToAnyPublisher() + } + + public init() {} + + public func notifyLoginSuccess(_ result: LoginResult) { + loginResultSubject.send(result) + } + + public func notifyLogout() { + logoutSubject.send() + } +} From 3c7b94e34cd88da4e5ac11fbc2960f34faf745b2 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:36 +0900 Subject: [PATCH 48/97] =?UTF-8?q?feat:=20CoachmarkStoring=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20CoachmarkStorage=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Coachmark/CoachmarkStorage.swift | 23 +++++++++++++++++++ .../Core/Coachmark/CoachmarkStoring.swift | 13 +++++++++++ 2 files changed, 36 insertions(+) create mode 100644 FoodDiary/Data/Sources/Core/Coachmark/CoachmarkStorage.swift create mode 100644 FoodDiary/Domain/Sources/Core/Coachmark/CoachmarkStoring.swift diff --git a/FoodDiary/Data/Sources/Core/Coachmark/CoachmarkStorage.swift b/FoodDiary/Data/Sources/Core/Coachmark/CoachmarkStorage.swift new file mode 100644 index 00000000..14673f0a --- /dev/null +++ b/FoodDiary/Data/Sources/Core/Coachmark/CoachmarkStorage.swift @@ -0,0 +1,23 @@ +// +// CoachmarkStorage.swift +// Data +// +// Created by 강대훈 on 4/9/26. +// + +import Domain +import Foundation + +public struct CoachmarkStorage: CoachmarkStoring { + private let key = "has_shown_coachmark" + + public init() {} + + public func get() -> Bool { + UserDefaults.standard.bool(forKey: key) + } + + public func set() { + UserDefaults.standard.set(true, forKey: key) + } +} diff --git a/FoodDiary/Domain/Sources/Core/Coachmark/CoachmarkStoring.swift b/FoodDiary/Domain/Sources/Core/Coachmark/CoachmarkStoring.swift new file mode 100644 index 00000000..5ffb9207 --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Coachmark/CoachmarkStoring.swift @@ -0,0 +1,13 @@ +// +// CoachmarkStoring.swift +// Domain +// +// Created by 강대훈 on 4/9/26. +// + +import Foundation + +public protocol CoachmarkStoring { + func get() -> Bool + func set() +} From 805cc0bcd80af04fe067761439a0fe18aaa9b38b Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:40 +0900 Subject: [PATCH 49/97] =?UTF-8?q?refactor:=20LoginSession=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=84=EB=8B=AC=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/UseCase/FinalizeAppleLoginUseCase.swift | 9 ++++++--- .../Sources/Login/View/LoginViewController.swift | 11 +---------- .../Sources/Login/ViewModel/LoginViewModel.swift | 4 ++-- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/FoodDiary/Domain/Sources/UseCase/FinalizeAppleLoginUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FinalizeAppleLoginUseCase.swift index e5427a68..07129ee7 100644 --- a/FoodDiary/Domain/Sources/UseCase/FinalizeAppleLoginUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FinalizeAppleLoginUseCase.swift @@ -19,6 +19,7 @@ public struct FinalizeAppleLoginUseCase { private let pushTokenStorage: PushTokenStoring private let notificationAuthorizationProvider: NotificationAuthorizationProviding private let initialLaunchStorage: InitialLaunchStoring + private let loginSession: LoginSession public init( authRepository: AuthRepository, @@ -26,7 +27,8 @@ public struct FinalizeAppleLoginUseCase { osVersion: String, pushTokenStorage: PushTokenStoring, notificationAuthorizationProvider: NotificationAuthorizationProviding, - initialLaunchStorage: InitialLaunchStoring + initialLaunchStorage: InitialLaunchStoring, + loginSession: LoginSession ) { self.authRepository = authRepository self.deviceId = deviceId @@ -34,9 +36,10 @@ public struct FinalizeAppleLoginUseCase { self.pushTokenStorage = pushTokenStorage self.notificationAuthorizationProvider = notificationAuthorizationProvider self.initialLaunchStorage = initialLaunchStorage + self.loginSession = loginSession } - public func execute(_ appleIdentityToken: String) async throws -> LoginResult { + public func execute(_ appleIdentityToken: String) async throws { let fcmToken = pushTokenStorage.get() let notificationEnabled = await notificationAuthorizationProvider.isNotificationEnabled() @@ -51,6 +54,6 @@ public struct FinalizeAppleLoginUseCase { let result = try await authRepository.login(loginRequest) initialLaunchStorage.set() - return result + loginSession.notifyLoginSuccess(result) } } diff --git a/FoodDiary/Presentation/Sources/Login/View/LoginViewController.swift b/FoodDiary/Presentation/Sources/Login/View/LoginViewController.swift index dfb58bcb..84c37b44 100644 --- a/FoodDiary/Presentation/Sources/Login/View/LoginViewController.swift +++ b/FoodDiary/Presentation/Sources/Login/View/LoginViewController.swift @@ -6,19 +6,11 @@ // import AuthenticationServices -import Combine import UIKit import SnapKit import DesignSystem -import Domain final public class LoginViewController: UIViewController { - private let didLoginSubject = PassthroughSubject() - - public var didLoginPublisher: AnyPublisher { - didLoginSubject.eraseToAnyPublisher() - } - private let viewModel: LoginViewModel public init(viewModel: LoginViewModel) { @@ -94,8 +86,7 @@ extension LoginViewController: ASAuthorizationControllerDelegate { let tokenString = String(data: token, encoding: .utf8) { Task { do { - let result = try await viewModel.sendIdentityToken(tokenString) - didLoginSubject.send(result) + try await viewModel.sendIdentityToken(tokenString) } catch { print(error.localizedDescription) } diff --git a/FoodDiary/Presentation/Sources/Login/ViewModel/LoginViewModel.swift b/FoodDiary/Presentation/Sources/Login/ViewModel/LoginViewModel.swift index 69d3ed8a..60cf5bd4 100644 --- a/FoodDiary/Presentation/Sources/Login/ViewModel/LoginViewModel.swift +++ b/FoodDiary/Presentation/Sources/Login/ViewModel/LoginViewModel.swift @@ -15,7 +15,7 @@ final public class LoginViewModel { self.finalizeAppleLoginUseCase = finalizeAppleLoginUseCase } - public func sendIdentityToken(_ token: String) async throws -> LoginResult { - return try await finalizeAppleLoginUseCase.execute(token) + public func sendIdentityToken(_ token: String) async throws { + try await finalizeAppleLoginUseCase.execute(token) } } From e8ef2cd7adabf018cf602074259e557c08e96935 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:43 +0900 Subject: [PATCH 50/97] =?UTF-8?q?refactor:=20LoginSession=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83/=ED=83=88=ED=87=B4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift | 8 ++++++-- .../Domain/Sources/UseCase/WithdrawUserUseCase.swift | 6 +++++- .../Sources/MyPage/MyPageViewController.swift | 10 ---------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift b/FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift index b6c797f2..a24cf296 100644 --- a/FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift @@ -9,15 +9,19 @@ import Foundation public struct LogoutUseCase { private let authRepository: AuthRepository + private let loginSession: LoginSession - public init(authRepository: AuthRepository) { + public init(authRepository: AuthRepository, loginSession: LoginSession) { self.authRepository = authRepository + self.loginSession = loginSession } /// 로그아웃을 수행합니다. /// - /// AuthTokenStorage를 clear하여 저장된 액세스 토큰을 제거합니다. + /// AuthTokenStorage를 clear하여 저장된 액세스 토큰을 제거하고, + /// LoginSession을 통해 로그아웃 이벤트를 전달합니다. public func execute() throws { try authRepository.logout() + loginSession.notifyLogout() } } diff --git a/FoodDiary/Domain/Sources/UseCase/WithdrawUserUseCase.swift b/FoodDiary/Domain/Sources/UseCase/WithdrawUserUseCase.swift index c315b15b..7e9c355b 100644 --- a/FoodDiary/Domain/Sources/UseCase/WithdrawUserUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/WithdrawUserUseCase.swift @@ -9,15 +9,19 @@ import Foundation public struct WithdrawUserUseCase { private let authRepository: AuthRepository + private let loginSession: LoginSession - public init(authRepository: AuthRepository) { + public init(authRepository: AuthRepository, loginSession: LoginSession) { self.authRepository = authRepository + self.loginSession = loginSession } /// 회원탈퇴를 수행합니다. /// /// 서버에 회원탈퇴 요청을 전송하고, 로컬에 저장된 토큰을 삭제합니다. + /// LoginSession을 통해 로그아웃 이벤트를 전달합니다. public func execute() async throws { try await authRepository.withdraw() + loginSession.notifyLogout() } } diff --git a/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift b/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift index cec927a7..3cd8af5f 100644 --- a/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift +++ b/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift @@ -53,17 +53,7 @@ public final class MyPageViewController: UIViewController { case logout } - // MARK: - Output - public var didLogoutPublisher: AnyPublisher { - viewModel.eventPublisher - .compactMap { event -> Void? in - switch event { - case .didLogout, .didWithdraw: return () - } - } - .eraseToAnyPublisher() - } // MARK: - Dependencies From 2f5eaee32b37b5f03d2fc009e7c807b6215e2711 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:46 +0900 Subject: [PATCH 51/97] =?UTF-8?q?refactor:=20AppFlowController=EC=97=90=20?= =?UTF-8?q?Coordinator=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=A8=EB=B3=B4=EB=94=A9/=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppFlowController.swift | 578 ++++-------------- 1 file changed, 111 insertions(+), 467 deletions(-) diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index e8ec2196..9b1c444a 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -14,20 +14,36 @@ import SnapKit import UIKit import UserNotifications -final class AppFlowController: UIViewController { +final class AppFlowController: UIViewController, SceneTransitioning { private typealias AssetFetcher = FoodImageAssetFetcher private typealias FetchUseCase = FetchFoodImageAssetUseCase - private typealias AuthUseCase = RequestPhotoAuthorizationUseCase private var currentChild: UIViewController? private var networkCancellable: AnyCancellable? private var cancellables = Set() private let container: DIContainer - private var pendingDeepLinkDate: String? + private let loginSession: LoginSession private var splashView: SplashView? + private var pendingDeepLinkDate: String? + + private lazy var coordinator: AppCoordinator = { + guard let sceneProducer = try? container.resolve(MainSceneProducer.self) else { + fatalError("MainSceneProducer not registered") + } + let coordinator = AppCoordinator( + container: container, + sceneProducer: sceneProducer + ) + coordinator.sceneTransitioner = self + return coordinator + }() public init(container: DIContainer) { + guard let loginSession = try? container.resolve(LoginSession.self) else { + fatalError("LoginSession not registered") + } self.container = container + self.loginSession = loginSession super.init(nibName: nil, bundle: nil) } @@ -42,6 +58,23 @@ final class AppFlowController: UIViewController { setupNetworkMonitoring() setupAnalysisCompletionToast() setupDeepLinkHandling() + setupLoginSessionObservation() + } + + private func setupLoginSessionObservation() { + loginSession.loginResultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] loginResult in + self?.handleLoginResult(loginResult) + } + .store(in: &cancellables) + + loginSession.logoutPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.coordinator.pushLoginVC() + } + .store(in: &cancellables) } private func setupSplashView() { @@ -69,6 +102,43 @@ final class AppFlowController: UIViewController { } .store(in: &cancellables) } + + fileprivate func handleLoginResult(_ loginResult: LoginResult) { + Task { [weak self] in + guard let self else { return } + try? await fetchUserProfile() + if !loginResult.isFirst { + registerForRemoteNotificationsAfterLogin() + } + await MainActor.run { [weak self] in + guard let self else { return } + loginResult.isFirst ? showOnboarding() : coordinator.pushMainVC() + } + } + } + + private func showOnboarding() { + guard let sceneProducer = try? container.resolve(MainSceneProducer.self) else { return } + let onboardingVC = sceneProducer.makeOnboardingScene() + var cancellable: AnyCancellable? + cancellable = onboardingVC.didCompletePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + registerForRemoteNotificationsAfterLogin() + coordinator.pushMainVC() + _ = cancellable + } + transition(to: UINavigationController(rootViewController: onboardingVC)) + } + + private func navigateToDetailFromDeepLink(diaryDateString: String) { + guard coordinator.currentNavigationController != nil else { + pendingDeepLinkDate = diaryDateString + return + } + coordinator.navigateToDetail(diaryDateString: diaryDateString) + } } extension AppFlowController { @@ -127,22 +197,51 @@ extension AppFlowController { } fileprivate func routeToAppropriateScreen(isLogin: Bool) { - let destinationVC = isLogin ? createMainView() : createLoginView() - transition(to: destinationVC) - removeSplashView() if isLogin { + coordinator.pushMainVC() registerForRemoteNotificationsAfterLogin() + if let deepLink = pendingDeepLinkDate { + pendingDeepLinkDate = nil + Task { [weak self] in + try? await Task.sleep(for: .seconds(0.5)) + await MainActor.run { self?.coordinator.navigateToDetail(diaryDateString: deepLink) } + } + } + } else { + coordinator.pushLoginVC() } + removeSplashView() + } - if isLogin, let deepLink = pendingDeepLinkDate { - pendingDeepLinkDate = nil - Task { [weak self] in - try? await Task.sleep(for: .seconds(0.5)) - self?.performDeepLinkNavigation(diaryDateString: deepLink) + fileprivate func registerForRemoteNotificationsAfterLogin() { + Task { @MainActor in + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + UIApplication.shared.registerForRemoteNotifications() + case .notDetermined: + await requestSystemNotificationAuthorization() + case .denied: + break + @unknown default: + break } } } + @MainActor + private func requestSystemNotificationAuthorization() async { + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .badge, .sound] + ) + guard granted else { return } + UIApplication.shared.registerForRemoteNotifications() + } catch { + print("알림 권한 요청 실패: \(error)") + } + } + fileprivate func validateToken() async -> Bool { guard let validateAccessTokenUseCase = try? container.resolve( @@ -172,213 +271,7 @@ extension AppFlowController { try await fetchUserProfileUseCase.execute() } - fileprivate func createMainView() -> UIViewController { - let weeklyCalendarVC = makeWeeklyCalendarVC() - let monthlyCalendarVC = makeMonthlyCalendarVC() - - let myPageVCFactory: (@escaping () -> Void) -> UIViewController = { [container] onLogout in - guard let vm = try? container.resolve(MyPageViewModel.self) else { - fatalError("MyPageViewModel not registered") - } - let vc = MyPageViewController(viewModel: vm) - var cancellable: AnyCancellable? - cancellable = vc.didLogoutPublisher - .sink { - onLogout() - _ = cancellable - } - 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(viewModel: insightVM), - myPageViewControllerFactory: myPageVCFactory - ) - - tabBarVC.didLogoutPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] in - guard let self else { return } - transition(to: createLoginView()) - } - .store(in: &cancellables) - - let navController = UINavigationController(rootViewController: tabBarVC) - let navAppearance = UINavigationBarAppearance() - navAppearance.configureWithTransparentBackground() - navAppearance.backgroundColor = .clear - navAppearance.titleTextAttributes = [.foregroundColor: UIColor(white: 1, alpha: 1)] - navAppearance.shadowColor = .clear - - navController.navigationBar.standardAppearance = navAppearance - navController.navigationBar.scrollEdgeAppearance = navAppearance - navController.navigationBar.tintColor = UIColor(white: 1, alpha: 1) - - return navController - } - - fileprivate func makeWeeklyCalendarVC() -> UIViewController { - guard let imageProvider = try? container.resolve(UIImageLoader.self) else { - fatalError("WeeklyCalendarViewController dependencies not registered") - } - - typealias WeeklyVM = WeeklyCalendarViewModel< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher, - PhotoAuthorizationFetcher, - PushNotificationObserver - > - - guard let weeklyViewModel = try? container.resolve(WeeklyVM.self) else { - fatalError("WeeklyCalendarViewModel not registered") - } - - return WeeklyCalendarViewController( - viewModel: weeklyViewModel, - imageProvider: imageProvider, - detailViewControllerFactory: { [weak self] date, records, scrollToMealType, shouldPopToRoot, onDismiss in - self?.makeDetailViewController( - date: date, - records: records, - scrollToMealType: scrollToMealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: onDismiss - ) ?? UIViewController() - } - ) - } - - fileprivate func makeMonthlyCalendarVC() -> UIViewController { - typealias MonthlyVM = MonthlyCalendarViewModel< - FoodRecordRepositoryImpl>, - PhotoAuthorizationFetcher - > - - guard let monthlyViewModel = try? container.resolve(MonthlyVM.self) else { - fatalError("MonthlyCalendarViewModel not registered") - } - - return MonthlyCalendarViewController( - viewModel: monthlyViewModel, - detailViewControllerFactory: { [weak self] date, records, scrollToMealType, shouldPopToRoot, onDismiss in - self?.makeDetailViewController( - date: date, - records: records, - scrollToMealType: scrollToMealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: onDismiss - ) ?? UIViewController() - } - ) - } - - fileprivate func createOnboardingView() -> UIViewController { - let onboardingVC = OnboardingViewController() - - onboardingVC.didCompletePublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { return } - registerForRemoteNotificationsAfterLogin() - let mainVC = createMainView() - transition(to: mainVC) - showCoachmarkOverlay(on: mainVC) - } - .store(in: &cancellables) - - return UINavigationController(rootViewController: onboardingVC) - } - - fileprivate func showCoachmarkOverlay(on viewController: UIViewController) { - let overlay = CoachmarkOverlayView() - viewController.view.addSubview(overlay) - overlay.snp.makeConstraints { $0.edges.equalToSuperview() } - overlay.alpha = 0 - UIView.animate(withDuration: 0.3) { overlay.alpha = 1 } - - overlay.didDismissPublisher - .receive(on: DispatchQueue.main) - .sink { _ in - UIView.animate( - withDuration: 0.3, - animations: { - overlay.alpha = 0 - }, - completion: { _ in - overlay.removeFromSuperview() - }) - } - .store(in: &cancellables) - } - - fileprivate func createLoginView() -> UIViewController { - guard let viewModel = try? container.resolve(LoginViewModel.self) else { - fatalError("LoginViewModel Failed Resolve") - } - - let loginVC = LoginViewController(viewModel: viewModel) - - loginVC.didLoginPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] loginResult in - guard let self else { return } - handleLoginResult(loginResult) - } - .store(in: &cancellables) - - return loginVC - } - - fileprivate func handleLoginResult(_ loginResult: LoginResult) { - Task { - try? await fetchUserProfile() - if !loginResult.isFirst { - registerForRemoteNotificationsAfterLogin() - } - transition(to: loginResult.isFirst ? createOnboardingView() : createMainView()) - } - } - - fileprivate func registerForRemoteNotificationsAfterLogin() { - Task { @MainActor in - let settings = await UNUserNotificationCenter.current().notificationSettings() - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - UIApplication.shared.registerForRemoteNotifications() - case .notDetermined: - await requestSystemNotificationAuthorization() - case .denied: - break - @unknown default: - break - } - } - } - - @MainActor - fileprivate func requestSystemNotificationAuthorization() async { - do { - let granted = try await UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .badge, .sound] - ) - guard granted else { return } - UIApplication.shared.registerForRemoteNotifications() - } catch { - print("알림 권한 요청 실패: \(error)") - } - } - - fileprivate func transition(to viewController: UIViewController) { + func transition(to viewController: UIViewController) { let previousChild = currentChild addChild(viewController) @@ -405,252 +298,3 @@ extension AppFlowController { ) } } - -// MARK: - Deep Link Navigation - -extension AppFlowController { - private static let deepLinkDateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter - }() - - fileprivate func navigateToDetailFromDeepLink(diaryDateString: String) { - guard findNavigationController() != nil else { - pendingDeepLinkDate = diaryDateString - return - } - performDeepLinkNavigation(diaryDateString: diaryDateString) - } - - fileprivate func performDeepLinkNavigation(diaryDateString: String) { - guard let date = Self.deepLinkDateFormatter.date(from: diaryDateString) else { - print("[DeepLink] 날짜 파싱 실패: \(diaryDateString)") - return - } - - guard let navController = findNavigationController() else { - print("[DeepLink] NavigationController를 찾을 수 없음") - return - } - - let detailVC = makeDetailViewController( - date: date, - records: [], - onDismissWithDate: nil - ) - - if let presented = navController.presentedViewController { - presented.dismiss(animated: false) { - navController.pushViewController(detailVC, animated: true) - } - } else { - navController.pushViewController(detailVC, animated: true) - } - } - - private func findNavigationController() -> UINavigationController? { - guard let child = currentChild else { return nil } - - if let nav = child as? UINavigationController { - return nav - } - - for child in child.children { - if let nav = child as? UINavigationController { - return nav - } - } - - return nil - } -} - -// MARK: - Detail & Edit Factory - -extension AppFlowController { - private typealias DetailVM = DetailViewModel< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > - private typealias EditVM = EditFoodRecordViewModel< - FoodRecordRepositoryImpl> - > - private typealias AddressSearchVM = AddressSearchViewModel - - fileprivate func makeDetailViewController( - date: Date, - records: [FoodRecord], - scrollToMealType: MealType? = nil, - shouldPopToRoot: Bool = false, - onDismissWithDate: ((Date) -> Void)? = nil - ) -> UIViewController { - guard - let detailVM = try? container.resolve(DetailVM.self, argument: (date, records)) - else { - fatalError("DetailViewController dependencies not registered") - } - - return DetailViewController( - viewModel: detailVM, - initialScrollTarget: scrollToMealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: onDismissWithDate, - editViewControllerFactory: { [weak self] record in - self?.makeEditViewController(for: record) ?? UIViewController() - }, - presentImagePickerHandler: makeDetailImagePickerHandler() - ) - } - - fileprivate func makeEditViewController(for record: FoodRecord) -> UIViewController { - guard let editVM = try? container.resolve(EditVM.self, argument: record) else { - fatalError("EditFoodRecordViewModel not registered") - } - - let addressSearchVCFactory: - (Int, @escaping (AddressSearchResult) -> Void) -> UIViewController = { - [container] diaryId, onSelect in - guard - let addressVM = try? container.resolve(AddressSearchVM.self, argument: diaryId) - else { - fatalError("AddressSearchViewModel not registered") - } - return AddressSearchViewController( - viewModel: addressVM, onAddressSelected: onSelect) - } - - return EditFoodRecordViewController( - viewModel: editVM, - addressSearchViewControllerFactory: addressSearchVCFactory - ) - } -} - -// MARK: - Image Picker - -extension AppFlowController { - fileprivate func makeDetailImagePickerHandler() - -> (UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void - { - return { [weak self] nav, date, onSelected in - guard let self else { return } - self.presentImagePicker( - from: nav, - date: date, - configuration: .withMaxSelectionCount(10), - autoPreselectByProbability: true, - loadPreviewImages: false, - onSelected: { assets, _ in - onSelected(assets) - } - ) - } - } - - fileprivate func presentImagePicker( - from nav: UINavigationController, - date: Date, - configuration: ImagePickerConfiguration, - autoPreselectByProbability: Bool, - loadPreviewImages: Bool, - onSelected: @escaping ([any ImageAssetable], [UIImage]) -> Void - ) { - guard let fetchUseCase = try? container.resolve(FetchUseCase.self), - let imageProvider = try? container.resolve(UIImageLoader.self), - let authUseCase = try? container.resolve(AuthUseCase.self) - else { - fatalError("ImagePicker dependencies not registered") - } - - Task { @MainActor in - if !authUseCase.isAuthorized() { - let status = await authUseCase.execute() - if status == .denied || status == .restricted { - Self.presentPhotoAccessDeniedAlert(on: nav) - return - } - } - - do { - let calendar = Calendar.current - let startOfDay = calendar.startOfDay(for: date) - let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) - let photosByDate = try await fetchUseCase.execute(from: startOfDay, to: endOfDay) - let foodImageAssets = photosByDate[startOfDay] ?? [] - let photos = foodImageAssets.map { $0.imageAsset } - - let preselectedFoodPhotoIds: Set = - autoPreselectByProbability - ? Set(foodImageAssets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) - : [] - - let picker = ImagePickerViewController( - photos: photos, - preselectedFoodPhotoIds: preselectedFoodPhotoIds, - imageProvider: imageProvider, - configuration: configuration - ) - - var cancellable: AnyCancellable? - cancellable = picker.resultPublisher - .sink { [weak nav] result in - defer { cancellable = nil } - switch result { - case .selected(let assets): - nav?.popViewController(animated: true) - if loadPreviewImages { - Task { @MainActor in - var previewImages: [UIImage] = [] - for asset in assets { - if let image = try? await imageProvider.loadImage( - for: asset, - targetSize: CGSize(width: 300, height: 300) - ) { - previewImages.append(image) - } - } - onSelected(assets, previewImages) - } - } else { - onSelected(assets, []) - } - case .cancelled: - break - } - } - - nav.pushViewController(picker, animated: true) - } catch { - Self.presentPhotoLoadFailedAlert(error: error, on: nav) - } - } - } - - private static func presentPhotoAccessDeniedAlert(on nav: UINavigationController) { - let alert = UIAlertController( - title: "사진 접근 권한 필요", - message: "음식 사진을 추가하려면 사진 라이브러리 접근 권한이 필요합니다. 설정에서 권한을 허용해 주세요.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "취소", style: .cancel)) - alert.addAction( - UIAlertAction(title: "설정으로 이동", style: .default) { _ in - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - }) - nav.topViewController?.present(alert, animated: true) - } - - private static func presentPhotoLoadFailedAlert(error: Error, on nav: UINavigationController) { - let alert = UIAlertController( - title: "사진 불러오기 실패", - message: error.localizedDescription, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - nav.topViewController?.present(alert, animated: true) - } -} From caa9ad5da91bbc2118047e25d5a6a96394c19c80 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:50 +0900 Subject: [PATCH 52/97] =?UTF-8?q?refactor:=20RootTabBarController=EC=97=90?= =?UTF-8?q?=EC=84=9C=20MyPage=20=EC=83=9D=EC=84=B1=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=EC=9D=84=20Coordinator=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/TabBar/RootTabBarController.swift | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift b/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift index 244f5b02..584fcfb4 100644 --- a/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift +++ b/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift @@ -12,23 +12,19 @@ import Combine public final class RootTabBarController: UITabBarController { let calendarVC: CalendarViewController let insightVC: UIViewController - private let myPageViewControllerFactory: (@escaping () -> Void) -> UIViewController - private let didLogoutSubject = PassthroughSubject() + private let mypageButtonTapSubject = PassthroughSubject() private var cancellables = Set() - public var didLogoutPublisher: AnyPublisher { - didLogoutSubject.eraseToAnyPublisher() + public var mypageButtonTapPublisher: AnyPublisher { + mypageButtonTapSubject.eraseToAnyPublisher() } public init( - weeklyVC: UIViewController, - monthlyVC: UIViewController, - insightVC: UIViewController, - myPageViewControllerFactory: @escaping (@escaping () -> Void) -> UIViewController + calendarVC: CalendarViewController, + insightVC: UIViewController ) { - self.calendarVC = CalendarViewController(weeklyVC: weeklyVC, monthlyVC: monthlyVC) + self.calendarVC = calendarVC self.insightVC = insightVC - self.myPageViewControllerFactory = myPageViewControllerFactory super.init(nibName: nil, bundle: nil) } @@ -83,20 +79,16 @@ public final class RootTabBarController: UITabBarController { } @objc private func mypageButtonTapped() { - let myPageVC = myPageViewControllerFactory { [weak self] in - self?.didLogoutSubject.send() - } - myPageVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(myPageVC, animated: true) + mypageButtonTapSubject.send() } - + private func toggleViewMode() { calendarVC.toggleViewMode() } - + private func updateToggleIcon(for mode: CalendarViewController.ViewMode) { guard let items = tabBar.items, items.count > 2 else { return } - + let newImage = mode == .monthly ? DesignSystemAsset.iconWeekly.image : DesignSystemAsset.iconMonthly.image items[2].image = newImage } @@ -105,15 +97,15 @@ public final class RootTabBarController: UITabBarController { extension RootTabBarController: UITabBarControllerDelegate { public func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { guard let index = viewControllers?.firstIndex(of: viewController) else { return true } - + if index == 2 { toggleViewMode() return false } - + return true } - + public func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { for subview in self.tabBar.subviews { if subview.frame.origin.x > self.tabBar.bounds.width * 0.6 { From 33a9cf17c52a95cc9df02560b5da402bee10dac4 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:54 +0900 Subject: [PATCH 53/97] =?UTF-8?q?feat:=20WeeklyCalendarViewModel=EC=97=90?= =?UTF-8?q?=20=EC=BD=94=EC=B9=98=EB=A7=88=ED=81=AC=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/WeeklyCalendarViewModel.swift | 17 +- .../WeeklyCalendarViewController.swift | 226 +++++++++++++++++- 2 files changed, 236 insertions(+), 7 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift index 5ea07ba5..5f5c9a35 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift @@ -50,6 +50,7 @@ public final class WeeklyCalendarViewModel< private let saveFoodRecordUseCase: SaveFoodRecordUseCase private let pushNotificationObserver: PushObserver private let getNicknameUseCase: GetNicknameUseCase + private let coachmarkStorage: any CoachmarkStoring // MARK: - Init @@ -58,13 +59,15 @@ public final class WeeklyCalendarViewModel< loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, saveFoodRecordUseCase: SaveFoodRecordUseCase, pushNotificationObserver: PushObserver, - getNicknameUseCase: GetNicknameUseCase + getNicknameUseCase: GetNicknameUseCase, + coachmarkStorage: any CoachmarkStoring ) { self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase self.loadWeeklyCalendarDataUseCase = loadWeeklyCalendarDataUseCase self.saveFoodRecordUseCase = saveFoodRecordUseCase self.pushNotificationObserver = pushNotificationObserver self.getNicknameUseCase = getNicknameUseCase + self.coachmarkStorage = coachmarkStorage let cal = Calendar.current self.calendar = cal @@ -166,6 +169,15 @@ public final class WeeklyCalendarViewModel< } await loadWeekData(for: currentWeekBaseDate) await updateDateContent(for: state.selectedDate) + + case .viewDidAppear: + if !coachmarkStorage.get() { + state.shouldShowCoachmark = true + } + + case .dismissCoachmark: + coachmarkStorage.set() + state.shouldShowCoachmark = false } } @@ -308,6 +320,7 @@ extension WeeklyCalendarViewModel { public internal(set) var dateContent: DateContent? public internal(set) var nickname: String? = nil public internal(set) var canGoToNextWeek: Bool = false + public internal(set) var shouldShowCoachmark: Bool = false } public enum Input { @@ -320,6 +333,8 @@ extension WeeklyCalendarViewModel { case saveSelectedPhotos([AssetRepo.Asset]) case handlePushNotification(AnalysisResultNotification) case refreshData(Date? = nil) + case viewDidAppear + case dismissCoachmark } public enum Event { diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index 3eacdb34..23d75bab 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -4,7 +4,9 @@ // import Combine +import Data import DesignSystem +import DI import Domain import SnapKit import UIKit @@ -28,7 +30,7 @@ public final class WeeklyCalendarViewController< RecordRepo, AssetRepo, AuthRepo, PushObserver > private let imageProvider: ImageProvider - private let detailViewControllerFactory: (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController + private let container: DIContainer // MARK: - UI Components @@ -71,11 +73,11 @@ public final class WeeklyCalendarViewController< RecordRepo, AssetRepo, AuthRepo, PushObserver >, imageProvider: ImageProvider, - detailViewControllerFactory: @escaping (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController + container: DIContainer ) { self.viewModel = viewModel self.imageProvider = imageProvider - self.detailViewControllerFactory = detailViewControllerFactory + self.container = container super.init(nibName: nil, bundle: nil) } @@ -95,6 +97,11 @@ public final class WeeklyCalendarViewController< viewModel.input.send(.loadInitialData) } + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.input.send(.viewDidAppear) + } + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) } @@ -234,6 +241,15 @@ public final class WeeklyCalendarViewController< } .store(in: &cancellables) + // 코치마크 표시 + viewModel.statePublisher + .map(\.shouldShowCoachmark) + .removeDuplicates() + .filter { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.showCoachmarkOverlay() } + .store(in: &cancellables) + // Event: 권한 거부 시 설정 이동 안내 Alert 표시 및 저장 결과 처리 viewModel.eventPublisher .receive(on: DispatchQueue.main) @@ -252,6 +268,31 @@ public final class WeeklyCalendarViewController< .store(in: &cancellables) } + // MARK: - Coachmark + + private func showCoachmarkOverlay() { + let overlay = CoachmarkOverlayView() + view.addSubview(overlay) + overlay.snp.makeConstraints { $0.edges.equalToSuperview() } + overlay.alpha = 0 + UIView.animate(withDuration: 0.3) { overlay.alpha = 1 } + + overlay.didDismissPublisher + .receive(on: DispatchQueue.main) + .first() + .sink { [weak self, weak overlay] _ in + UIView.animate( + withDuration: 0.3, + animations: { overlay?.alpha = 0 }, + completion: { _ in + overlay?.removeFromSuperview() + self?.viewModel.input.send(.dismissCoachmark) + } + ) + } + .store(in: &cancellables) + } + // MARK: - Image Picker Loading private func setImagePickerLoading(_ isLoading: Bool) { @@ -362,11 +403,184 @@ public final class WeeklyCalendarViewController< private func navigateToDetail(for date: Date, scrollTo mealType: MealType? = nil, shouldPopToRoot: Bool = false) { let records = viewModel.state.weekDays.records(for: date) - let detailVC = detailViewControllerFactory(date, records, mealType, shouldPopToRoot) { [weak self] date in - self?.viewModel.input.send(.refreshData(date)) - } + let detailVC = makeDetailViewController( + date: date, + records: records, + scrollToMealType: mealType, + shouldPopToRoot: shouldPopToRoot, + onDismissWithDate: { [weak self] date in + self?.viewModel.input.send(.refreshData(date)) + } + ) navigationController?.pushViewController(detailVC, animated: true) } + private typealias DetailVM = DetailViewModel< + FoodRecordRepositoryImpl>, + PushNotificationObserver + > + private typealias EditVM = EditFoodRecordViewModel< + FoodRecordRepositoryImpl> + > + private typealias AddressSearchVM = AddressSearchViewModel + private typealias AssetFetcher = FoodImageAssetFetcher + private typealias FetchUseCase = FetchFoodImageAssetUseCase + private typealias AuthUseCase = RequestPhotoAuthorizationUseCase + + private func makeDetailViewController( + date: Date, + records: [FoodRecord], + scrollToMealType: MealType? = nil, + shouldPopToRoot: Bool = false, + onDismissWithDate: ((Date) -> Void)? = nil + ) -> UIViewController { + guard let detailVM = try? container.resolve(DetailVM.self, argument: (date, records)) else { + fatalError("DetailViewModel not registered") + } + return DetailViewController( + viewModel: detailVM, + initialScrollTarget: scrollToMealType, + shouldPopToRoot: shouldPopToRoot, + onDismissWithDate: onDismissWithDate, + editViewControllerFactory: { [weak self] record in + self?.makeEditViewController(for: record) ?? UIViewController() + }, + presentImagePickerHandler: makeDetailImagePickerHandler() + ) + } + + private func makeEditViewController(for record: FoodRecord) -> UIViewController { + guard let editVM = try? container.resolve(EditVM.self, argument: record) else { + fatalError("EditFoodRecordViewModel not registered") + } + let addressSearchVCFactory: (Int, @escaping (AddressSearchResult) -> Void) -> UIViewController = { + [container] diaryId, onSelect in + guard let addressVM = try? container.resolve(AddressSearchVM.self, argument: diaryId) else { + fatalError("AddressSearchViewModel not registered") + } + return AddressSearchViewController(viewModel: addressVM, onAddressSelected: onSelect) + } + return EditFoodRecordViewController(viewModel: editVM, addressSearchViewControllerFactory: addressSearchVCFactory) + } + + private func makeDetailImagePickerHandler() + -> (UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void + { + return { [weak self] nav, date, onSelected in + guard let self else { return } + self.presentImagePicker( + from: nav, + date: date, + configuration: .withMaxSelectionCount(10), + autoPreselectByProbability: true, + loadPreviewImages: false, + onSelected: { assets, _ in onSelected(assets) } + ) + } + } + private func presentImagePicker( + from nav: UINavigationController, + date: Date, + configuration: ImagePickerConfiguration, + autoPreselectByProbability: Bool, + loadPreviewImages: Bool, + onSelected: @escaping ([any ImageAssetable], [UIImage]) -> Void + ) { + guard let fetchUseCase = try? container.resolve(FetchUseCase.self), + let imageProvider = try? container.resolve(UIImageLoader.self), + let authUseCase = try? container.resolve(AuthUseCase.self) + else { + fatalError("ImagePicker dependencies not registered") + } + + Task { @MainActor in + if !authUseCase.isAuthorized() { + let status = await authUseCase.execute() + if status == .denied || status == .restricted { + Self.presentPhotoAccessDeniedAlert(on: nav) + return + } + } + + do { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) + let photosByDate = try await fetchUseCase.execute(from: startOfDay, to: endOfDay) + let foodImageAssets = photosByDate[startOfDay] ?? [] + let photos = foodImageAssets.map { $0.imageAsset } + + let preselectedFoodPhotoIds: Set = + autoPreselectByProbability + ? Set(foodImageAssets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) + : [] + + let picker = ImagePickerViewController( + photos: photos, + preselectedFoodPhotoIds: preselectedFoodPhotoIds, + imageProvider: imageProvider, + configuration: configuration + ) + + var cancellable: AnyCancellable? + cancellable = picker.resultPublisher + .sink { [weak nav] result in + defer { cancellable = nil } + switch result { + case .selected(let assets): + nav?.popViewController(animated: true) + if loadPreviewImages { + Task { @MainActor in + var previewImages: [UIImage] = [] + for asset in assets { + if let image = try? await imageProvider.loadImage( + for: asset, + targetSize: CGSize(width: 300, height: 300) + ) { + previewImages.append(image) + } + } + onSelected(assets, previewImages) + } + } else { + onSelected(assets, []) + } + case .cancelled: + break + } + } + + nav.pushViewController(picker, animated: true) + } catch { + Self.presentPhotoLoadFailedAlert(error: error, on: nav) + } + } + } + + private static func presentPhotoAccessDeniedAlert(on nav: UINavigationController) { + let alert = UIAlertController( + title: "사진 접근 권한 필요", + message: "음식 사진을 추가하려면 사진 라이브러리 접근 권한이 필요합니다. 설정에서 권한을 허용해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction( + UIAlertAction(title: "설정으로 이동", style: .default) { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) + nav.topViewController?.present(alert, animated: true) + } + + private static func presentPhotoLoadFailedAlert(error: Error, on nav: UINavigationController) { + let alert = UIAlertController( + title: "사진 불러오기 실패", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + nav.topViewController?.present(alert, animated: true) + } } From 90e540b3da9d7173afba1e8374392a7c34925926 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:56:59 +0900 Subject: [PATCH 54/97] =?UTF-8?q?refactor:=20CalendarViewController?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=ED=81=B4?= =?UTF-8?q?=EB=A1=9C=EC=A0=80=EB=A5=BC=20DIContainer=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=20=EC=A3=BC=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonthlyCalendarViewController.swift | 187 +++++++++++++++++- 1 file changed, 181 insertions(+), 6 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index de8c51f5..691b18d5 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -4,7 +4,9 @@ // import Combine +import Data import DesignSystem +import DI import Domain import SnapKit import UIKit @@ -21,7 +23,7 @@ public final class MonthlyCalendarViewController< // MARK: - Dependencies private let viewModel: MonthlyCalendarViewModel - private let detailViewControllerFactory: ((Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController) + private let container: DIContainer // MARK: - UI Components @@ -69,10 +71,10 @@ public final class MonthlyCalendarViewController< public init( viewModel: MonthlyCalendarViewModel, - detailViewControllerFactory: @escaping (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController + container: DIContainer ) { self.viewModel = viewModel - self.detailViewControllerFactory = detailViewControllerFactory + self.container = container super.init(nibName: nil, bundle: nil) } @@ -323,13 +325,186 @@ public final class MonthlyCalendarViewController< // MARK: - Navigation private func navigateToDetail(date: Date, records: [FoodRecord]) { - let detailVC = detailViewControllerFactory(date, records, nil, false) { [weak self] date in - self?.viewModel.input.send(.updateMonth(date)) - } + let detailVC = makeDetailViewController( + date: date, + records: records, + onDismissWithDate: { [weak self] date in + self?.viewModel.input.send(.updateMonth(date)) + } + ) detailVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(detailVC, animated: true) } + private typealias DetailVM = DetailViewModel< + FoodRecordRepositoryImpl>, + PushNotificationObserver + > + private typealias EditVM = EditFoodRecordViewModel< + FoodRecordRepositoryImpl> + > + private typealias AddressSearchVM = AddressSearchViewModel + private typealias AssetFetcher = FoodImageAssetFetcher + private typealias FetchUseCase = FetchFoodImageAssetUseCase + private typealias AuthUseCase = RequestPhotoAuthorizationUseCase + + private func makeDetailViewController( + date: Date, + records: [FoodRecord], + scrollToMealType: MealType? = nil, + shouldPopToRoot: Bool = false, + onDismissWithDate: ((Date) -> Void)? = nil + ) -> UIViewController { + guard let detailVM = try? container.resolve(DetailVM.self, argument: (date, records)) else { + fatalError("DetailViewModel not registered") + } + return DetailViewController( + viewModel: detailVM, + initialScrollTarget: scrollToMealType, + shouldPopToRoot: shouldPopToRoot, + onDismissWithDate: onDismissWithDate, + editViewControllerFactory: { [weak self] record in + self?.makeEditViewController(for: record) ?? UIViewController() + }, + presentImagePickerHandler: makeDetailImagePickerHandler() + ) + } + + private func makeEditViewController(for record: FoodRecord) -> UIViewController { + guard let editVM = try? container.resolve(EditVM.self, argument: record) else { + fatalError("EditFoodRecordViewModel not registered") + } + let addressSearchVCFactory: (Int, @escaping (AddressSearchResult) -> Void) -> UIViewController = { + [container] diaryId, onSelect in + guard let addressVM = try? container.resolve(AddressSearchVM.self, argument: diaryId) else { + fatalError("AddressSearchViewModel not registered") + } + return AddressSearchViewController(viewModel: addressVM, onAddressSelected: onSelect) + } + return EditFoodRecordViewController(viewModel: editVM, addressSearchViewControllerFactory: addressSearchVCFactory) + } + + private func makeDetailImagePickerHandler() + -> (UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void + { + return { [weak self] nav, date, onSelected in + guard let self else { return } + self.presentImagePicker( + from: nav, + date: date, + configuration: .withMaxSelectionCount(10), + autoPreselectByProbability: true, + loadPreviewImages: false, + onSelected: { assets, _ in onSelected(assets) } + ) + } + } + + private func presentImagePicker( + from nav: UINavigationController, + date: Date, + configuration: ImagePickerConfiguration, + autoPreselectByProbability: Bool, + loadPreviewImages: Bool, + onSelected: @escaping ([any ImageAssetable], [UIImage]) -> Void + ) { + guard let fetchUseCase = try? container.resolve(FetchUseCase.self), + let imageProvider = try? container.resolve(UIImageLoader.self), + let authUseCase = try? container.resolve(AuthUseCase.self) + else { + fatalError("ImagePicker dependencies not registered") + } + + Task { @MainActor in + if !authUseCase.isAuthorized() { + let status = await authUseCase.execute() + if status == .denied || status == .restricted { + Self.presentPhotoAccessDeniedAlert(on: nav) + return + } + } + + do { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) + let photosByDate = try await fetchUseCase.execute(from: startOfDay, to: endOfDay) + let foodImageAssets = photosByDate[startOfDay] ?? [] + let photos = foodImageAssets.map { $0.imageAsset } + + let preselectedFoodPhotoIds: Set = + autoPreselectByProbability + ? Set(foodImageAssets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) + : [] + + let picker = ImagePickerViewController( + photos: photos, + preselectedFoodPhotoIds: preselectedFoodPhotoIds, + imageProvider: imageProvider, + configuration: configuration + ) + + var cancellable: AnyCancellable? + cancellable = picker.resultPublisher + .sink { [weak nav] result in + defer { cancellable = nil } + switch result { + case .selected(let assets): + nav?.popViewController(animated: true) + if loadPreviewImages { + Task { @MainActor in + var previewImages: [UIImage] = [] + for asset in assets { + if let image = try? await imageProvider.loadImage( + for: asset, + targetSize: CGSize(width: 300, height: 300) + ) { + previewImages.append(image) + } + } + onSelected(assets, previewImages) + } + } else { + onSelected(assets, []) + } + case .cancelled: + break + } + } + + nav.pushViewController(picker, animated: true) + } catch { + Self.presentPhotoLoadFailedAlert(error: error, on: nav) + } + } + } + + private static func presentPhotoAccessDeniedAlert(on nav: UINavigationController) { + let alert = UIAlertController( + title: "사진 접근 권한 필요", + message: "음식 사진을 추가하려면 사진 라이브러리 접근 권한이 필요합니다. 설정에서 권한을 허용해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction( + UIAlertAction(title: "설정으로 이동", style: .default) { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) + nav.topViewController?.present(alert, animated: true) + } + + private static func presentPhotoLoadFailedAlert(error: Error, on nav: UINavigationController) { + let alert = UIAlertController( + title: "사진 불러오기 실패", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + nav.topViewController?.present(alert, animated: true) + } + private func showErrorAlert(_ error: Error) { let alert = UIAlertController(title: "오류", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "확인", style: .default)) From 5768bf75a6e5768e9c9a152bf8cd8d8326f77a6a Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 16:57:02 +0900 Subject: [PATCH 55/97] =?UTF-8?q?chore:=20SceneDelegate=20DI=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/SceneDelegate.swift | 47 ++++++++++++++++++----- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 4f95c449..66b5a148 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -28,7 +28,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { #endif guard let windowScene = scene as? UIWindowScene else { return } window = UIWindow(windowScene: windowScene) - window?.rootViewController = AppFlowController(container: container) + + guard let appFlowController = try? container.resolve(AppFlowController.self) else { + fatalError("AppFlowController Failed Resolve") + } + + window?.rootViewController = appFlowController window?.makeKeyAndVisible() } } @@ -192,15 +197,24 @@ extension SceneDelegate { container.register(InitialLaunchStorage.self) { _ in InitialLaunchStorage() } + + container.register(CoachmarkStoring.self) { _ in + CoachmarkStorage() + } } fileprivate func registerDomain() { + container.register(LoginSession.self, scope: .container) { _ in + LoginSession() + } + container.register(FinalizeAppleLoginUseCase.self) { resolver in guard let repository = resolver.resolve(AuthRepository.self), let pushTokenStorage = resolver.resolve(PushTokenStoring.self), let notificationAuthProvider = resolver.resolve( NotificationAuthorizationProviding.self), let launchStorage = resolver.resolve(InitialLaunchStorage.self), + let loginSession = resolver.resolve(LoginSession.self), let deviceId = UIDevice.current.identifierForVendor?.uuidString else { fatalError("FinalizeAppleLoginUseCase dependencies not registered") @@ -212,7 +226,8 @@ extension SceneDelegate { osVersion: UIDevice.current.systemVersion, pushTokenStorage: pushTokenStorage, notificationAuthorizationProvider: notificationAuthProvider, - initialLaunchStorage: launchStorage + initialLaunchStorage: launchStorage, + loginSession: loginSession ) } @@ -373,17 +388,19 @@ extension SceneDelegate { } container.register(LogoutUseCase.self) { resolver in - guard let authRepository = resolver.resolve(AuthRepository.self) else { - fatalError("AuthRepository not registered") + guard let authRepository = resolver.resolve(AuthRepository.self), + let loginSession = resolver.resolve(LoginSession.self) else { + fatalError("LogoutUseCase dependencies not registered") } - return LogoutUseCase(authRepository: authRepository) + return LogoutUseCase(authRepository: authRepository, loginSession: loginSession) } container.register(WithdrawUserUseCase.self) { resolver in - guard let authRepository = resolver.resolve(AuthRepository.self) else { - fatalError("AuthRepository not registered") + guard let authRepository = resolver.resolve(AuthRepository.self), + let loginSession = resolver.resolve(LoginSession.self) else { + fatalError("WithdrawUserUseCase dependencies not registered") } - return WithdrawUserUseCase(authRepository: authRepository) + return WithdrawUserUseCase(authRepository: authRepository, loginSession: loginSession) } container.register( @@ -497,7 +514,8 @@ extension SceneDelegate { >.self ), let pushObserver = resolver.resolve(PushNotificationObserver.self), - let getNicknameUseCase = resolver.resolve(GetNicknameUseCase.self) + let getNicknameUseCase = resolver.resolve(GetNicknameUseCase.self), + let coachmarkStorage = resolver.resolve(CoachmarkStoring.self) else { fatalError("WeeklyCalendarViewModel dependencies not registered") } @@ -507,7 +525,8 @@ extension SceneDelegate { loadWeeklyCalendarDataUseCase: loadWeeklyUseCase, saveFoodRecordUseCase: saveFoodRecordUseCase, pushNotificationObserver: pushObserver, - getNicknameUseCase: getNicknameUseCase + getNicknameUseCase: getNicknameUseCase, + coachmarkStorage: coachmarkStorage ) } @@ -662,6 +681,14 @@ extension SceneDelegate { getAppVersionUseCase: getAppVersionUseCase ) } + + container.register(MainSceneProducer.self, scope: .container) { _ in + MainSceneProducer(container: DIContainer.shared) + } + + container.register(AppFlowController.self, scope: .container) { _ in + AppFlowController(container: DIContainer.shared) + } } #if DEBUG From 3ec1d8d9bbb69c1354aa490a5dd2f4c98e30a782 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 22:18:42 +0900 Subject: [PATCH 56/97] =?UTF-8?q?feat:=20Presentation/Core=EC=97=90=20Coor?= =?UTF-8?q?dinator=20=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20Fa?= =?UTF-8?q?ctories=20=EA=B5=AC=EC=A1=B0=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Core/Coordinator.swift | 25 +++++++ .../Sources/Core/SceneFactories.swift | 25 +++++++ .../Sources/Main/MainViewController.swift | 73 ------------------- 3 files changed, 50 insertions(+), 73 deletions(-) create mode 100644 FoodDiary/Presentation/Sources/Core/Coordinator.swift create mode 100644 FoodDiary/Presentation/Sources/Core/SceneFactories.swift delete mode 100644 FoodDiary/Presentation/Sources/Main/MainViewController.swift diff --git a/FoodDiary/Presentation/Sources/Core/Coordinator.swift b/FoodDiary/Presentation/Sources/Core/Coordinator.swift new file mode 100644 index 00000000..0d36e469 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/Coordinator.swift @@ -0,0 +1,25 @@ +// +// Coordinator.swift +// Presentation +// + +import UIKit + +public protocol Coordinator: AnyObject { + var childCoordinators: [any Coordinator] { get set } + func start() +} + +public extension Coordinator { + func addChild(_ coordinator: any Coordinator) { + childCoordinators.append(coordinator) + } + + func removeChild(_ coordinator: any Coordinator) { + childCoordinators = childCoordinators.filter { $0 !== coordinator } + } +} + +public protocol SceneTransitioning: AnyObject { + func transition(to viewController: UIViewController) +} diff --git a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift new file mode 100644 index 00000000..609b28ab --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift @@ -0,0 +1,25 @@ +// +// SceneProducing.swift +// Presentation +// + +import UIKit + +public struct Factories { + public let login: any LoginSceneProducing + public let calendar: any CalendarSceneProducing + public let insight: any InsightSceneProducing + public let myPage: any MyPageSceneProducing + + public init( + login: any LoginSceneProducing, + calendar: any CalendarSceneProducing, + insight: any InsightSceneProducing, + myPage: any MyPageSceneProducing + ) { + self.login = login + self.calendar = calendar + self.insight = insight + self.myPage = myPage + } +} diff --git a/FoodDiary/Presentation/Sources/Main/MainViewController.swift b/FoodDiary/Presentation/Sources/Main/MainViewController.swift deleted file mode 100644 index ac8609de..00000000 --- a/FoodDiary/Presentation/Sources/Main/MainViewController.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// MainViewController.swift -// Presentation -// -// Created by 강대훈 on 1/23/26. -// - -import Combine -import Domain -import UIKit - -public final class MainViewController: UIViewController { - private let authRepository: AuthRepository - private let didLogoutSubject = PassthroughSubject() - - public var didLogoutPublisher: AnyPublisher { - didLogoutSubject.eraseToAnyPublisher() - } - - public init(authRepository: AuthRepository) { - self.authRepository = authRepository - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func viewDidLoad() { - super.viewDidLoad() - configureUI() - } -} - -private extension MainViewController { - func configureUI() { - view.backgroundColor = .systemBackground - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textColor = .white - label.text = "메인화면" - - let logoutButton = UIButton(type: .system) - logoutButton.translatesAutoresizingMaskIntoConstraints = false - logoutButton.setTitle("로그아웃", for: .normal) - logoutButton.addTarget(self, action: #selector(logoutButtonTapped), for: .touchUpInside) - - view.addSubview(label) - view.addSubview(logoutButton) - - NSLayoutConstraint.activate([ - label.centerYAnchor.constraint(equalTo: view.centerYAnchor), - label.centerXAnchor.constraint(equalTo: view.centerXAnchor), - - logoutButton.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 24), - logoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - ]) - } - - @objc - func logoutButtonTapped() { - do { - try authRepository.logout() - didLogoutSubject.send() - } catch { - // TODO: 에러 처리 전략 확정 후 구체적인 UI 처리 - print("로그아웃 실패: \(error)") - } - } -} - From 7c4fb52e0a883a8f560fd16413bf93876d850b9e Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 22:18:49 +0900 Subject: [PATCH 57/97] =?UTF-8?q?feat:=20ViewTransitionHandling=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EB=B0=8F=20DefaultViewT?= =?UTF-8?q?ransitionHandler=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultViewTransitionHandler.swift | 38 +++++++++++++++++++ .../Transition/ViewTransitionHandling.swift | 10 +++++ 2 files changed, 48 insertions(+) create mode 100644 FoodDiary/Presentation/Sources/Core/Transition/DefaultViewTransitionHandler.swift create mode 100644 FoodDiary/Presentation/Sources/Core/Transition/ViewTransitionHandling.swift diff --git a/FoodDiary/Presentation/Sources/Core/Transition/DefaultViewTransitionHandler.swift b/FoodDiary/Presentation/Sources/Core/Transition/DefaultViewTransitionHandler.swift new file mode 100644 index 00000000..ee41526f --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/Transition/DefaultViewTransitionHandler.swift @@ -0,0 +1,38 @@ +// +// DefaultViewTransitionHandler.swift +// Presentation +// + +import SnapKit +import UIKit + +public final class DefaultViewTransitionHandler: ViewTransitionHandling { + private weak var currentChild: UIViewController? + + public init() {} + + public func transition(from parent: UIViewController, to viewController: UIViewController) { + let previousChild = currentChild + + parent.addChild(viewController) + parent.view.addSubview(viewController.view) + viewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + viewController.view.alpha = 0 + + UIView.animate( + withDuration: 0.3, + animations: { + previousChild?.view.alpha = 0 + viewController.view.alpha = 1 + }, + completion: { _ in + previousChild?.willMove(toParent: nil) + previousChild?.view.removeFromSuperview() + previousChild?.removeFromParent() + + viewController.didMove(toParent: parent) + self.currentChild = viewController + } + ) + } +} diff --git a/FoodDiary/Presentation/Sources/Core/Transition/ViewTransitionHandling.swift b/FoodDiary/Presentation/Sources/Core/Transition/ViewTransitionHandling.swift new file mode 100644 index 00000000..be881ed8 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/Transition/ViewTransitionHandling.swift @@ -0,0 +1,10 @@ +// +// ViewTransitionHandling.swift +// Presentation +// + +import UIKit + +public protocol ViewTransitionHandling: AnyObject { + func transition(from parent: UIViewController, to viewController: UIViewController) +} From d17016ef34a0b5c8d72495a4ce124a22f62dbc3c Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 22:18:53 +0900 Subject: [PATCH 58/97] =?UTF-8?q?refactor:=20Feature=20Coordinator=20?= =?UTF-8?q?=EB=B0=8F=20Factory=EB=A5=BC=20App=20=E2=86=92=20Presentation?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Sources/CalendarCoordinator.swift | 34 ------- FoodDiary/App/Sources/Coordinator.swift | 28 ------ .../App/Sources/InsightCoordinator.swift | 26 ----- FoodDiary/App/Sources/LoginCoordinator.swift | 26 ----- FoodDiary/App/Sources/MainSceneProducer.swift | 95 ------------------- FoodDiary/App/Sources/MyPageCoordinator.swift | 28 ------ .../Coordinator/CalendarCoordinator.swift | 27 ++++++ .../Coordinator/CalendarSceneFactory.swift | 59 ++++++++++++ .../Coordinator/InsightCoordinator.swift | 23 +++++ .../Coordinator/InsightSceneFactory.swift | 26 +++++ .../Login/Coordinator/LoginCoordinator.swift | 23 +++++ .../Login/Coordinator/LoginSceneFactory.swift | 22 +++++ .../Coordinator/MyPageCoordinator.swift | 25 +++++ .../Coordinator/MyPageSceneFactory.swift | 46 +++++++++ 14 files changed, 251 insertions(+), 237 deletions(-) delete mode 100644 FoodDiary/App/Sources/CalendarCoordinator.swift delete mode 100644 FoodDiary/App/Sources/Coordinator.swift delete mode 100644 FoodDiary/App/Sources/InsightCoordinator.swift delete mode 100644 FoodDiary/App/Sources/LoginCoordinator.swift delete mode 100644 FoodDiary/App/Sources/MainSceneProducer.swift delete mode 100644 FoodDiary/App/Sources/MyPageCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift create mode 100644 FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift create mode 100644 FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift create mode 100644 FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift diff --git a/FoodDiary/App/Sources/CalendarCoordinator.swift b/FoodDiary/App/Sources/CalendarCoordinator.swift deleted file mode 100644 index 14052d94..00000000 --- a/FoodDiary/App/Sources/CalendarCoordinator.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// CalendarCoordinator.swift -// App -// -// Created by 강대훈 on 4/11/26. -// - -import Presentation -import UIKit - -final class CalendarCoordinator: Coordinator { - var childCoordinators: [Coordinator] = [] - weak var parentCoordinator: MainCoordinator? - - private let sceneProducer: MainSceneProducer - private(set) var navigationController: UINavigationController? - - init(sceneProducer: MainSceneProducer) { - self.sceneProducer = sceneProducer - } - - func start() {} - - func makeViewController() -> CalendarViewController { - CalendarViewController( - weeklyVC: sceneProducer.makeWeeklyCalendarVC(), - monthlyVC: sceneProducer.makeMonthlyCalendarVC() - ) - } - - func configure(navigationController: UINavigationController) { - self.navigationController = navigationController - } -} diff --git a/FoodDiary/App/Sources/Coordinator.swift b/FoodDiary/App/Sources/Coordinator.swift deleted file mode 100644 index a73a7705..00000000 --- a/FoodDiary/App/Sources/Coordinator.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Coordinator.swift -// App -// -// Created by 강대훈 on 4/8/26. -// - -import Foundation -import UIKit - -protocol Coordinator: AnyObject { - var childCoordinators: [Coordinator] { get set } - func start() -} - -extension Coordinator { - func addChild(_ coordinator: Coordinator) { - childCoordinators.append(coordinator) - } - - func removeChild(_ coordinator: Coordinator) { - childCoordinators = childCoordinators.filter { $0 !== coordinator } - } -} - -protocol SceneTransitioning: AnyObject { - func transition(to viewController: UIViewController) -} diff --git a/FoodDiary/App/Sources/InsightCoordinator.swift b/FoodDiary/App/Sources/InsightCoordinator.swift deleted file mode 100644 index 09bf6228..00000000 --- a/FoodDiary/App/Sources/InsightCoordinator.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// InsightCoordinator.swift -// App -// -// Created by 강대훈 on 4/11/26. -// - -import Presentation -import UIKit - -final class InsightCoordinator: Coordinator { - var childCoordinators: [Coordinator] = [] - weak var parentCoordinator: MainCoordinator? - - private let sceneProducer: MainSceneProducer - - init(sceneProducer: MainSceneProducer) { - self.sceneProducer = sceneProducer - } - - func start() {} - - func makeViewController() -> UIViewController { - sceneProducer.makeInsightScene() - } -} diff --git a/FoodDiary/App/Sources/LoginCoordinator.swift b/FoodDiary/App/Sources/LoginCoordinator.swift deleted file mode 100644 index ee472dbc..00000000 --- a/FoodDiary/App/Sources/LoginCoordinator.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// LoginCoordinator.swift -// App -// -// Created by 강대훈 on 4/10/26. -// - -import Presentation -import UIKit - -final class LoginCoordinator: Coordinator { - var childCoordinators: [Coordinator] = [] - weak var parentCoordinator: AppCoordinator? - weak var sceneTransitioner: SceneTransitioning? - - private let sceneProducer: MainSceneProducer - - init(sceneProducer: MainSceneProducer) { - self.sceneProducer = sceneProducer - } - - func start() { - let loginVC = sceneProducer.makeLoginScene() - sceneTransitioner?.transition(to: loginVC) - } -} diff --git a/FoodDiary/App/Sources/MainSceneProducer.swift b/FoodDiary/App/Sources/MainSceneProducer.swift deleted file mode 100644 index 0601f964..00000000 --- a/FoodDiary/App/Sources/MainSceneProducer.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// MainSceneProducer.swift -// App -// -// Created by 강대훈 on 4/8/26. -// - -import DI -import Data -import Domain -import Presentation -import UIKit - -final class MainSceneProducer { - private let container: DIContainer - - init(container: DIContainer) { - self.container = container - } -} - -// MARK: - Scene Factory - -extension MainSceneProducer { - func makeLoginScene() -> LoginViewController { - guard let viewModel = try? container.resolve(LoginViewModel.self) else { - fatalError("LoginViewModel Failed Resolve") - } - return LoginViewController(viewModel: viewModel) - } - - func makeInsightScene() -> UIViewController { - typealias InsightVM = InsightViewModel< - InsightRepositoryImpl> - > - guard let insightVM = try? container.resolve(InsightVM.self) else { - fatalError("InsightViewModel not registered") - } - return InsightViewController(viewModel: insightVM) - } - - func makeOnboardingScene() -> OnboardingViewController { - return OnboardingViewController() - } - - func makeMyPageScene() -> MyPageViewController { - guard let vm = try? container.resolve(MyPageViewModel.self) else { - fatalError("MyPageViewModel not registered") - } - return MyPageViewController(viewModel: vm) - } -} - -// MARK: - Calendar Scene Helpers - -extension MainSceneProducer { - func makeWeeklyCalendarVC() -> UIViewController { - guard let imageProvider = try? container.resolve(UIImageLoader.self) else { - fatalError("WeeklyCalendarViewController dependencies not registered") - } - - typealias WeeklyVM = WeeklyCalendarViewModel< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher, - PhotoAuthorizationFetcher, - PushNotificationObserver - > - - guard let weeklyViewModel = try? container.resolve(WeeklyVM.self) else { - fatalError("WeeklyCalendarViewModel not registered") - } - - return WeeklyCalendarViewController( - viewModel: weeklyViewModel, - imageProvider: imageProvider, - container: container - ) - } - - func makeMonthlyCalendarVC() -> UIViewController { - typealias MonthlyVM = MonthlyCalendarViewModel< - FoodRecordRepositoryImpl>, - PhotoAuthorizationFetcher - > - - guard let monthlyViewModel = try? container.resolve(MonthlyVM.self) else { - fatalError("MonthlyCalendarViewModel not registered") - } - - return MonthlyCalendarViewController( - viewModel: monthlyViewModel, - container: container - ) - } -} diff --git a/FoodDiary/App/Sources/MyPageCoordinator.swift b/FoodDiary/App/Sources/MyPageCoordinator.swift deleted file mode 100644 index 5c13a2c5..00000000 --- a/FoodDiary/App/Sources/MyPageCoordinator.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// MyPageCoordinator.swift -// App -// -// Created by 강대훈 on 4/11/26. -// - -import Presentation -import UIKit - -final class MyPageCoordinator: Coordinator { - var childCoordinators: [Coordinator] = [] - weak var parentCoordinator: MainCoordinator? - - private let sceneProducer: MainSceneProducer - private weak var navigationController: UINavigationController? - - init(sceneProducer: MainSceneProducer, navigationController: UINavigationController) { - self.sceneProducer = sceneProducer - self.navigationController = navigationController - } - - func start() { - let myPageVC = sceneProducer.makeMyPageScene() - myPageVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(myPageVC, animated: true) - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift new file mode 100644 index 00000000..f444327c --- /dev/null +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift @@ -0,0 +1,27 @@ +// +// CalendarCoordinator.swift +// Presentation +// + +import UIKit + +public final class CalendarCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factory: any CalendarSceneProducing + public private(set) var navigationController: UINavigationController? + + public init(factory: any CalendarSceneProducing) { + self.factory = factory + } + + public func start() {} + + public func makeViewController() -> CalendarViewController { + CalendarViewController( + weeklyVC: factory.makeWeeklyCalendarVC(), + monthlyVC: factory.makeMonthlyCalendarVC() + ) + } +} diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift new file mode 100644 index 00000000..544d8999 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -0,0 +1,59 @@ +// +// CalendarSceneFactory.swift +// Presentation +// + +import DI +import Data +import UIKit + +public protocol CalendarSceneProducing { + func makeWeeklyCalendarVC() -> UIViewController + func makeMonthlyCalendarVC() -> UIViewController +} + +public final class CalendarSceneFactory: CalendarSceneProducing { + public typealias WeeklyVM = WeeklyCalendarViewModel< + FoodRecordRepositoryImpl>, + FoodImageAssetFetcher, + PhotoAuthorizationFetcher, + PushNotificationObserver + > + public typealias MonthlyVM = MonthlyCalendarViewModel< + FoodRecordRepositoryImpl>, + PhotoAuthorizationFetcher + > + + private let weeklyVM: WeeklyVM + private let monthlyVM: MonthlyVM + private let imageProvider: UIImageLoader + // WeeklyCalendarVC / MonthlyCalendarVC 의 하위 화면 생성에 전달 — Factory 내부에서 resolve 하지 않음 + private let container: DIContainer + + public init( + weeklyVM: WeeklyVM, + monthlyVM: MonthlyVM, + imageProvider: UIImageLoader, + container: DIContainer + ) { + self.weeklyVM = weeklyVM + self.monthlyVM = monthlyVM + self.imageProvider = imageProvider + self.container = container + } + + public func makeWeeklyCalendarVC() -> UIViewController { + WeeklyCalendarViewController( + viewModel: weeklyVM, + imageProvider: imageProvider, + container: container + ) + } + + public func makeMonthlyCalendarVC() -> UIViewController { + MonthlyCalendarViewController( + viewModel: monthlyVM, + container: container + ) + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift new file mode 100644 index 00000000..d6704b0c --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift @@ -0,0 +1,23 @@ +// +// InsightCoordinator.swift +// Presentation +// + +import UIKit + +public final class InsightCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factory: any InsightSceneProducing + + public init(factory: any InsightSceneProducing) { + self.factory = factory + } + + public func start() {} + + public func makeViewController() -> UIViewController { + factory.makeInsightScene() + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift new file mode 100644 index 00000000..fde05045 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift @@ -0,0 +1,26 @@ +// +// InsightSceneFactory.swift +// Presentation +// + +import Data +import Domain +import UIKit + +public protocol InsightSceneProducing { + func makeInsightScene() -> UIViewController +} + +public final class InsightSceneFactory: InsightSceneProducing { + public typealias UseCase = FetchInsightUseCase>> + + private let useCase: UseCase + + public init(useCase: UseCase) { + self.useCase = useCase + } + + public func makeInsightScene() -> UIViewController { + InsightViewController(viewModel: InsightViewModel(fetchInsightUseCase: useCase)) + } +} diff --git a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift new file mode 100644 index 00000000..32ed2a16 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift @@ -0,0 +1,23 @@ +// +// LoginCoordinator.swift +// Presentation +// + +import UIKit + +public final class LoginCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + public weak var sceneTransitioner: SceneTransitioning? + + private let factory: any LoginSceneProducing + + public init(factory: any LoginSceneProducing) { + self.factory = factory + } + + public func start() { + let loginVC = factory.makeLoginScene() + sceneTransitioner?.transition(to: loginVC) + } +} diff --git a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift new file mode 100644 index 00000000..35b2e0d9 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift @@ -0,0 +1,22 @@ +// +// LoginSceneFactory.swift +// Presentation +// + +import Domain + +public protocol LoginSceneProducing { + func makeLoginScene() -> LoginViewController +} + +public final class LoginSceneFactory: LoginSceneProducing { + private let useCase: FinalizeAppleLoginUseCase + + public init(useCase: FinalizeAppleLoginUseCase) { + self.useCase = useCase + } + + public func makeLoginScene() -> LoginViewController { + LoginViewController(viewModel: LoginViewModel(finalizeAppleLoginUseCase: useCase)) + } +} diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift new file mode 100644 index 00000000..a2a722dc --- /dev/null +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift @@ -0,0 +1,25 @@ +// +// MyPageCoordinator.swift +// Presentation +// + +import UIKit + +public final class MyPageCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factory: any MyPageSceneProducing + private weak var navigationController: UINavigationController? + + public init(factory: any MyPageSceneProducing, navigationController: UINavigationController?) { + self.factory = factory + self.navigationController = navigationController + } + + public func start() { + let myPageVC = factory.makeMyPageScene() + myPageVC.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(myPageVC, animated: true) + } +} diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift new file mode 100644 index 00000000..1b13e661 --- /dev/null +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift @@ -0,0 +1,46 @@ +// +// MyPageSceneFactory.swift +// Presentation +// + +import Domain + +public protocol MyPageSceneProducing { + func makeMyPageScene() -> MyPageViewController +} + +public final class MyPageSceneFactory: MyPageSceneProducing { + private let updateDeviceUseCase: UpdateDeviceNotificationSettingUseCase + private let notificationAuthProvider: NotificationAuthorizationProviding + private let logoutUseCase: LogoutUseCase + private let withdrawUserUseCase: WithdrawUserUseCase + private let getNicknameUseCase: GetNicknameUseCase + private let getAppVersionUseCase: GetAppVersionUseCase + + public init( + updateDeviceUseCase: UpdateDeviceNotificationSettingUseCase, + notificationAuthProvider: NotificationAuthorizationProviding, + logoutUseCase: LogoutUseCase, + withdrawUserUseCase: WithdrawUserUseCase, + getNicknameUseCase: GetNicknameUseCase, + getAppVersionUseCase: GetAppVersionUseCase + ) { + self.updateDeviceUseCase = updateDeviceUseCase + self.notificationAuthProvider = notificationAuthProvider + self.logoutUseCase = logoutUseCase + self.withdrawUserUseCase = withdrawUserUseCase + self.getNicknameUseCase = getNicknameUseCase + self.getAppVersionUseCase = getAppVersionUseCase + } + + public func makeMyPageScene() -> MyPageViewController { + MyPageViewController(viewModel: MyPageViewModel( + updateDeviceNotificationSettingUseCase: updateDeviceUseCase, + notificationAuthorizationProvider: notificationAuthProvider, + logoutUseCase: logoutUseCase, + withdrawUserUseCase: withdrawUserUseCase, + getNicknameUseCase: getNicknameUseCase, + getAppVersionUseCase: getAppVersionUseCase + )) + } +} From 81495ff6d978fa423892487ff3f84b7a3f4c2487 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 22:18:57 +0900 Subject: [PATCH 59/97] =?UTF-8?q?refactor:=20App=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20Coordinator=20=EB=B0=8F=20AppFlowController=20Prese?= =?UTF-8?q?ntation=20=EA=B5=AC=EC=A1=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppCoordinator.swift | 39 ++--- FoodDiary/App/Sources/AppFlowController.swift | 71 ++------- FoodDiary/App/Sources/MainCoordinator.swift | 26 ++-- FoodDiary/App/Sources/SceneDelegate.swift | 145 ++++++++---------- 4 files changed, 108 insertions(+), 173 deletions(-) diff --git a/FoodDiary/App/Sources/AppCoordinator.swift b/FoodDiary/App/Sources/AppCoordinator.swift index d2ef07fe..848624c7 100644 --- a/FoodDiary/App/Sources/AppCoordinator.swift +++ b/FoodDiary/App/Sources/AppCoordinator.swift @@ -5,7 +5,6 @@ // Created by 강대훈 on 4/8/26. // -import Combine import DI import Data import Domain @@ -13,22 +12,15 @@ import Presentation import UIKit final class AppCoordinator: Coordinator { - var childCoordinators: [Coordinator] = [] + var childCoordinators: [any Coordinator] = [] weak var sceneTransitioner: SceneTransitioning? private let container: DIContainer - private let sceneProducer: MainSceneProducer + private let factories: Factories - var currentNavigationController: UINavigationController? { - childCoordinators.compactMap { $0 as? MainCoordinator }.last?.navigationController - } - - init( - container: DIContainer, - sceneProducer: MainSceneProducer - ) { + init(factories: Factories, container: DIContainer) { + self.factories = factories self.container = container - self.sceneProducer = sceneProducer } func start() {} @@ -38,20 +30,20 @@ final class AppCoordinator: Coordinator { extension AppCoordinator { func pushLoginVC() { - let loginCoordinator = LoginCoordinator(sceneProducer: sceneProducer) + let loginCoordinator = LoginCoordinator(factory: factories.login) loginCoordinator.sceneTransitioner = sceneTransitioner loginCoordinator.parentCoordinator = self addChild(loginCoordinator) - + loginCoordinator.start() } func pushMainVC() { - let mainCoordinator = MainCoordinator(sceneProducer: sceneProducer) + let mainCoordinator = MainCoordinator(factories: factories) mainCoordinator.sceneTransitioner = sceneTransitioner mainCoordinator.parentCoordinator = self addChild(mainCoordinator) - + mainCoordinator.start() } @@ -76,10 +68,11 @@ extension AppCoordinator { return } - guard let navController = currentNavigationController else { - print("[DeepLink] NavigationController를 찾을 수 없음") - return - } + let navController = childCoordinators + .compactMap({ $0 as? MainCoordinator }) + .last?.childCoordinators + .compactMap({ $0 as? CalendarCoordinator }) + .last?.navigationController typealias DeepLinkDetailVM = DetailViewModel< FoodRecordRepositoryImpl>, @@ -90,12 +83,12 @@ extension AppCoordinator { } let detailVC = DetailViewController(viewModel: detailVM) - if let presented = navController.presentedViewController { + if let presented = navController?.presentedViewController { presented.dismiss(animated: false) { - navController.pushViewController(detailVC, animated: true) + navController?.pushViewController(detailVC, animated: true) } } else { - navController.pushViewController(detailVC, animated: true) + navController?.pushViewController(detailVC, animated: true) } } } diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index 9b1c444a..c3bdaf99 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -18,32 +18,24 @@ final class AppFlowController: UIViewController, SceneTransitioning { private typealias AssetFetcher = FoodImageAssetFetcher private typealias FetchUseCase = FetchFoodImageAssetUseCase - private var currentChild: UIViewController? private var networkCancellable: AnyCancellable? private var cancellables = Set() private let container: DIContainer private let loginSession: LoginSession private var splashView: SplashView? - private var pendingDeepLinkDate: String? - - private lazy var coordinator: AppCoordinator = { - guard let sceneProducer = try? container.resolve(MainSceneProducer.self) else { - fatalError("MainSceneProducer not registered") - } - let coordinator = AppCoordinator( - container: container, - sceneProducer: sceneProducer - ) - coordinator.sceneTransitioner = self - return coordinator - }() - - public init(container: DIContainer) { - guard let loginSession = try? container.resolve(LoginSession.self) else { - fatalError("LoginSession not registered") - } - self.container = container + private let coordinator: AppCoordinator + private let transitionHandler: ViewTransitionHandling + + public init( + appCoordinator: AppCoordinator, + loginSession: LoginSession, + container: DIContainer, + transitionHandler: ViewTransitionHandling + ) { + self.coordinator = appCoordinator self.loginSession = loginSession + self.container = container + self.transitionHandler = transitionHandler super.init(nibName: nil, bundle: nil) } @@ -118,8 +110,7 @@ final class AppFlowController: UIViewController, SceneTransitioning { } private func showOnboarding() { - guard let sceneProducer = try? container.resolve(MainSceneProducer.self) else { return } - let onboardingVC = sceneProducer.makeOnboardingScene() + let onboardingVC = OnboardingViewController() var cancellable: AnyCancellable? cancellable = onboardingVC.didCompletePublisher .receive(on: DispatchQueue.main) @@ -133,10 +124,6 @@ final class AppFlowController: UIViewController, SceneTransitioning { } private func navigateToDetailFromDeepLink(diaryDateString: String) { - guard coordinator.currentNavigationController != nil else { - pendingDeepLinkDate = diaryDateString - return - } coordinator.navigateToDetail(diaryDateString: diaryDateString) } } @@ -200,13 +187,6 @@ extension AppFlowController { if isLogin { coordinator.pushMainVC() registerForRemoteNotificationsAfterLogin() - if let deepLink = pendingDeepLinkDate { - pendingDeepLinkDate = nil - Task { [weak self] in - try? await Task.sleep(for: .seconds(0.5)) - await MainActor.run { self?.coordinator.navigateToDetail(diaryDateString: deepLink) } - } - } } else { coordinator.pushLoginVC() } @@ -272,29 +252,6 @@ extension AppFlowController { } func transition(to viewController: UIViewController) { - let previousChild = currentChild - - addChild(viewController) - view.addSubview(viewController.view) - viewController.view.snp.makeConstraints { - $0.edges.equalToSuperview() - } - viewController.view.alpha = 0 - - UIView.animate( - withDuration: 0.3, - animations: { - previousChild?.view.alpha = 0 - viewController.view.alpha = 1 - }, - completion: { _ in - previousChild?.willMove(toParent: nil) - previousChild?.view.removeFromSuperview() - previousChild?.removeFromParent() - - viewController.didMove(toParent: self) - self.currentChild = viewController - } - ) + transitionHandler.transition(from: self, to: viewController) } } diff --git a/FoodDiary/App/Sources/MainCoordinator.swift b/FoodDiary/App/Sources/MainCoordinator.swift index 5d75db05..7aff5f1a 100644 --- a/FoodDiary/App/Sources/MainCoordinator.swift +++ b/FoodDiary/App/Sources/MainCoordinator.swift @@ -10,26 +10,21 @@ import Presentation import UIKit final class MainCoordinator: Coordinator { - var childCoordinators: [Coordinator] = [] - weak var parentCoordinator: AppCoordinator? + var childCoordinators: [any Coordinator] = [] + weak var parentCoordinator: (any Coordinator)? weak var sceneTransitioner: SceneTransitioning? - private let sceneProducer: MainSceneProducer + private let factories: Factories private var cancellables = Set() - private let calendarCoordinator: CalendarCoordinator - private let insightCoordinator: InsightCoordinator - var navigationController: UINavigationController? { - calendarCoordinator.navigationController - } - - init(sceneProducer: MainSceneProducer) { - self.sceneProducer = sceneProducer - self.calendarCoordinator = CalendarCoordinator(sceneProducer: sceneProducer) - self.insightCoordinator = InsightCoordinator(sceneProducer: sceneProducer) + init(factories: Factories) { + self.factories = factories } func start() { + let calendarCoordinator = CalendarCoordinator(factory: factories.calendar) + let insightCoordinator = InsightCoordinator(factory: factories.insight) + calendarCoordinator.parentCoordinator = self insightCoordinator.parentCoordinator = self addChild(calendarCoordinator) @@ -48,15 +43,14 @@ final class MainCoordinator: Coordinator { .store(in: &cancellables) let navController = makeNavigationController(root: tabBarController) - calendarCoordinator.configure(navigationController: navController) sceneTransitioner?.transition(to: navController) } } extension MainCoordinator { func pushMyPageVC() { - guard let navController = navigationController else { return } - let myPageCoordinator = MyPageCoordinator(sceneProducer: sceneProducer, navigationController: navController) + let navController = (childCoordinators.first { $0 is CalendarCoordinator } as? CalendarCoordinator)?.navigationController + let myPageCoordinator = MyPageCoordinator(factory: factories.myPage, navigationController: navController) myPageCoordinator.parentCoordinator = self addChild(myPageCoordinator) myPageCoordinator.start() diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 66b5a148..7bd9c053 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -23,17 +23,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { registerDependencies() - #if DEBUG - // saveDebugImageToPhotoLibrary() - #endif guard let windowScene = scene as? UIWindowScene else { return } window = UIWindow(windowScene: windowScene) - guard let appFlowController = try? container.resolve(AppFlowController.self) else { - fatalError("AppFlowController Failed Resolve") - } - - window?.rootViewController = appFlowController + window?.rootViewController = makeAppFlowController() window?.makeKeyAndVisible() } } @@ -465,30 +458,6 @@ extension SceneDelegate { } fileprivate func registerPresentation() { - container.register(LoginViewModel.self, scope: .transient) { resolver in - guard let useCase = resolver.resolve(FinalizeAppleLoginUseCase.self) else { - fatalError("FinalizeAppleLoginUseCase not registered") - } - - 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>, @@ -660,59 +629,81 @@ extension SceneDelegate { ) } - container.register(MyPageViewModel.self, scope: .transient) { resolver in - guard let updateDeviceUseCase = resolver.resolve( - UpdateDeviceNotificationSettingUseCase.self - ), - let notificationAuthProvider = resolver.resolve(NotificationAuthorizationProviding.self), - let logoutUseCase = resolver.resolve(LogoutUseCase.self), - let withdrawUserUseCase = resolver.resolve(WithdrawUserUseCase.self), - let getNicknameUseCase = resolver.resolve(GetNicknameUseCase.self), - let getAppVersionUseCase = resolver.resolve(GetAppVersionUseCase.self) else { - fatalError("MyPageViewModel dependencies not registered") - } + } - return MyPageViewModel( - updateDeviceNotificationSettingUseCase: updateDeviceUseCase, - notificationAuthorizationProvider: notificationAuthProvider, + fileprivate func makeAppFlowController() -> AppFlowController { + // Login + guard let finalizeUseCase = try? container.resolve(FinalizeAppleLoginUseCase.self) else { + fatalError("FinalizeAppleLoginUseCase not registered") + } + + // Calendar + typealias WeeklyVM = WeeklyCalendarViewModel< + FoodRecordRepositoryImpl>, + FoodImageAssetFetcher, + PhotoAuthorizationFetcher, + PushNotificationObserver + > + typealias MonthlyVM = MonthlyCalendarViewModel< + FoodRecordRepositoryImpl>, + PhotoAuthorizationFetcher + > + guard let weeklyVM = try? container.resolve(WeeklyVM.self), + let monthlyVM = try? container.resolve(MonthlyVM.self), + let imageProvider = try? container.resolve(UIImageLoader.self) else { + fatalError("CalendarSceneFactory dependencies not registered") + } + + // Insight + typealias FetchInsightUC = FetchInsightUseCase< + InsightRepositoryImpl> + > + guard let fetchInsightUseCase = try? container.resolve(FetchInsightUC.self) else { + fatalError("FetchInsightUseCase not registered") + } + + // MyPage + guard let updateDeviceUseCase = try? container.resolve(UpdateDeviceNotificationSettingUseCase.self), + let notificationAuthProvider = try? container.resolve(NotificationAuthorizationProviding.self), + let logoutUseCase = try? container.resolve(LogoutUseCase.self), + let withdrawUserUseCase = try? container.resolve(WithdrawUserUseCase.self), + let getNicknameUseCase = try? container.resolve(GetNicknameUseCase.self), + let getAppVersionUseCase = try? container.resolve(GetAppVersionUseCase.self) else { + fatalError("MyPageSceneFactory dependencies not registered") + } + + let factories = Factories( + login: LoginSceneFactory(useCase: finalizeUseCase), + calendar: CalendarSceneFactory( + weeklyVM: weeklyVM, + monthlyVM: monthlyVM, + imageProvider: imageProvider, + container: container + ), + insight: InsightSceneFactory(useCase: fetchInsightUseCase), + myPage: MyPageSceneFactory( + updateDeviceUseCase: updateDeviceUseCase, + notificationAuthProvider: notificationAuthProvider, logoutUseCase: logoutUseCase, withdrawUserUseCase: withdrawUserUseCase, getNicknameUseCase: getNicknameUseCase, getAppVersionUseCase: getAppVersionUseCase ) - } + ) - container.register(MainSceneProducer.self, scope: .container) { _ in - MainSceneProducer(container: DIContainer.shared) + guard let loginSession = try? container.resolve(LoginSession.self) else { + fatalError("LoginSession not registered") } - container.register(AppFlowController.self, scope: .container) { _ in - AppFlowController(container: DIContainer.shared) - } + let appCoordinator = AppCoordinator(factories: factories, container: container) + let transitionHandler = DefaultViewTransitionHandler() + let appFlowController = AppFlowController( + appCoordinator: appCoordinator, + loginSession: loginSession, + container: container, + transitionHandler: transitionHandler + ) + appCoordinator.sceneTransitioner = appFlowController + return appFlowController } - - #if DEBUG - fileprivate func saveDebugImageToPhotoLibrary() { - // PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in - // guard status == .authorized || status == .limited else { return } - - // guard let path = Bundle.main.path(forResource: "food", ofType: "jpg"), - // let image = UIImage(contentsOfFile: path) - // else { return } - - // let calendar = Calendar.current - // let today = calendar.startOfDay(for: Date()) - - // for dayOffset in 0..<7 { - // guard - // let targetDate = calendar.date(byAdding: .day, value: -dayOffset, to: today) - // else { continue } - // PHPhotoLibrary.shared().performChanges { - // let request = PHAssetChangeRequest.creationRequestForAsset(from: image) - // request.creationDate = targetDate - // } - // } - // } - } - #endif } From 0f4b5f6532351476573e767afd3dcbd9d6ca393e Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sat, 11 Apr 2026 22:19:00 +0900 Subject: [PATCH 60/97] =?UTF-8?q?chore:=20InsightViewModel=20@MainActor=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift index c65812e3..4d1caabb 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift @@ -7,7 +7,6 @@ import Combine import Domain import Foundation -@MainActor public final class InsightViewModel { // MARK: - Output From 4a91a8d74efa50dbb12e0aaa7badbb60311a1272 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 14:39:34 +0900 Subject: [PATCH 61/97] =?UTF-8?q?feat:=20Factories=EC=97=90=20detail,=20im?= =?UTF-8?q?agePicker=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Core/SceneFactories.swift | 8 ++- .../Coordinator/DetailCoordinator.swift | 44 ++++++++++++ .../Coordinator/DetailSceneFactory.swift | 67 +++++++++++++++++++ .../Detail/Coordinator/DetailSceneInput.swift | 29 ++++++++ .../Coordinator/ImagePickerCoordinator.swift | 36 ++++++++++ .../Coordinator/ImagePickerSceneFactory.swift | 53 +++++++++++++++ .../Coordinator/ImagePickerSceneInput.swift | 17 +++++ 7 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift create mode 100644 FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneInput.swift create mode 100644 FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift create mode 100644 FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneInput.swift diff --git a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift index 609b28ab..222263b7 100644 --- a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift +++ b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift @@ -10,16 +10,22 @@ public struct Factories { public let calendar: any CalendarSceneProducing public let insight: any InsightSceneProducing public let myPage: any MyPageSceneProducing + public let detail: any DetailSceneProducing + public let imagePicker: any ImagePickerSceneProducing public init( login: any LoginSceneProducing, calendar: any CalendarSceneProducing, insight: any InsightSceneProducing, - myPage: any MyPageSceneProducing + myPage: any MyPageSceneProducing, + detail: any DetailSceneProducing, + imagePicker: any ImagePickerSceneProducing ) { self.login = login self.calendar = calendar self.insight = insight self.myPage = myPage + self.detail = detail + self.imagePicker = imagePicker } } diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift new file mode 100644 index 00000000..f99e827c --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift @@ -0,0 +1,44 @@ +// +// DetailCoordinator.swift +// Presentation +// + +import UIKit + +public final class DetailCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start() {} + + public func start(input: DetailSceneInput) { + let detailVC = factories.detail.makeDetailScene( + input: input, + imagePickerDelegate: self + ) + detailVC.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(detailVC, animated: true) + } +} + +// MARK: - ImagePickerDelegate + +extension DetailCoordinator: ImagePickerDelegate { + public func pushImagePicker(input: ImagePickerSceneInput) { + let coord = ImagePickerCoordinator( + factories: factories, + navigationController: navigationController + ) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) + } +} diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift new file mode 100644 index 00000000..006a4bc7 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift @@ -0,0 +1,67 @@ +// +// DetailSceneFactory.swift +// Presentation +// + +import Data +import DI +import Domain +import UIKit + +public protocol DetailSceneProducing { + func makeDetailScene(input: DetailSceneInput, imagePickerDelegate: (any ImagePickerDelegate)?) -> UIViewController +} + +public final class DetailSceneFactory: DetailSceneProducing { + private typealias DetailVM = DetailViewModel< + FoodRecordRepositoryImpl>, + PushNotificationObserver + > + private typealias EditVM = EditFoodRecordViewModel< + FoodRecordRepositoryImpl> + > + private typealias AddressSearchVM = AddressSearchViewModel + + private let container: DIContainer + + public init(container: DIContainer) { + self.container = container + } + + public func makeDetailScene(input: DetailSceneInput, imagePickerDelegate: (any ImagePickerDelegate)?) -> UIViewController { + guard let detailVM = try? container.resolve(DetailVM.self, argument: (input.date, input.records)) else { + fatalError("DetailViewModel not registered") + } + return DetailViewController( + viewModel: detailVM, + initialScrollTarget: input.scrollToMealType, + shouldPopToRoot: input.shouldPopToRoot, + onDismissWithDate: input.onDismissWithDate, + editViewControllerFactory: { [weak self] record in + self?.makeEditViewController(for: record) ?? UIViewController() + }, + imagePickerDelegate: imagePickerDelegate + ) + } +} + +// MARK: - Private + +private extension DetailSceneFactory { + func makeEditViewController(for record: FoodRecord) -> UIViewController { + guard let editVM = try? container.resolve(EditVM.self, argument: record) else { + fatalError("EditFoodRecordViewModel not registered") + } + let addressSearchVCFactory: (Int, @escaping (AddressSearchResult) -> Void) -> UIViewController = { + [container] diaryId, onSelect in + guard let addressVM = try? container.resolve(AddressSearchVM.self, argument: diaryId) else { + fatalError("AddressSearchViewModel not registered") + } + return AddressSearchViewController(viewModel: addressVM, onAddressSelected: onSelect) + } + return EditFoodRecordViewController( + viewModel: editVM, + addressSearchViewControllerFactory: addressSearchVCFactory + ) + } +} diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneInput.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneInput.swift new file mode 100644 index 00000000..50827e7c --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneInput.swift @@ -0,0 +1,29 @@ +// +// DetailSceneInput.swift +// Presentation +// + +import Domain +import Foundation + +public struct DetailSceneInput { + public let date: Date + public let records: [FoodRecord] + public let scrollToMealType: MealType? + public let shouldPopToRoot: Bool + public let onDismissWithDate: ((Date) -> Void)? + + public init( + date: Date, + records: [FoodRecord], + scrollToMealType: MealType? = nil, + shouldPopToRoot: Bool = false, + onDismissWithDate: ((Date) -> Void)? = nil + ) { + self.date = date + self.records = records + self.scrollToMealType = scrollToMealType + self.shouldPopToRoot = shouldPopToRoot + self.onDismissWithDate = onDismissWithDate + } +} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift new file mode 100644 index 00000000..7c0f62dc --- /dev/null +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift @@ -0,0 +1,36 @@ +// +// ImagePickerCoordinator.swift +// Presentation +// + +import Domain +import UIKit + +// MARK: - Protocol + +public protocol ImagePickerDelegate: AnyObject { + func pushImagePicker(input: ImagePickerSceneInput) +} + +// MARK: - Coordinator + +public final class ImagePickerCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start() {} + + public func start(input: ImagePickerSceneInput) { + guard let nav = navigationController else { return } + let vc = factories.imagePicker.makeScene(input: input) + nav.pushViewController(vc, animated: true) + } +} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift new file mode 100644 index 00000000..a200e901 --- /dev/null +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift @@ -0,0 +1,53 @@ +// +// ImagePickerSceneFactory.swift +// Presentation +// + +import Data +import Domain +import Photos +import UIKit + +// MARK: - Protocol + +public protocol ImagePickerSceneProducing { + func makeScene(input: ImagePickerSceneInput) -> UIViewController +} + +// MARK: - Factory + +public final class ImagePickerSceneFactory: ImagePickerSceneProducing { + private let imageProvider: UIImageLoader + private let fetchUseCase: FetchFoodImageAssetUseCase> + + public init( + imageProvider: UIImageLoader, + fetchUseCase: FetchFoodImageAssetUseCase> + ) { + self.imageProvider = imageProvider + self.fetchUseCase = fetchUseCase + } + + public func makeScene(input: ImagePickerSceneInput) -> UIViewController { + let date = input.date + let fetchUseCase = self.fetchUseCase + + let fetcher: () async throws -> (photos: [PHAsset], preselectedIds: Set) = { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + let byDate = try await fetchUseCase.execute(from: startOfDay, to: endOfDay) + let assets = byDate[startOfDay] ?? [] + let photos = assets.map { $0.imageAsset } + let preselectedIds = Set(assets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) + return (photos, preselectedIds) + } + + return ImagePickerViewController( + imageProvider: imageProvider, + configuration: .withMaxSelectionCount(10), + photosFetcher: fetcher, + onSelected: { assets in input.onSelected(assets) } + ) + } +} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneInput.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneInput.swift new file mode 100644 index 00000000..c095ee33 --- /dev/null +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneInput.swift @@ -0,0 +1,17 @@ +// +// ImagePickerSceneInput.swift +// Presentation +// + +import Domain +import Foundation + +public struct ImagePickerSceneInput { + public let date: Date + public let onSelected: ([any ImageAssetable]) -> Void + + public init(date: Date, onSelected: @escaping ([any ImageAssetable]) -> Void) { + self.date = date + self.onSelected = onSelected + } +} From 1f1c023cfcd1b65ba42959bddc8754200f83be38 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 14:39:38 +0900 Subject: [PATCH 62/97] =?UTF-8?q?refactor:=20=EA=B0=81=20Coordinator?= =?UTF-8?q?=EA=B0=80=20=EA=B0=9C=EB=B3=84=20Factory=20=EB=8C=80=EC=8B=A0?= =?UTF-8?q?=20Factories=20=EC=A0=84=EC=B2=B4=EB=A5=BC=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EB=B0=9B=EB=8F=84=EB=A1=9D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppCoordinator.swift | 26 ++++++------------- FoodDiary/App/Sources/MainCoordinator.swift | 13 +++++----- .../Coordinator/InsightCoordinator.swift | 8 +++--- .../Login/Coordinator/LoginCoordinator.swift | 8 +++--- .../Coordinator/MyPageCoordinator.swift | 8 +++--- 5 files changed, 27 insertions(+), 36 deletions(-) diff --git a/FoodDiary/App/Sources/AppCoordinator.swift b/FoodDiary/App/Sources/AppCoordinator.swift index 848624c7..6ab4b793 100644 --- a/FoodDiary/App/Sources/AppCoordinator.swift +++ b/FoodDiary/App/Sources/AppCoordinator.swift @@ -5,8 +5,6 @@ // Created by 강대훈 on 4/8/26. // -import DI -import Data import Domain import Presentation import UIKit @@ -15,12 +13,10 @@ final class AppCoordinator: Coordinator { var childCoordinators: [any Coordinator] = [] weak var sceneTransitioner: SceneTransitioning? - private let container: DIContainer private let factories: Factories - init(factories: Factories, container: DIContainer) { + init(factories: Factories) { self.factories = factories - self.container = container } func start() {} @@ -30,7 +26,7 @@ final class AppCoordinator: Coordinator { extension AppCoordinator { func pushLoginVC() { - let loginCoordinator = LoginCoordinator(factory: factories.login) + let loginCoordinator = LoginCoordinator(factories: factories) loginCoordinator.sceneTransitioner = sceneTransitioner loginCoordinator.parentCoordinator = self addChild(loginCoordinator) @@ -70,25 +66,19 @@ extension AppCoordinator { let navController = childCoordinators .compactMap({ $0 as? MainCoordinator }) - .last?.childCoordinators - .compactMap({ $0 as? CalendarCoordinator }) .last?.navigationController - typealias DeepLinkDetailVM = DetailViewModel< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > - guard let detailVM = try? container.resolve(DeepLinkDetailVM.self, argument: (date, [FoodRecord]())) else { - fatalError("DetailViewModel not registered") - } - let detailVC = DetailViewController(viewModel: detailVM) + let input = DetailSceneInput(date: date, records: []) + let coord = DetailCoordinator(factories: factories, navigationController: navController) + coord.parentCoordinator = self + addChild(coord) if let presented = navController?.presentedViewController { presented.dismiss(animated: false) { - navController?.pushViewController(detailVC, animated: true) + coord.start(input: input) } } else { - navController?.pushViewController(detailVC, animated: true) + coord.start(input: input) } } } diff --git a/FoodDiary/App/Sources/MainCoordinator.swift b/FoodDiary/App/Sources/MainCoordinator.swift index 7aff5f1a..fec98774 100644 --- a/FoodDiary/App/Sources/MainCoordinator.swift +++ b/FoodDiary/App/Sources/MainCoordinator.swift @@ -16,14 +16,15 @@ final class MainCoordinator: Coordinator { private let factories: Factories private var cancellables = Set() + var navigationController: UINavigationController? init(factories: Factories) { self.factories = factories } func start() { - let calendarCoordinator = CalendarCoordinator(factory: factories.calendar) - let insightCoordinator = InsightCoordinator(factory: factories.insight) + let calendarCoordinator = CalendarCoordinator(factories: factories) + let insightCoordinator = InsightCoordinator(factories: factories) calendarCoordinator.parentCoordinator = self insightCoordinator.parentCoordinator = self @@ -42,15 +43,15 @@ final class MainCoordinator: Coordinator { } .store(in: &cancellables) - let navController = makeNavigationController(root: tabBarController) - sceneTransitioner?.transition(to: navController) + navigationController = makeNavigationController(root: tabBarController) + calendarCoordinator.configure(navigationController: navigationController) + sceneTransitioner?.transition(to: navigationController!) } } extension MainCoordinator { func pushMyPageVC() { - let navController = (childCoordinators.first { $0 is CalendarCoordinator } as? CalendarCoordinator)?.navigationController - let myPageCoordinator = MyPageCoordinator(factory: factories.myPage, navigationController: navController) + let myPageCoordinator = MyPageCoordinator(factories: factories, navigationController: navigationController) myPageCoordinator.parentCoordinator = self addChild(myPageCoordinator) myPageCoordinator.start() diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift index d6704b0c..1868f131 100644 --- a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift @@ -9,15 +9,15 @@ public final class InsightCoordinator: Coordinator { public var childCoordinators: [any Coordinator] = [] public weak var parentCoordinator: (any Coordinator)? - private let factory: any InsightSceneProducing + private let factories: Factories - public init(factory: any InsightSceneProducing) { - self.factory = factory + public init(factories: Factories) { + self.factories = factories } public func start() {} public func makeViewController() -> UIViewController { - factory.makeInsightScene() + factories.insight.makeInsightScene() } } diff --git a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift index 32ed2a16..e3cdb321 100644 --- a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift @@ -10,14 +10,14 @@ public final class LoginCoordinator: Coordinator { public weak var parentCoordinator: (any Coordinator)? public weak var sceneTransitioner: SceneTransitioning? - private let factory: any LoginSceneProducing + private let factories: Factories - public init(factory: any LoginSceneProducing) { - self.factory = factory + public init(factories: Factories) { + self.factories = factories } public func start() { - let loginVC = factory.makeLoginScene() + let loginVC = factories.login.makeLoginScene() sceneTransitioner?.transition(to: loginVC) } } diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift index a2a722dc..ba50aa37 100644 --- a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift @@ -9,16 +9,16 @@ public final class MyPageCoordinator: Coordinator { public var childCoordinators: [any Coordinator] = [] public weak var parentCoordinator: (any Coordinator)? - private let factory: any MyPageSceneProducing + private let factories: Factories private weak var navigationController: UINavigationController? - public init(factory: any MyPageSceneProducing, navigationController: UINavigationController?) { - self.factory = factory + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories self.navigationController = navigationController } public func start() { - let myPageVC = factory.makeMyPageScene() + let myPageVC = factories.myPage.makeMyPageScene() myPageVC.hidesBottomBarWhenPushed = true navigationController?.pushViewController(myPageVC, animated: true) } From 012b58b81cc5c455a51954515403d73f00b1f798 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 14:39:43 +0900 Subject: [PATCH 63/97] =?UTF-8?q?refactor:=20VC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=ED=81=B4=EB=A1=9C=EC=A0=80=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=E2=86=92=20ImagePickerDelegate=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Calendar/CalendarViewController.swift | 6 + .../Coordinator/CalendarCoordinator.swift | 48 ++- .../Coordinator/CalendarSceneFactory.swift | 41 ++- .../MonthlyCalendarViewController.swift | 181 +---------- .../ViewModel/WeeklyCalendarViewModel.swift | 4 - .../WeeklyCalendarViewController.swift | 298 ++---------------- .../Sources/Detail/DetailViewController.swift | 19 +- 7 files changed, 108 insertions(+), 489 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift index 5f857d40..b23f8af1 100644 --- a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift @@ -10,12 +10,18 @@ import Combine import DesignSystem import SnapKit +public protocol CalendarViewControllerDelegate: AnyObject { + func pushDetail(input: DetailSceneInput) +} + public final class CalendarViewController: UIViewController { public enum ViewMode { case weekly case monthly } + public weak var delegate: (any CalendarViewControllerDelegate)? + private let weeklyVC: UIViewController private let monthlyVC: UIViewController private let currentModeSubject = CurrentValueSubject(.weekly) diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift index f444327c..54f97db3 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift @@ -5,23 +5,57 @@ import UIKit +// MARK: - CalendarCoordinator + public final class CalendarCoordinator: Coordinator { public var childCoordinators: [any Coordinator] = [] public weak var parentCoordinator: (any Coordinator)? + public weak var navigationController: UINavigationController? - private let factory: any CalendarSceneProducing - public private(set) var navigationController: UINavigationController? + private let factories: Factories - public init(factory: any CalendarSceneProducing) { - self.factory = factory + public init(factories: Factories) { + self.factories = factories } public func start() {} + public func configure(navigationController: UINavigationController?) { + self.navigationController = navigationController + } + public func makeViewController() -> CalendarViewController { - CalendarViewController( - weeklyVC: factory.makeWeeklyCalendarVC(), - monthlyVC: factory.makeMonthlyCalendarVC() + factories.calendar.makeCalendarViewController( + delegate: self, + imagePickerDelegate: self + ) + } +} + +// MARK: - CalendarViewControllerDelegate + +extension CalendarCoordinator: CalendarViewControllerDelegate { + public func pushDetail(input: DetailSceneInput) { + let detailCoordinator = DetailCoordinator( + factories: factories, + navigationController: navigationController + ) + detailCoordinator.parentCoordinator = self + addChild(detailCoordinator) + detailCoordinator.start(input: input) + } +} + +// MARK: - ImagePickerDelegate + +extension CalendarCoordinator: ImagePickerDelegate { + public func pushImagePicker(input: ImagePickerSceneInput) { + let imagePickerCoordinator = ImagePickerCoordinator( + factories: factories, + navigationController: navigationController ) + imagePickerCoordinator.parentCoordinator = self + addChild(imagePickerCoordinator) + imagePickerCoordinator.start(input: input) } } diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift index 544d8999..00909091 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -3,13 +3,15 @@ // Presentation // -import DI import Data +import Domain import UIKit public protocol CalendarSceneProducing { - func makeWeeklyCalendarVC() -> UIViewController - func makeMonthlyCalendarVC() -> UIViewController + func makeCalendarViewController( + delegate: any CalendarViewControllerDelegate, + imagePickerDelegate: (any ImagePickerDelegate)? + ) -> CalendarViewController } public final class CalendarSceneFactory: CalendarSceneProducing { @@ -26,34 +28,27 @@ public final class CalendarSceneFactory: CalendarSceneProducing { private let weeklyVM: WeeklyVM private let monthlyVM: MonthlyVM - private let imageProvider: UIImageLoader - // WeeklyCalendarVC / MonthlyCalendarVC 의 하위 화면 생성에 전달 — Factory 내부에서 resolve 하지 않음 - private let container: DIContainer - public init( - weeklyVM: WeeklyVM, - monthlyVM: MonthlyVM, - imageProvider: UIImageLoader, - container: DIContainer - ) { + public init(weeklyVM: WeeklyVM, monthlyVM: MonthlyVM) { self.weeklyVM = weeklyVM self.monthlyVM = monthlyVM - self.imageProvider = imageProvider - self.container = container } - public func makeWeeklyCalendarVC() -> UIViewController { - WeeklyCalendarViewController( + public func makeCalendarViewController( + delegate: any CalendarViewControllerDelegate, + imagePickerDelegate: (any ImagePickerDelegate)? + ) -> CalendarViewController { + let weeklyVC = WeeklyCalendarViewController( viewModel: weeklyVM, - imageProvider: imageProvider, - container: container + imagePickerDelegate: imagePickerDelegate, + delegate: delegate ) - } - - public func makeMonthlyCalendarVC() -> UIViewController { - MonthlyCalendarViewController( + let monthlyVC = MonthlyCalendarViewController( viewModel: monthlyVM, - container: container + delegate: delegate ) + let calendarVC = CalendarViewController(weeklyVC: weeklyVC, monthlyVC: monthlyVC) + calendarVC.delegate = delegate + return calendarVC } } diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index 691b18d5..cf084a5e 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -6,7 +6,6 @@ import Combine import Data import DesignSystem -import DI import Domain import SnapKit import UIKit @@ -23,7 +22,7 @@ public final class MonthlyCalendarViewController< // MARK: - Dependencies private let viewModel: MonthlyCalendarViewModel - private let container: DIContainer + private weak var delegate: (any CalendarViewControllerDelegate)? // MARK: - UI Components @@ -71,10 +70,10 @@ public final class MonthlyCalendarViewController< public init( viewModel: MonthlyCalendarViewModel, - container: DIContainer + delegate: (any CalendarViewControllerDelegate)? = nil ) { self.viewModel = viewModel - self.container = container + self.delegate = delegate super.init(nibName: nil, bundle: nil) } @@ -325,184 +324,14 @@ public final class MonthlyCalendarViewController< // MARK: - Navigation private func navigateToDetail(date: Date, records: [FoodRecord]) { - let detailVC = makeDetailViewController( + let input = DetailSceneInput( date: date, records: records, onDismissWithDate: { [weak self] date in self?.viewModel.input.send(.updateMonth(date)) } ) - detailVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(detailVC, animated: true) - } - - private typealias DetailVM = DetailViewModel< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > - private typealias EditVM = EditFoodRecordViewModel< - FoodRecordRepositoryImpl> - > - private typealias AddressSearchVM = AddressSearchViewModel - private typealias AssetFetcher = FoodImageAssetFetcher - private typealias FetchUseCase = FetchFoodImageAssetUseCase - private typealias AuthUseCase = RequestPhotoAuthorizationUseCase - - private func makeDetailViewController( - date: Date, - records: [FoodRecord], - scrollToMealType: MealType? = nil, - shouldPopToRoot: Bool = false, - onDismissWithDate: ((Date) -> Void)? = nil - ) -> UIViewController { - guard let detailVM = try? container.resolve(DetailVM.self, argument: (date, records)) else { - fatalError("DetailViewModel not registered") - } - return DetailViewController( - viewModel: detailVM, - initialScrollTarget: scrollToMealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: onDismissWithDate, - editViewControllerFactory: { [weak self] record in - self?.makeEditViewController(for: record) ?? UIViewController() - }, - presentImagePickerHandler: makeDetailImagePickerHandler() - ) - } - - private func makeEditViewController(for record: FoodRecord) -> UIViewController { - guard let editVM = try? container.resolve(EditVM.self, argument: record) else { - fatalError("EditFoodRecordViewModel not registered") - } - let addressSearchVCFactory: (Int, @escaping (AddressSearchResult) -> Void) -> UIViewController = { - [container] diaryId, onSelect in - guard let addressVM = try? container.resolve(AddressSearchVM.self, argument: diaryId) else { - fatalError("AddressSearchViewModel not registered") - } - return AddressSearchViewController(viewModel: addressVM, onAddressSelected: onSelect) - } - return EditFoodRecordViewController(viewModel: editVM, addressSearchViewControllerFactory: addressSearchVCFactory) - } - - private func makeDetailImagePickerHandler() - -> (UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void - { - return { [weak self] nav, date, onSelected in - guard let self else { return } - self.presentImagePicker( - from: nav, - date: date, - configuration: .withMaxSelectionCount(10), - autoPreselectByProbability: true, - loadPreviewImages: false, - onSelected: { assets, _ in onSelected(assets) } - ) - } - } - - private func presentImagePicker( - from nav: UINavigationController, - date: Date, - configuration: ImagePickerConfiguration, - autoPreselectByProbability: Bool, - loadPreviewImages: Bool, - onSelected: @escaping ([any ImageAssetable], [UIImage]) -> Void - ) { - guard let fetchUseCase = try? container.resolve(FetchUseCase.self), - let imageProvider = try? container.resolve(UIImageLoader.self), - let authUseCase = try? container.resolve(AuthUseCase.self) - else { - fatalError("ImagePicker dependencies not registered") - } - - Task { @MainActor in - if !authUseCase.isAuthorized() { - let status = await authUseCase.execute() - if status == .denied || status == .restricted { - Self.presentPhotoAccessDeniedAlert(on: nav) - return - } - } - - do { - let calendar = Calendar.current - let startOfDay = calendar.startOfDay(for: date) - let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) - let photosByDate = try await fetchUseCase.execute(from: startOfDay, to: endOfDay) - let foodImageAssets = photosByDate[startOfDay] ?? [] - let photos = foodImageAssets.map { $0.imageAsset } - - let preselectedFoodPhotoIds: Set = - autoPreselectByProbability - ? Set(foodImageAssets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) - : [] - - let picker = ImagePickerViewController( - photos: photos, - preselectedFoodPhotoIds: preselectedFoodPhotoIds, - imageProvider: imageProvider, - configuration: configuration - ) - - var cancellable: AnyCancellable? - cancellable = picker.resultPublisher - .sink { [weak nav] result in - defer { cancellable = nil } - switch result { - case .selected(let assets): - nav?.popViewController(animated: true) - if loadPreviewImages { - Task { @MainActor in - var previewImages: [UIImage] = [] - for asset in assets { - if let image = try? await imageProvider.loadImage( - for: asset, - targetSize: CGSize(width: 300, height: 300) - ) { - previewImages.append(image) - } - } - onSelected(assets, previewImages) - } - } else { - onSelected(assets, []) - } - case .cancelled: - break - } - } - - nav.pushViewController(picker, animated: true) - } catch { - Self.presentPhotoLoadFailedAlert(error: error, on: nav) - } - } - } - - private static func presentPhotoAccessDeniedAlert(on nav: UINavigationController) { - let alert = UIAlertController( - title: "사진 접근 권한 필요", - message: "음식 사진을 추가하려면 사진 라이브러리 접근 권한이 필요합니다. 설정에서 권한을 허용해 주세요.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "취소", style: .cancel)) - alert.addAction( - UIAlertAction(title: "설정으로 이동", style: .default) { _ in - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - }) - nav.topViewController?.present(alert, animated: true) - } - - private static func presentPhotoLoadFailedAlert(error: Error, on nav: UINavigationController) { - let alert = UIAlertController( - title: "사진 불러오기 실패", - message: error.localizedDescription, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - nav.topViewController?.present(alert, animated: true) + delegate?.pushDetail(input: input) } private func showErrorAlert(_ error: Error) { diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift index 5f5c9a35..c6e56c7c 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift @@ -367,8 +367,4 @@ extension WeeklyCalendarViewModel { return nextWeekStart <= today } - /// 특정 날짜의 사진 로드 - public func photos(for date: Date) async throws -> [FoodImageAsset] { - try await loadWeeklyCalendarDataUseCase.loadPhotos(for: date) - } } diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index 23d75bab..6040ec96 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -6,7 +6,6 @@ import Combine import Data import DesignSystem -import DI import Domain import SnapKit import UIKit @@ -19,18 +18,14 @@ public final class WeeklyCalendarViewController< RecordRepo: FoodRecordRepository, AssetRepo: FoodImageAssetRepository, AuthRepo: PhotoAuthorizationRepository, - ImageProvider: RenderableImageRepository, PushObserver: PushNotificationObserving ->: UIViewController where ImageProvider.Asset == AssetRepo.Asset { +>: UIViewController { // MARK: - Dependencies - private let viewModel: - WeeklyCalendarViewModel< - RecordRepo, AssetRepo, AuthRepo, PushObserver - > - private let imageProvider: ImageProvider - private let container: DIContainer + private let viewModel: WeeklyCalendarViewModel + private weak var imagePickerDelegate: (any ImagePickerDelegate)? + private weak var delegate: (any CalendarViewControllerDelegate)? // MARK: - UI Components @@ -54,30 +49,20 @@ public final class WeeklyCalendarViewController< return sv }() - private let imagePickerLoadingView: UIActivityIndicatorView = { - let indicator = UIActivityIndicatorView(style: .medium) - indicator.color = .gray400 - indicator.hidesWhenStopped = true - return indicator - }() - // MARK: - State private var cancellables = Set() - private var isLoadingImagePicker = false // MARK: - Init public init( - viewModel: WeeklyCalendarViewModel< - RecordRepo, AssetRepo, AuthRepo, PushObserver - >, - imageProvider: ImageProvider, - container: DIContainer + viewModel: WeeklyCalendarViewModel, + imagePickerDelegate: (any ImagePickerDelegate)? = nil, + delegate: (any CalendarViewControllerDelegate)? = nil ) { self.viewModel = viewModel - self.imageProvider = imageProvider - self.container = container + self.imagePickerDelegate = imagePickerDelegate + self.delegate = delegate super.init(nibName: nil, bundle: nil) } @@ -114,7 +99,6 @@ public final class WeeklyCalendarViewController< view.addSubview(recordPromptHeaderView) view.addSubview(headerView) view.addSubview(containerStackView) - view.addSubview(imagePickerLoadingView) } private func setupConstraints() { @@ -133,7 +117,6 @@ public final class WeeklyCalendarViewController< $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-34) } - } private func setupBindings() { @@ -293,26 +276,16 @@ public final class WeeklyCalendarViewController< .store(in: &cancellables) } - // MARK: - Image Picker Loading - - private func setImagePickerLoading(_ isLoading: Bool) { - isLoadingImagePicker = isLoading - bottomContentView.isUserInteractionEnabled = !isLoading - bottomContentView.alpha = isLoading ? 0.5 : 1.0 - } - // MARK: - Actions private func handleAddButtonTap() { - guard !isLoadingImagePicker else { return } - if viewModel.checkPhotoAuthorizationForAddingPhoto() { - Task { - await presentImagePicker() + let date = viewModel.state.selectedDate + imagePickerDelegate?.pushImagePicker( + input: ImagePickerSceneInput(date: date) { [weak self] assets in + let typed = assets.compactMap { $0 as? AssetRepo.Asset } + self?.viewModel.input.send(.saveSelectedPhotos(typed)) } - } else { - // 권한이 없으면 재요청 (notDetermined면 시스템 다이얼로그, 아니면 denied 이벤트 발생) - viewModel.input.send(.requestPhotoAuthorization) - } + ) } private func showPhotoAuthorizationDeniedAlert() { @@ -335,75 +308,13 @@ public final class WeeklyCalendarViewController< // MARK: - Navigation - @MainActor - private func presentImagePicker() async { - setImagePickerLoading(true) - defer { setImagePickerLoading(false) } - - let selectedDate = viewModel.state.selectedDate - - do { - let foodImageAssets = try await viewModel.photos(for: selectedDate) - let selectedPhotos = foodImageAssets.map { $0.imageAsset } - - // 음식 확률 0.6 이상인 사진 ID를 미리 선택 - let preselectedFoodPhotoIds = Set( - foodImageAssets - .filter { $0.foodProbability >= 0.6 } - .map { $0.id } - ) - - let picker = ImagePickerViewController( - photos: selectedPhotos, - preselectedFoodPhotoIds: preselectedFoodPhotoIds, - imageProvider: imageProvider, - configuration: .withMaxSelectionCount(10) - ) - - picker.resultPublisher - .sink { [weak self] result in - self?.handleImagePickerResult(result) - } - .store(in: &cancellables) - - navigationController?.pushViewController(picker, animated: true) - } catch { - showLoadErrorAlert(error) - } - } - - private func handleImagePickerResult(_ result: ImagePickerResult) { - switch result { - case .selected(let assets): - viewModel.input.send(.saveSelectedPhotos(assets)) - case .cancelled: - navigationController?.popViewController(animated: true) - } - } - - private func showLoadErrorAlert(_ error: Error) { - let alert = UIAlertController( - title: "불러오기 실패", - message: error.localizedDescription, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - present(alert, animated: true) - } - - private func showSaveErrorAlert(_ error: Error) { - let alert = UIAlertController( - title: "저장 실패", - message: error.localizedDescription, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - present(alert, animated: true) - } - - private func navigateToDetail(for date: Date, scrollTo mealType: MealType? = nil, shouldPopToRoot: Bool = false) { + private func navigateToDetail( + for date: Date, + scrollTo mealType: MealType? = nil, + shouldPopToRoot: Bool = false + ) { let records = viewModel.state.weekDays.records(for: date) - let detailVC = makeDetailViewController( + let input = DetailSceneInput( date: date, records: records, scrollToMealType: mealType, @@ -412,175 +323,26 @@ public final class WeeklyCalendarViewController< self?.viewModel.input.send(.refreshData(date)) } ) - navigationController?.pushViewController(detailVC, animated: true) - } - - private typealias DetailVM = DetailViewModel< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > - private typealias EditVM = EditFoodRecordViewModel< - FoodRecordRepositoryImpl> - > - private typealias AddressSearchVM = AddressSearchViewModel - private typealias AssetFetcher = FoodImageAssetFetcher - private typealias FetchUseCase = FetchFoodImageAssetUseCase - private typealias AuthUseCase = RequestPhotoAuthorizationUseCase - - private func makeDetailViewController( - date: Date, - records: [FoodRecord], - scrollToMealType: MealType? = nil, - shouldPopToRoot: Bool = false, - onDismissWithDate: ((Date) -> Void)? = nil - ) -> UIViewController { - guard let detailVM = try? container.resolve(DetailVM.self, argument: (date, records)) else { - fatalError("DetailViewModel not registered") - } - return DetailViewController( - viewModel: detailVM, - initialScrollTarget: scrollToMealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: onDismissWithDate, - editViewControllerFactory: { [weak self] record in - self?.makeEditViewController(for: record) ?? UIViewController() - }, - presentImagePickerHandler: makeDetailImagePickerHandler() - ) - } - - private func makeEditViewController(for record: FoodRecord) -> UIViewController { - guard let editVM = try? container.resolve(EditVM.self, argument: record) else { - fatalError("EditFoodRecordViewModel not registered") - } - let addressSearchVCFactory: (Int, @escaping (AddressSearchResult) -> Void) -> UIViewController = { - [container] diaryId, onSelect in - guard let addressVM = try? container.resolve(AddressSearchVM.self, argument: diaryId) else { - fatalError("AddressSearchViewModel not registered") - } - return AddressSearchViewController(viewModel: addressVM, onAddressSelected: onSelect) - } - return EditFoodRecordViewController(viewModel: editVM, addressSearchViewControllerFactory: addressSearchVCFactory) - } - - private func makeDetailImagePickerHandler() - -> (UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void - { - return { [weak self] nav, date, onSelected in - guard let self else { return } - self.presentImagePicker( - from: nav, - date: date, - configuration: .withMaxSelectionCount(10), - autoPreselectByProbability: true, - loadPreviewImages: false, - onSelected: { assets, _ in onSelected(assets) } - ) - } + delegate?.pushDetail(input: input) } - private func presentImagePicker( - from nav: UINavigationController, - date: Date, - configuration: ImagePickerConfiguration, - autoPreselectByProbability: Bool, - loadPreviewImages: Bool, - onSelected: @escaping ([any ImageAssetable], [UIImage]) -> Void - ) { - guard let fetchUseCase = try? container.resolve(FetchUseCase.self), - let imageProvider = try? container.resolve(UIImageLoader.self), - let authUseCase = try? container.resolve(AuthUseCase.self) - else { - fatalError("ImagePicker dependencies not registered") - } - - Task { @MainActor in - if !authUseCase.isAuthorized() { - let status = await authUseCase.execute() - if status == .denied || status == .restricted { - Self.presentPhotoAccessDeniedAlert(on: nav) - return - } - } - - do { - let calendar = Calendar.current - let startOfDay = calendar.startOfDay(for: date) - let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) - let photosByDate = try await fetchUseCase.execute(from: startOfDay, to: endOfDay) - let foodImageAssets = photosByDate[startOfDay] ?? [] - let photos = foodImageAssets.map { $0.imageAsset } - - let preselectedFoodPhotoIds: Set = - autoPreselectByProbability - ? Set(foodImageAssets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) - : [] - - let picker = ImagePickerViewController( - photos: photos, - preselectedFoodPhotoIds: preselectedFoodPhotoIds, - imageProvider: imageProvider, - configuration: configuration - ) - - var cancellable: AnyCancellable? - cancellable = picker.resultPublisher - .sink { [weak nav] result in - defer { cancellable = nil } - switch result { - case .selected(let assets): - nav?.popViewController(animated: true) - if loadPreviewImages { - Task { @MainActor in - var previewImages: [UIImage] = [] - for asset in assets { - if let image = try? await imageProvider.loadImage( - for: asset, - targetSize: CGSize(width: 300, height: 300) - ) { - previewImages.append(image) - } - } - onSelected(assets, previewImages) - } - } else { - onSelected(assets, []) - } - case .cancelled: - break - } - } - - nav.pushViewController(picker, animated: true) - } catch { - Self.presentPhotoLoadFailedAlert(error: error, on: nav) - } - } - } - - private static func presentPhotoAccessDeniedAlert(on nav: UINavigationController) { + private func showLoadErrorAlert(_ error: Error) { let alert = UIAlertController( - title: "사진 접근 권한 필요", - message: "음식 사진을 추가하려면 사진 라이브러리 접근 권한이 필요합니다. 설정에서 권한을 허용해 주세요.", + title: "불러오기 실패", + message: error.localizedDescription, preferredStyle: .alert ) - alert.addAction(UIAlertAction(title: "취소", style: .cancel)) - alert.addAction( - UIAlertAction(title: "설정으로 이동", style: .default) { _ in - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - }) - nav.topViewController?.present(alert, animated: true) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) } - private static func presentPhotoLoadFailedAlert(error: Error, on nav: UINavigationController) { + private func showSaveErrorAlert(_ error: Error) { let alert = UIAlertController( - title: "사진 불러오기 실패", + title: "저장 실패", message: error.localizedDescription, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "확인", style: .default)) - nav.topViewController?.present(alert, animated: true) + present(alert, animated: true) } } diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index b071560d..b395906b 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -32,8 +32,7 @@ public final class DetailViewController< private let viewModel: DetailViewModel private let onDismissWithDate: ((Date) -> Void)? private let editViewControllerFactory: ((FoodRecord) -> UIViewController)? - private let presentImagePickerHandler: - ((UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void)? + private weak var imagePickerDelegate: (any ImagePickerDelegate)? // MARK: - UI Components @@ -126,9 +125,7 @@ public final class DetailViewController< shouldPopToRoot: Bool = false, onDismissWithDate: ((Date) -> Void)? = nil, editViewControllerFactory: ((FoodRecord) -> UIViewController)? = nil, - presentImagePickerHandler: ( - (UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void - )? = nil + imagePickerDelegate: (any ImagePickerDelegate)? = nil ) { self.viewModel = viewModel self.pendingScrollTarget = initialScrollTarget @@ -136,7 +133,7 @@ public final class DetailViewController< self.shouldPopToRoot = shouldPopToRoot self.onDismissWithDate = onDismissWithDate self.editViewControllerFactory = editViewControllerFactory - self.presentImagePickerHandler = presentImagePickerHandler + self.imagePickerDelegate = imagePickerDelegate super.init(nibName: nil, bundle: nil) } @@ -516,12 +513,12 @@ public final class DetailViewController< } private func handleAddPhoto() { - guard let nav = navigationController else { return } let date = viewModel.state.currentDate - - presentImagePickerHandler?(nav, date) { [weak self] assets in - self?.viewModel.input.send(.saveSelectedPhotos(assets)) - } + imagePickerDelegate?.pushImagePicker( + input: ImagePickerSceneInput(date: date) { [weak self] assets in + self?.viewModel.input.send(.saveSelectedPhotos(assets)) + } + ) } private func showShareUnavailableAlert() { From 7dd2fa35743c7f70c0b3d8096f614c30c149ec56 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 14:39:48 +0900 Subject: [PATCH 64/97] =?UTF-8?q?refactor:=20ImagePickerViewController?= =?UTF-8?q?=EA=B0=80=20=EC=82=AC=EC=A7=84=20=EB=A1=9C=EB=93=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EB=82=B4?= =?UTF-8?q?=EC=9E=AC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImagePickerViewController.swift | 136 +++++++++--------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift index 7ace58e6..81e040bc 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift @@ -11,45 +11,6 @@ import UIKit // MARK: - ImagePickerViewController -/// 음식 사진을 선택할 수 있는 커스텀 이미지 피커 뷰 컨트롤러입니다. -/// -/// ## Overview -/// `ImagePickerViewController`는 `FoodImageAsset` 배열을 받아 그리드 형태로 표시하고, -/// 사용자가 사진을 선택하면 `resultPublisher`를 통해 결과를 전달합니다. -/// -/// ## Usage -/// ```swift -/// // 1. 피커 생성 -/// let picker = ImagePickerViewController( -/// photos: foodPhotos, -/// imageProvider: myImageProvider, -/// configuration: .default -/// ) -/// -/// // 2. 결과 구독 -/// picker.resultPublisher -/// .sink { result in -/// switch result { -/// case .selected(let photos): -/// // 선택된 사진 처리 -/// self.dismiss(animated: true) -/// case .cancelled: -/// // 취소 처리 -/// self.dismiss(animated: true) -/// } -/// } -/// .store(in: &cancellables) -/// -/// // 3. 피커 표시 -/// present(picker, animated: true) -/// ``` -/// -/// ## Configuration -/// `ImagePickerConfiguration`을 통해 다음 항목을 커스터마이징할 수 있습니다: -/// - `primaryColor`: 선택 테두리 및 버튼 색상 -/// - `maxSelectionCount`: 최대 선택 가능 수 (nil이면 무제한) -/// - `confirmButtonTitle`: 확인 버튼 텍스트 -/// public final class ImagePickerViewController< Asset: ImageAssetable, ImageProvider: RenderableImageRepository @@ -98,13 +59,15 @@ public final class ImagePickerViewController< private let resultSubject = PassthroughSubject, Never>() private var cancellables = Set() - private let photos: [Asset] - private let preselectedFoodPhotoIds: Set + private var photos: [Asset] = [] + private var preselectedFoodPhotoIds: Set = [] private let imageProvider: ImageProvider private let configuration: ImagePickerConfiguration - // preselected된 사진은 food/all 양쪽 섹션에 나타날 수 있어, id 기준 인덱스를 캐싱한다. - private let foodPhotos: [Asset] - private let indexPathsByPhotoId: [String: [IndexPath]] + private var foodPhotos: [Asset] = [] + private var indexPathsByPhotoId: [String: [IndexPath]] = [:] + + private let photosFetcher: () async throws -> (photos: [Asset], preselectedIds: Set) + private let onSelected: (([Asset]) -> Void)? // MARK: - State @@ -192,30 +155,16 @@ public final class ImagePickerViewController< // MARK: - Initialization - /// 이미지 피커 초기화 - /// - Parameters: - /// - photos: 표시할 사진 목록 - /// - preselectedFoodPhotoIds: 미리 선택될 음식 사진 ID 집합 - /// - imageProvider: 이미지 로딩 제공자 - /// - configuration: 피커 설정 public init( - photos: [Asset], - preselectedFoodPhotoIds: Set = [], imageProvider: ImageProvider, - configuration: ImagePickerConfiguration = .default + configuration: ImagePickerConfiguration = .default, + photosFetcher: @escaping () async throws -> (photos: [Asset], preselectedIds: Set), + onSelected: (([Asset]) -> Void)? = nil ) { - // 매 접근마다 filter하지 않도록 음식 섹션 데이터를 1회 계산해 둔다. - let foodPhotos = photos.filter { preselectedFoodPhotoIds.contains($0.id) } - - self.photos = photos - self.preselectedFoodPhotoIds = preselectedFoodPhotoIds self.imageProvider = imageProvider self.configuration = configuration - self.foodPhotos = foodPhotos - self.indexPathsByPhotoId = Self.makeIndexPathsByPhotoId( - allPhotos: photos, - foodPhotos: foodPhotos - ) + self.photosFetcher = photosFetcher + self.onSelected = onSelected super.init(nibName: nil, bundle: nil) } @@ -231,7 +180,17 @@ public final class ImagePickerViewController< setupNavigationBar() setupUI() setupConstraints() - applyPreselection() + loadPhotos() + + resultPublisher + .compactMap { if case .selected(let a) = $0 { return a } else { return nil } } + .first() + .receive(on: DispatchQueue.main) + .sink { [weak self] assets in + self?.navigationController?.popViewController(animated: true) + self?.onSelected?(assets) + } + .store(in: &cancellables) } public override func viewWillAppear(_ animated: Bool) { @@ -246,6 +205,35 @@ public final class ImagePickerViewController< } } + // MARK: - Data Loading + + private func loadPhotos() { + Task { @MainActor in + do { + let (photos, preselectedIds) = try await photosFetcher() + applyPhotos(photos, preselectedIds: preselectedIds) + } catch { + showLoadErrorAndPop(error) + } + } + } + + private func applyPhotos(_ photos: [Asset], preselectedIds: Set) { + self.photos = photos + self.preselectedFoodPhotoIds = preselectedIds + let foodPhotos = photos.filter { preselectedIds.contains($0.id) } + self.foodPhotos = foodPhotos + self.indexPathsByPhotoId = Self.makeIndexPathsByPhotoId( + allPhotos: photos, + foodPhotos: foodPhotos + ) + + emptyView.isHidden = !photos.isEmpty + collectionView.isHidden = photos.isEmpty + collectionView.reloadData() + applyPreselection() + } + // MARK: - Setup private func setupNavigationBar() { @@ -266,8 +254,8 @@ public final class ImagePickerViewController< view.addSubview(emptyView) view.addSubview(confirmButton) - emptyView.isHidden = !photos.isEmpty - collectionView.isHidden = photos.isEmpty + emptyView.isHidden = true + collectionView.isHidden = true } private func setupConstraints() { @@ -319,7 +307,6 @@ public final class ImagePickerViewController< selectedPhotoIds.insert(photo.id) } - // 같은 사진이 양쪽 섹션에 동시에 보일 수 있어, 매핑된 셀을 함께 갱신한다. let affectedIndexPaths = indexPathsByPhotoId[photo.id] ?? [indexPath] collectionView.reloadItems(at: affectedIndexPaths) @@ -330,7 +317,6 @@ public final class ImagePickerViewController< allPhotos: [Asset], foodPhotos: [Asset] ) -> [String: [IndexPath]] { - // photo id -> [food 섹션 indexPath, all 섹션 indexPath] var indexPathsById: [String: [IndexPath]] = [:] for (item, photo) in foodPhotos.enumerated() { @@ -392,6 +378,20 @@ public final class ImagePickerViewController< resultSubject.send(.selected(selectedAssets)) } + // MARK: - Error + + private func showLoadErrorAndPop(_ error: Error) { + let alert = UIAlertController( + title: "사진 불러오기 실패", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default) { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + }) + present(alert, animated: true) + } + // MARK: - UICollectionViewDataSource public func numberOfSections(in collectionView: UICollectionView) -> Int { From 6b029f3dfae02bd2d1e67017d1e60d0064d34bbc Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 14:39:51 +0900 Subject: [PATCH 65/97] =?UTF-8?q?chore:=20SceneDelegate=20ImagePicker=20au?= =?UTF-8?q?thUseCase=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20init=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/SceneDelegate.swift | 26 ++++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 7bd9c053..77ea11dd 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -672,14 +672,22 @@ extension SceneDelegate { fatalError("MyPageSceneFactory dependencies not registered") } + // ImagePicker + typealias FetchImageAssetUC = FetchFoodImageAssetUseCase< + FoodImageAssetFetcher + > + guard let fetchImageAssetUseCase = try? container.resolve(FetchImageAssetUC.self) else { + fatalError("ImagePickerCoordinator dependencies not registered") + } + + let imagePickerFactory = ImagePickerSceneFactory( + imageProvider: imageProvider, + fetchUseCase: fetchImageAssetUseCase + ) + let factories = Factories( login: LoginSceneFactory(useCase: finalizeUseCase), - calendar: CalendarSceneFactory( - weeklyVM: weeklyVM, - monthlyVM: monthlyVM, - imageProvider: imageProvider, - container: container - ), + calendar: CalendarSceneFactory(weeklyVM: weeklyVM, monthlyVM: monthlyVM), insight: InsightSceneFactory(useCase: fetchInsightUseCase), myPage: MyPageSceneFactory( updateDeviceUseCase: updateDeviceUseCase, @@ -688,14 +696,16 @@ extension SceneDelegate { withdrawUserUseCase: withdrawUserUseCase, getNicknameUseCase: getNicknameUseCase, getAppVersionUseCase: getAppVersionUseCase - ) + ), + detail: DetailSceneFactory(container: container), + imagePicker: imagePickerFactory ) guard let loginSession = try? container.resolve(LoginSession.self) else { fatalError("LoginSession not registered") } - let appCoordinator = AppCoordinator(factories: factories, container: container) + let appCoordinator = AppCoordinator(factories: factories) let transitionHandler = DefaultViewTransitionHandler() let appFlowController = AppFlowController( appCoordinator: appCoordinator, From f5bdfa53a45504550fb9229ba656ef91f4a1d18e Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 16:15:20 +0900 Subject: [PATCH 66/97] =?UTF-8?q?feat:=20Edit/AddressSearch=20Coordinator,?= =?UTF-8?q?=20Factory,=20Input,=20Flow=20=ED=8C=8C=EC=9D=BC=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 - EditCoordinator, EditSceneFactory, EditSceneInput, EditFlow 추가 - AddressSearchCoordinator, AddressSearchSceneFactory, AddressSearchSceneInput 추가 - CalendarFlow, DetailFlow enum 및 FlowEmitting 프로토콜 추가 --- .../AddressSearchCoordinator.swift | 29 +++++++++++ .../AddressSearchSceneFactory.swift | 37 +++++++++++++ .../Coordinator/AddressSearchSceneInput.swift | 16 ++++++ .../Calendar/Coordinator/CalendarFlow.swift | 15 ++++++ .../Detail/Coordinator/DetailFlow.swift | 15 ++++++ .../Coordinator/EditCoordinator.swift | 52 +++++++++++++++++++ .../EditFoodRecord/Coordinator/EditFlow.swift | 14 +++++ .../Coordinator/EditSceneFactory.swift | 35 +++++++++++++ .../Coordinator/EditSceneInput.swift | 14 +++++ 9 files changed, 227 insertions(+) create mode 100644 FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift create mode 100644 FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneInput.swift create mode 100644 FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift create mode 100644 FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift create mode 100644 FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift create mode 100644 FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift create mode 100644 FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneInput.swift diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift new file mode 100644 index 00000000..19462cca --- /dev/null +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift @@ -0,0 +1,29 @@ +// +// AddressSearchCoordinator.swift +// Presentation +// + +import UIKit + +// MARK: - Coordinator + +public final class AddressSearchCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start() {} + + public func start(input: AddressSearchSceneInput) { + guard let presentingVC = navigationController?.topViewController else { return } + let vc = factories.addressSearch.makeScene(input: input) + presentingVC.present(vc, animated: true) + } +} diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift new file mode 100644 index 00000000..27ce1923 --- /dev/null +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift @@ -0,0 +1,37 @@ +// +// AddressSearchSceneFactory.swift +// Presentation +// + +import Data +import DI +import Domain +import UIKit + +// MARK: - Protocol + +public protocol AddressSearchSceneProducing { + func makeScene(input: AddressSearchSceneInput) -> UIViewController +} + +// MARK: - Factory + +public final class AddressSearchSceneFactory: AddressSearchSceneProducing { + private typealias AddressSearchVM = AddressSearchViewModel + + private let container: DIContainer + + public init(container: DIContainer) { + self.container = container + } + + public func makeScene(input: AddressSearchSceneInput) -> UIViewController { + guard let addressVM = try? container.resolve(AddressSearchVM.self, argument: input.diaryId) else { + fatalError("AddressSearchViewModel not registered") + } + return AddressSearchViewController( + viewModel: addressVM, + onAddressSelected: input.onSelected + ) + } +} diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneInput.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneInput.swift new file mode 100644 index 00000000..4d0086af --- /dev/null +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneInput.swift @@ -0,0 +1,16 @@ +// +// AddressSearchSceneInput.swift +// Presentation +// + +import Domain + +public struct AddressSearchSceneInput { + public let diaryId: Int + public let onSelected: (AddressSearchResult) -> Void + + public init(diaryId: Int, onSelected: @escaping (AddressSearchResult) -> Void) { + self.diaryId = diaryId + self.onSelected = onSelected + } +} diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift new file mode 100644 index 00000000..37256ceb --- /dev/null +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift @@ -0,0 +1,15 @@ +// +// CalendarFlow.swift +// Presentation +// + +import Combine + +public enum CalendarFlow { + case pushDetail(DetailSceneInput) + case pushImagePicker(ImagePickerSceneInput) +} + +public protocol CalendarFlowEmitting: AnyObject { + var flowPublisher: AnyPublisher { get } +} diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift new file mode 100644 index 00000000..e7a2e409 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift @@ -0,0 +1,15 @@ +// +// DetailFlow.swift +// Presentation +// + +import Combine + +public enum DetailFlow { + case pushEdit(EditSceneInput) + case pushImagePicker(ImagePickerSceneInput) +} + +public protocol DetailFlowEmitting: AnyObject { + var flowPublisher: AnyPublisher { get } +} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift new file mode 100644 index 00000000..4ab897b5 --- /dev/null +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift @@ -0,0 +1,52 @@ +// +// EditCoordinator.swift +// Presentation +// + +import Combine +import UIKit + +// MARK: - Coordinator + +public final class EditCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + private var cancellables = Set() + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start() {} + + public func start(input: EditSceneInput) { + guard let nav = navigationController else { return } + let vc = factories.edit.makeScene(input: input) + nav.pushViewController(vc, animated: true) + if let flowVC = vc as? any EditFlowEmitting { + flowVC.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .presentAddressSearch(let input): + self?.presentAddressSearch(input: input) + } + } + .store(in: &cancellables) + } + } + + private func presentAddressSearch(input: AddressSearchSceneInput) { + let coord = AddressSearchCoordinator( + factories: factories, + navigationController: navigationController + ) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) + } +} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift new file mode 100644 index 00000000..14f5c5fa --- /dev/null +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift @@ -0,0 +1,14 @@ +// +// EditFlow.swift +// Presentation +// + +import Combine + +public enum EditFlow { + case presentAddressSearch(AddressSearchSceneInput) +} + +public protocol EditFlowEmitting: AnyObject { + var flowPublisher: AnyPublisher { get } +} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift new file mode 100644 index 00000000..0075d736 --- /dev/null +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift @@ -0,0 +1,35 @@ +// +// EditSceneFactory.swift +// Presentation +// + +import Data +import DI +import UIKit + +// MARK: - Protocol + +public protocol EditSceneProducing { + func makeScene(input: EditSceneInput) -> UIViewController +} + +// MARK: - Factory + +public final class EditSceneFactory: EditSceneProducing { + private typealias EditVM = EditFoodRecordViewModel< + FoodRecordRepositoryImpl> + > + + private let container: DIContainer + + public init(container: DIContainer) { + self.container = container + } + + public func makeScene(input: EditSceneInput) -> UIViewController { + guard let editVM = try? container.resolve(EditVM.self, argument: input.record) else { + fatalError("EditFoodRecordViewModel not registered") + } + return EditFoodRecordViewController(viewModel: editVM) + } +} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneInput.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneInput.swift new file mode 100644 index 00000000..1bf2b160 --- /dev/null +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneInput.swift @@ -0,0 +1,14 @@ +// +// EditSceneInput.swift +// Presentation +// + +import Domain + +public struct EditSceneInput { + public let record: FoodRecord + + public init(record: FoodRecord) { + self.record = record + } +} From e815dba7b9fa44e82385f31064aa0f541e6870cb Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 16:15:24 +0900 Subject: [PATCH 67/97] =?UTF-8?q?refactor:=20Factories=EC=97=90=20edit,=20?= =?UTF-8?q?addressSearch=20=ED=8C=A9=ED=86=A0=EB=A6=AC=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 --- FoodDiary/Presentation/Sources/Core/SceneFactories.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift index 222263b7..5f1d4f99 100644 --- a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift +++ b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift @@ -12,6 +12,8 @@ public struct Factories { public let myPage: any MyPageSceneProducing public let detail: any DetailSceneProducing public let imagePicker: any ImagePickerSceneProducing + public let edit: any EditSceneProducing + public let addressSearch: any AddressSearchSceneProducing public init( login: any LoginSceneProducing, @@ -19,7 +21,9 @@ public struct Factories { insight: any InsightSceneProducing, myPage: any MyPageSceneProducing, detail: any DetailSceneProducing, - imagePicker: any ImagePickerSceneProducing + imagePicker: any ImagePickerSceneProducing, + edit: any EditSceneProducing, + addressSearch: any AddressSearchSceneProducing ) { self.login = login self.calendar = calendar @@ -27,5 +31,7 @@ public struct Factories { self.myPage = myPage self.detail = detail self.imagePicker = imagePicker + self.edit = edit + self.addressSearch = addressSearch } } From a29f7cc4f0599275b40ebbcb1cfea35ea66b7b1e Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 16:15:32 +0900 Subject: [PATCH 68/97] =?UTF-8?q?refactor:=20Calendar=20Delegate=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=A0=9C=EA=B1=B0=20=E2=86=92=20Combine?= =?UTF-8?q?=20Flow=20Publisher=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CalendarViewControllerDelegate, ImagePickerDelegate 제거 - WeeklyVC, MonthlyVC에 flowSubject/flowPublisher 추가 및 CalendarFlowEmitting 채택 - CalendarViewController에 generic init 도입, 자식 VC publisher 병합 - CalendarSceneFactory가 CalendarViewController 직접 반환 - CalendarCoordinator가 flowPublisher 구독하여 화면 전환 처리 --- .../Calendar/CalendarViewController.swift | 24 +++++++++----- .../Coordinator/CalendarCoordinator.swift | 31 ++++++++++--------- .../Coordinator/CalendarSceneFactory.swift | 25 +++------------ .../MonthlyCalendarViewController.swift | 18 ++++++++--- .../WeeklyCalendarViewController.swift | 27 +++++++++------- 5 files changed, 67 insertions(+), 58 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift index b23f8af1..fc250ac3 100644 --- a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift @@ -5,14 +5,10 @@ // Created by 강대훈 on 2/13/26. // -import UIKit import Combine import DesignSystem import SnapKit - -public protocol CalendarViewControllerDelegate: AnyObject { - func pushDetail(input: DetailSceneInput) -} +import UIKit public final class CalendarViewController: UIViewController { public enum ViewMode { @@ -20,23 +16,35 @@ public final class CalendarViewController: UIViewController { case monthly } - public weak var delegate: (any CalendarViewControllerDelegate)? - private let weeklyVC: UIViewController private let monthlyVC: UIViewController private let currentModeSubject = CurrentValueSubject(.weekly) private var currentChild: UIViewController? + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } + private var cancellables = Set() + public var currentModePublisher: AnyPublisher { currentModeSubject.eraseToAnyPublisher() } - public init(weeklyVC: UIViewController, monthlyVC: UIViewController) { + public init( + weeklyVC: W, + monthlyVC: M + ) { self.weeklyVC = weeklyVC self.monthlyVC = monthlyVC super.init(nibName: nil, bundle: nil) + + Publishers.Merge(weeklyVC.flowPublisher, monthlyVC.flowPublisher) + .sink { [weak self] flow in self?.flowSubject.send(flow) } + .store(in: &cancellables) } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift index 54f97db3..068914b3 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift @@ -3,6 +3,7 @@ // Presentation // +import Combine import UIKit // MARK: - CalendarCoordinator @@ -13,6 +14,7 @@ public final class CalendarCoordinator: Coordinator { public weak var navigationController: UINavigationController? private let factories: Factories + private var cancellables = Set() public init(factories: Factories) { self.factories = factories @@ -25,17 +27,22 @@ public final class CalendarCoordinator: Coordinator { } public func makeViewController() -> CalendarViewController { - factories.calendar.makeCalendarViewController( - delegate: self, - imagePickerDelegate: self - ) + let calendarVC = factories.calendar.makeScene() + calendarVC.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .pushDetail(let input): + self?.pushDetail(input: input) + case .pushImagePicker(let input): + self?.pushImagePicker(input: input) + } + } + .store(in: &cancellables) + return calendarVC } -} - -// MARK: - CalendarViewControllerDelegate -extension CalendarCoordinator: CalendarViewControllerDelegate { - public func pushDetail(input: DetailSceneInput) { + private func pushDetail(input: DetailSceneInput) { let detailCoordinator = DetailCoordinator( factories: factories, navigationController: navigationController @@ -44,12 +51,8 @@ extension CalendarCoordinator: CalendarViewControllerDelegate { addChild(detailCoordinator) detailCoordinator.start(input: input) } -} - -// MARK: - ImagePickerDelegate -extension CalendarCoordinator: ImagePickerDelegate { - public func pushImagePicker(input: ImagePickerSceneInput) { + private func pushImagePicker(input: ImagePickerSceneInput) { let imagePickerCoordinator = ImagePickerCoordinator( factories: factories, navigationController: navigationController diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift index 00909091..263760e8 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -8,10 +8,7 @@ import Domain import UIKit public protocol CalendarSceneProducing { - func makeCalendarViewController( - delegate: any CalendarViewControllerDelegate, - imagePickerDelegate: (any ImagePickerDelegate)? - ) -> CalendarViewController + func makeScene() -> CalendarViewController } public final class CalendarSceneFactory: CalendarSceneProducing { @@ -34,21 +31,9 @@ public final class CalendarSceneFactory: CalendarSceneProducing { self.monthlyVM = monthlyVM } - public func makeCalendarViewController( - delegate: any CalendarViewControllerDelegate, - imagePickerDelegate: (any ImagePickerDelegate)? - ) -> CalendarViewController { - let weeklyVC = WeeklyCalendarViewController( - viewModel: weeklyVM, - imagePickerDelegate: imagePickerDelegate, - delegate: delegate - ) - let monthlyVC = MonthlyCalendarViewController( - viewModel: monthlyVM, - delegate: delegate - ) - let calendarVC = CalendarViewController(weeklyVC: weeklyVC, monthlyVC: monthlyVC) - calendarVC.delegate = delegate - return calendarVC + public func makeScene() -> CalendarViewController { + let weeklyVC = WeeklyCalendarViewController(viewModel: weeklyVM) + let monthlyVC = MonthlyCalendarViewController(viewModel: monthlyVM) + return CalendarViewController(weeklyVC: weeklyVC, monthlyVC: monthlyVC) } } diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index cf084a5e..3eede711 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -22,7 +22,13 @@ public final class MonthlyCalendarViewController< // MARK: - Dependencies private let viewModel: MonthlyCalendarViewModel - private weak var delegate: (any CalendarViewControllerDelegate)? + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } // MARK: - UI Components @@ -69,11 +75,9 @@ public final class MonthlyCalendarViewController< // MARK: - Init public init( - viewModel: MonthlyCalendarViewModel, - delegate: (any CalendarViewControllerDelegate)? = nil + viewModel: MonthlyCalendarViewModel ) { self.viewModel = viewModel - self.delegate = delegate super.init(nibName: nil, bundle: nil) } @@ -331,7 +335,7 @@ public final class MonthlyCalendarViewController< self?.viewModel.input.send(.updateMonth(date)) } ) - delegate?.pushDetail(input: input) + flowSubject.send(.pushDetail(input)) } private func showErrorAlert(_ error: Error) { @@ -341,6 +345,10 @@ public final class MonthlyCalendarViewController< } } +// MARK: - CalendarFlowEmitting + +extension MonthlyCalendarViewController: CalendarFlowEmitting {} + // MARK: - Constants extension MonthlyCalendarViewController { diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index 6040ec96..9a93104f 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -24,8 +24,13 @@ public final class WeeklyCalendarViewController< // MARK: - Dependencies private let viewModel: WeeklyCalendarViewModel - private weak var imagePickerDelegate: (any ImagePickerDelegate)? - private weak var delegate: (any CalendarViewControllerDelegate)? + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } // MARK: - UI Components @@ -56,13 +61,9 @@ public final class WeeklyCalendarViewController< // MARK: - Init public init( - viewModel: WeeklyCalendarViewModel, - imagePickerDelegate: (any ImagePickerDelegate)? = nil, - delegate: (any CalendarViewControllerDelegate)? = nil + viewModel: WeeklyCalendarViewModel ) { self.viewModel = viewModel - self.imagePickerDelegate = imagePickerDelegate - self.delegate = delegate super.init(nibName: nil, bundle: nil) } @@ -280,12 +281,12 @@ public final class WeeklyCalendarViewController< private func handleAddButtonTap() { let date = viewModel.state.selectedDate - imagePickerDelegate?.pushImagePicker( - input: ImagePickerSceneInput(date: date) { [weak self] assets in + flowSubject.send(.pushImagePicker( + ImagePickerSceneInput(date: date) { [weak self] assets in let typed = assets.compactMap { $0 as? AssetRepo.Asset } self?.viewModel.input.send(.saveSelectedPhotos(typed)) } - ) + )) } private func showPhotoAuthorizationDeniedAlert() { @@ -323,7 +324,7 @@ public final class WeeklyCalendarViewController< self?.viewModel.input.send(.refreshData(date)) } ) - delegate?.pushDetail(input: input) + flowSubject.send(.pushDetail(input)) } private func showLoadErrorAlert(_ error: Error) { @@ -346,3 +347,7 @@ public final class WeeklyCalendarViewController< present(alert, animated: true) } } + +// MARK: - CalendarFlowEmitting + +extension WeeklyCalendarViewController: CalendarFlowEmitting {} From 79f5086fb5fda436e4c530b7e1197f0d65d2e7a8 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 16:15:39 +0900 Subject: [PATCH 69/97] =?UTF-8?q?refactor:=20Detail/Edit=20Delegate=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=A0=9C=EA=B1=B0=20=E2=86=92=20Combine?= =?UTF-8?q?=20Flow=20Publisher=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImagePickerDelegate, EditDelegate, AddressSearchDelegate 제거 - DetailViewController에 flowSubject/flowPublisher 추가 및 DetailFlowEmitting 채택 - EditFoodRecordViewController에 flowSubject/flowPublisher 추가 및 EditFlowEmitting 채택 - DetailSceneFactory, EditSceneFactory가 UIViewController 직접 반환 - DetailCoordinator, EditCoordinator가 flowPublisher 구독하여 화면 전환 처리 --- .../Coordinator/DetailCoordinator.swift | 40 ++++++++++++++----- .../Coordinator/DetailSceneFactory.swift | 35 ++-------------- .../Sources/Detail/DetailViewController.swift | 29 ++++++++------ .../EditFoodRecordViewController.swift | 26 ++++++++---- .../Coordinator/ImagePickerCoordinator.swift | 6 --- 5 files changed, 67 insertions(+), 69 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift index f99e827c..90aa3ccb 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift @@ -3,6 +3,7 @@ // Presentation // +import Combine import UIKit public final class DetailCoordinator: Coordinator { @@ -11,6 +12,7 @@ public final class DetailCoordinator: Coordinator { private let factories: Factories private weak var navigationController: UINavigationController? + private var cancellables = Set() public init(factories: Factories, navigationController: UINavigationController?) { self.factories = factories @@ -20,19 +22,25 @@ public final class DetailCoordinator: Coordinator { public func start() {} public func start(input: DetailSceneInput) { - let detailVC = factories.detail.makeDetailScene( - input: input, - imagePickerDelegate: self - ) - detailVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(detailVC, animated: true) + let vc = factories.detail.makeDetailScene(input: input) + vc.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(vc, animated: true) + if let flowVC = vc as? any DetailFlowEmitting { + flowVC.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .pushEdit(let input): + self?.pushEdit(input: input) + case .pushImagePicker(let input): + self?.pushImagePicker(input: input) + } + } + .store(in: &cancellables) + } } -} - -// MARK: - ImagePickerDelegate -extension DetailCoordinator: ImagePickerDelegate { - public func pushImagePicker(input: ImagePickerSceneInput) { + private func pushImagePicker(input: ImagePickerSceneInput) { let coord = ImagePickerCoordinator( factories: factories, navigationController: navigationController @@ -41,4 +49,14 @@ extension DetailCoordinator: ImagePickerDelegate { addChild(coord) coord.start(input: input) } + + private func pushEdit(input: EditSceneInput) { + let coord = EditCoordinator( + factories: factories, + navigationController: navigationController + ) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) + } } diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift index 006a4bc7..75104bd9 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift @@ -9,7 +9,7 @@ import Domain import UIKit public protocol DetailSceneProducing { - func makeDetailScene(input: DetailSceneInput, imagePickerDelegate: (any ImagePickerDelegate)?) -> UIViewController + func makeDetailScene(input: DetailSceneInput) -> UIViewController } public final class DetailSceneFactory: DetailSceneProducing { @@ -17,10 +17,6 @@ public final class DetailSceneFactory: DetailSceneProducing { FoodRecordRepositoryImpl>, PushNotificationObserver > - private typealias EditVM = EditFoodRecordViewModel< - FoodRecordRepositoryImpl> - > - private typealias AddressSearchVM = AddressSearchViewModel private let container: DIContainer @@ -28,7 +24,7 @@ public final class DetailSceneFactory: DetailSceneProducing { self.container = container } - public func makeDetailScene(input: DetailSceneInput, imagePickerDelegate: (any ImagePickerDelegate)?) -> UIViewController { + public func makeDetailScene(input: DetailSceneInput) -> UIViewController { guard let detailVM = try? container.resolve(DetailVM.self, argument: (input.date, input.records)) else { fatalError("DetailViewModel not registered") } @@ -36,32 +32,7 @@ public final class DetailSceneFactory: DetailSceneProducing { viewModel: detailVM, initialScrollTarget: input.scrollToMealType, shouldPopToRoot: input.shouldPopToRoot, - onDismissWithDate: input.onDismissWithDate, - editViewControllerFactory: { [weak self] record in - self?.makeEditViewController(for: record) ?? UIViewController() - }, - imagePickerDelegate: imagePickerDelegate - ) - } -} - -// MARK: - Private - -private extension DetailSceneFactory { - func makeEditViewController(for record: FoodRecord) -> UIViewController { - guard let editVM = try? container.resolve(EditVM.self, argument: record) else { - fatalError("EditFoodRecordViewModel not registered") - } - let addressSearchVCFactory: (Int, @escaping (AddressSearchResult) -> Void) -> UIViewController = { - [container] diaryId, onSelect in - guard let addressVM = try? container.resolve(AddressSearchVM.self, argument: diaryId) else { - fatalError("AddressSearchViewModel not registered") - } - return AddressSearchViewController(viewModel: addressVM, onAddressSelected: onSelect) - } - return EditFoodRecordViewController( - viewModel: editVM, - addressSearchViewControllerFactory: addressSearchVCFactory + onDismissWithDate: input.onDismissWithDate ) } } diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index b395906b..cb426b65 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -31,8 +31,13 @@ public final class DetailViewController< private let viewModel: DetailViewModel private let onDismissWithDate: ((Date) -> Void)? - private let editViewControllerFactory: ((FoodRecord) -> UIViewController)? - private weak var imagePickerDelegate: (any ImagePickerDelegate)? + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } // MARK: - UI Components @@ -123,17 +128,13 @@ public final class DetailViewController< initialScrollTarget: MealType? = nil, scrollToFirstRecord: Bool = true, shouldPopToRoot: Bool = false, - onDismissWithDate: ((Date) -> Void)? = nil, - editViewControllerFactory: ((FoodRecord) -> UIViewController)? = nil, - imagePickerDelegate: (any ImagePickerDelegate)? = nil + onDismissWithDate: ((Date) -> Void)? = nil ) { self.viewModel = viewModel self.pendingScrollTarget = initialScrollTarget self.scrollToFirstRecord = scrollToFirstRecord self.shouldPopToRoot = shouldPopToRoot self.onDismissWithDate = onDismissWithDate - self.editViewControllerFactory = editViewControllerFactory - self.imagePickerDelegate = imagePickerDelegate super.init(nibName: nil, bundle: nil) } @@ -503,9 +504,8 @@ public final class DetailViewController< } private func handleEdit(record: FoodRecord) { - guard let editVC = editViewControllerFactory?(record) else { return } pendingScrollTarget = record.mealType - navigationController?.pushViewController(editVC, animated: true) + flowSubject.send(.pushEdit(EditSceneInput(record: record))) } @objc private func floatingAddButtonTapped() { @@ -514,11 +514,11 @@ public final class DetailViewController< private func handleAddPhoto() { let date = viewModel.state.currentDate - imagePickerDelegate?.pushImagePicker( - input: ImagePickerSceneInput(date: date) { [weak self] assets in + flowSubject.send(.pushImagePicker( + ImagePickerSceneInput(date: date) { [weak self] assets in self?.viewModel.input.send(.saveSelectedPhotos(assets)) } - ) + )) } private func showShareUnavailableAlert() { @@ -592,6 +592,7 @@ public final class DetailViewController< private func scrollToMealSection(_ mealType: MealType) { let targetSection = mealSectionView(for: mealType) let sectionFrame = targetSection.convert(targetSection.bounds, to: scrollView) + let targetOffset = CGPoint( x: 0, y: sectionFrame.origin.y - scrollView.adjustedContentInset.top @@ -599,3 +600,7 @@ public final class DetailViewController< scrollView.setContentOffset(targetOffset, animated: false) } } + +// MARK: - DetailFlowEmitting + +extension DetailViewController: DetailFlowEmitting {} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift index c83fcf5d..94c46e77 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift @@ -29,7 +29,14 @@ public final class EditFoodRecordViewController< // MARK: - Dependencies private let viewModel: EditFoodRecordViewModel - private let addressSearchViewControllerFactory: ((Int, @escaping (AddressSearchResult) -> Void) -> UIViewController)? + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } + // MARK: - UI Components private let scrollView: UIScrollView = { @@ -109,11 +116,9 @@ public final class EditFoodRecordViewController< // MARK: - Init public init( - viewModel: EditFoodRecordViewModel, - addressSearchViewControllerFactory: ((Int, @escaping (AddressSearchResult) -> Void) -> UIViewController)? = nil + viewModel: EditFoodRecordViewModel ) { self.viewModel = viewModel - self.addressSearchViewControllerFactory = addressSearchViewControllerFactory super.init(nibName: nil, bundle: nil) } @@ -405,10 +410,11 @@ public final class EditFoodRecordViewController< private func presentAddressSearchModal() { guard let diaryId = Int(viewModel.state.originalRecord.id) else { return } - guard let addressSearchVC = addressSearchViewControllerFactory?(diaryId, { [weak self] result in - self?.viewModel.input.send(.selectAddress(result)) - }) else { return } - present(addressSearchVC, animated: true) + flowSubject.send(.presentAddressSearch( + AddressSearchSceneInput(diaryId: diaryId) { [weak self] result in + self?.viewModel.input.send(.selectAddress(result)) + } + )) } private func presentAddTagAlert() { @@ -484,3 +490,7 @@ public final class EditFoodRecordViewController< return true } } + +// MARK: - EditFlowEmitting + +extension EditFoodRecordViewController: EditFlowEmitting {} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift index 7c0f62dc..f559a0ae 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift @@ -6,12 +6,6 @@ import Domain import UIKit -// MARK: - Protocol - -public protocol ImagePickerDelegate: AnyObject { - func pushImagePicker(input: ImagePickerSceneInput) -} - // MARK: - Coordinator public final class ImagePickerCoordinator: Coordinator { From fce0e8886024a68c469ff3d297eb15eff87c558f Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 16:15:43 +0900 Subject: [PATCH 70/97] =?UTF-8?q?chore:=20SceneDelegate=EC=97=90=20EditSce?= =?UTF-8?q?neFactory,=20AddressSearchSceneFactory=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/SceneDelegate.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 77ea11dd..eadb8a51 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -698,7 +698,9 @@ extension SceneDelegate { getAppVersionUseCase: getAppVersionUseCase ), detail: DetailSceneFactory(container: container), - imagePicker: imagePickerFactory + imagePicker: imagePickerFactory, + edit: EditSceneFactory(container: container), + addressSearch: AddressSearchSceneFactory(container: container) ) guard let loginSession = try? container.resolve(LoginSession.self) else { From c0904817c0d7fa646777b922c9608ff52bfd3662 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 16:56:48 +0900 Subject: [PATCH 71/97] =?UTF-8?q?refactor:=20Detail/Edit/AddressSearch=20F?= =?UTF-8?q?actory=20DIContainer=20=EC=A0=9C=EA=B1=B0=20=E2=86=92=20UseCase?= =?UTF-8?q?=20=EC=A7=81=EC=A0=91=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DetailSceneFactory: DIContainer 제거, FetchFoodRecordsUseCase / SaveFoodRecordUseCase / DeleteFoodRecordUseCase / PushNotificationObserver 직접 주입, makeDetailScene(input:)에서 ViewModel 자체 생성 - EditSceneFactory: DIContainer 제거, UpdateFoodRecordUseCase / DeleteFoodRecordUseCase 직접 주입 - AddressSearchSceneFactory: DIContainer 제거, SearchAddressUseCase 직접 주입 - SceneDelegate: registerPresentation()에서 DetailVM / EditVM / AddressSearchVM 등록 블록 삭제, makeAppFlowController()에서 각 Factory에 UseCase 직접 전달 --- FoodDiary/App/Sources/SceneDelegate.swift | 132 +++++------------- .../AddressSearchSceneFactory.swift | 18 ++- .../Coordinator/DetailSceneFactory.swift | 36 +++-- .../Coordinator/EditSceneFactory.swift | 27 ++-- 4 files changed, 81 insertions(+), 132 deletions(-) diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index eadb8a51..940a94da 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -499,102 +499,6 @@ extension SceneDelegate { ) } - typealias DetailVM = DetailViewModel< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > - - container.register( - DetailVM.self, - argument: (Date, [FoodRecord]).self, - scope: .transient, - factory: { resolver, args in - let (initialDate, initialRecords) = args - guard - let fetchRecordsUseCase = resolver.resolve( - FetchFoodRecordsUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let saveFoodRecordUseCase = resolver.resolve( - SaveFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let deleteFoodRecordUseCase = resolver.resolve( - DeleteFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let pushObserver = resolver.resolve(PushNotificationObserver.self) - else { - fatalError("DetailViewModel dependencies not registered") - } - - return DetailViewModel( - initialDate: initialDate, - initialRecords: initialRecords, - fetchRecordsUseCase: fetchRecordsUseCase, - saveFoodRecordUseCase: saveFoodRecordUseCase, - deleteFoodRecordUseCase: deleteFoodRecordUseCase, - pushNotificationObserver: pushObserver - ) - } - ) - - typealias EditVM = EditFoodRecordViewModel< - FoodRecordRepositoryImpl> - > - - container.register( - EditVM.self, - argument: FoodRecord.self, - scope: .transient, - factory: { resolver, record in - guard - let updateUseCase = resolver.resolve( - UpdateFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let deleteUseCase = resolver.resolve( - DeleteFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ) - else { - fatalError("EditFoodRecordViewModel dependencies not registered") - } - - return EditFoodRecordViewModel( - record: record, - updateFoodRecordUseCase: updateUseCase, - deleteFoodRecordUseCase: deleteUseCase - ) - } - ) - - typealias AddressSearchVM = AddressSearchViewModel - - container.register( - AddressSearchVM.self, - argument: Int.self, - scope: .transient, - factory: { resolver, diaryId in - guard - let searchUseCase = resolver.resolve( - SearchAddressUseCase.self - ) - else { - fatalError("SearchAddressUseCase not registered") - } - return AddressSearchViewModel( - searchAddressUseCase: searchUseCase, - diaryId: diaryId - ) - } - ) - // MonthlyCalendarViewModel 타입 별칭 typealias MonthlyVM = MonthlyCalendarViewModel< FoodRecordRepositoryImpl>, @@ -632,6 +536,8 @@ extension SceneDelegate { } fileprivate func makeAppFlowController() -> AppFlowController { + typealias RecordRepo = FoodRecordRepositoryImpl> + // Login guard let finalizeUseCase = try? container.resolve(FinalizeAppleLoginUseCase.self) else { fatalError("FinalizeAppleLoginUseCase not registered") @@ -685,6 +591,26 @@ extension SceneDelegate { fetchUseCase: fetchImageAssetUseCase ) + // Detail + guard + let fetchRecordsUseCase = try? container.resolve(FetchFoodRecordsUseCase.self), + let saveFoodRecordUseCase = try? container.resolve(SaveFoodRecordUseCase.self), + let deleteFoodRecordUseCase = try? container.resolve(DeleteFoodRecordUseCase.self), + let pushNotificationObserver = try? container.resolve(PushNotificationObserver.self) + else { + fatalError("DetailSceneFactory dependencies not registered") + } + + // Edit + guard let updateFoodRecordUseCase = try? container.resolve(UpdateFoodRecordUseCase.self) else { + fatalError("EditSceneFactory dependencies not registered") + } + + // AddressSearch + guard let searchAddressUseCase = try? container.resolve(SearchAddressUseCase.self) else { + fatalError("AddressSearchSceneFactory dependencies not registered") + } + let factories = Factories( login: LoginSceneFactory(useCase: finalizeUseCase), calendar: CalendarSceneFactory(weeklyVM: weeklyVM, monthlyVM: monthlyVM), @@ -697,10 +623,18 @@ extension SceneDelegate { getNicknameUseCase: getNicknameUseCase, getAppVersionUseCase: getAppVersionUseCase ), - detail: DetailSceneFactory(container: container), + detail: DetailSceneFactory( + fetchRecordsUseCase: fetchRecordsUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + deleteFoodRecordUseCase: deleteFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver + ), imagePicker: imagePickerFactory, - edit: EditSceneFactory(container: container), - addressSearch: AddressSearchSceneFactory(container: container) + edit: EditSceneFactory( + updateFoodRecordUseCase: updateFoodRecordUseCase, + deleteFoodRecordUseCase: deleteFoodRecordUseCase + ), + addressSearch: AddressSearchSceneFactory(searchAddressUseCase: searchAddressUseCase) ) guard let loginSession = try? container.resolve(LoginSession.self) else { diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift index 27ce1923..31358947 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift @@ -4,7 +4,6 @@ // import Data -import DI import Domain import UIKit @@ -17,20 +16,19 @@ public protocol AddressSearchSceneProducing { // MARK: - Factory public final class AddressSearchSceneFactory: AddressSearchSceneProducing { - private typealias AddressSearchVM = AddressSearchViewModel + private let searchAddressUseCase: SearchAddressUseCase - private let container: DIContainer - - public init(container: DIContainer) { - self.container = container + public init(searchAddressUseCase: SearchAddressUseCase) { + self.searchAddressUseCase = searchAddressUseCase } public func makeScene(input: AddressSearchSceneInput) -> UIViewController { - guard let addressVM = try? container.resolve(AddressSearchVM.self, argument: input.diaryId) else { - fatalError("AddressSearchViewModel not registered") - } + let viewModel = AddressSearchViewModel( + searchAddressUseCase: searchAddressUseCase, + diaryId: input.diaryId + ) return AddressSearchViewController( - viewModel: addressVM, + viewModel: viewModel, onAddressSelected: input.onSelected ) } diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift index 75104bd9..a7d3a438 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift @@ -4,7 +4,6 @@ // import Data -import DI import Domain import UIKit @@ -13,23 +12,36 @@ public protocol DetailSceneProducing { } public final class DetailSceneFactory: DetailSceneProducing { - private typealias DetailVM = DetailViewModel< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > + private typealias RecordRepo = FoodRecordRepositoryImpl> - private let container: DIContainer + private let fetchRecordsUseCase: FetchFoodRecordsUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + private let pushNotificationObserver: PushNotificationObserver - public init(container: DIContainer) { - self.container = container + public init( + fetchRecordsUseCase: FetchFoodRecordsUseCase>>, + saveFoodRecordUseCase: SaveFoodRecordUseCase>>, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase>>, + pushNotificationObserver: PushNotificationObserver + ) { + self.fetchRecordsUseCase = fetchRecordsUseCase + self.saveFoodRecordUseCase = saveFoodRecordUseCase + self.deleteFoodRecordUseCase = deleteFoodRecordUseCase + self.pushNotificationObserver = pushNotificationObserver } public func makeDetailScene(input: DetailSceneInput) -> UIViewController { - guard let detailVM = try? container.resolve(DetailVM.self, argument: (input.date, input.records)) else { - fatalError("DetailViewModel not registered") - } + let viewModel = DetailViewModel( + initialDate: input.date, + initialRecords: input.records, + fetchRecordsUseCase: fetchRecordsUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + deleteFoodRecordUseCase: deleteFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver + ) return DetailViewController( - viewModel: detailVM, + viewModel: viewModel, initialScrollTarget: input.scrollToMealType, shouldPopToRoot: input.shouldPopToRoot, onDismissWithDate: input.onDismissWithDate diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift index 0075d736..c2c652af 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift @@ -4,7 +4,7 @@ // import Data -import DI +import Domain import UIKit // MARK: - Protocol @@ -16,20 +16,25 @@ public protocol EditSceneProducing { // MARK: - Factory public final class EditSceneFactory: EditSceneProducing { - private typealias EditVM = EditFoodRecordViewModel< - FoodRecordRepositoryImpl> - > + private typealias RecordRepo = FoodRecordRepositoryImpl> - private let container: DIContainer + private let updateFoodRecordUseCase: UpdateFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase - public init(container: DIContainer) { - self.container = container + public init( + updateFoodRecordUseCase: UpdateFoodRecordUseCase>>, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase>> + ) { + self.updateFoodRecordUseCase = updateFoodRecordUseCase + self.deleteFoodRecordUseCase = deleteFoodRecordUseCase } public func makeScene(input: EditSceneInput) -> UIViewController { - guard let editVM = try? container.resolve(EditVM.self, argument: input.record) else { - fatalError("EditFoodRecordViewModel not registered") - } - return EditFoodRecordViewController(viewModel: editVM) + let viewModel = EditFoodRecordViewModel( + record: input.record, + updateFoodRecordUseCase: updateFoodRecordUseCase, + deleteFoodRecordUseCase: deleteFoodRecordUseCase + ) + return EditFoodRecordViewController(viewModel: viewModel) } } From 1b6ce7ba788f0929e63ab774942dfc67161de065 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 16:57:14 +0900 Subject: [PATCH 72/97] =?UTF-8?q?refactor:=20CalendarSceneFactory=20DICont?= =?UTF-8?q?ainer=20=EC=A0=9C=EA=B1=B0=20=E2=86=92=20UseCase=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CalendarSceneFactory: WeeklyVM / MonthlyVM 저장 방식 제거, UseCase 8개(requestPhotoAuthorizationUseCase, loadWeeklyCalendarDataUseCase, saveFoodRecordUseCase, pushNotificationObserver, getNicknameUseCase, coachmarkStorage, fetchMonthlyCalendarDaysUseCase, fetchFoodRecordsUseCase) 직접 주입, makeScene()에서 ViewModel 자체 생성 - SceneDelegate: registerPresentation()에서 WeeklyVM / MonthlyVM 등록 블록 삭제, makeAppFlowController()에서 Calendar UseCase를 직접 resolve하여 Factory에 전달, typealias RecordRepo / AssetFetcher 함수 상단 통합 --- FoodDiary/App/Sources/SceneDelegate.swift | 124 ++++-------------- .../Coordinator/CalendarSceneFactory.swift | 60 ++++++--- 2 files changed, 70 insertions(+), 114 deletions(-) diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 940a94da..75e7ca85 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -457,109 +457,17 @@ extension SceneDelegate { } } - fileprivate func registerPresentation() { - // WeeklyCalendarViewModel 타입 별칭 - typealias WeeklyVM = WeeklyCalendarViewModel< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher, - PhotoAuthorizationFetcher, - PushNotificationObserver - > - - container.register(WeeklyVM.self, scope: .transient) { resolver in - guard - let requestPhotoAuthUseCase = resolver.resolve( - RequestPhotoAuthorizationUseCase.self - ), - let loadWeeklyUseCase = resolver.resolve( - LoadWeeklyRecordUseCase< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher - >.self - ), - let saveFoodRecordUseCase = resolver.resolve( - SaveFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let pushObserver = resolver.resolve(PushNotificationObserver.self), - let getNicknameUseCase = resolver.resolve(GetNicknameUseCase.self), - let coachmarkStorage = resolver.resolve(CoachmarkStoring.self) - else { - fatalError("WeeklyCalendarViewModel dependencies not registered") - } - - return WeeklyCalendarViewModel( - requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, - loadWeeklyCalendarDataUseCase: loadWeeklyUseCase, - saveFoodRecordUseCase: saveFoodRecordUseCase, - pushNotificationObserver: pushObserver, - getNicknameUseCase: getNicknameUseCase, - coachmarkStorage: coachmarkStorage - ) - } - - // MonthlyCalendarViewModel 타입 별칭 - typealias MonthlyVM = MonthlyCalendarViewModel< - FoodRecordRepositoryImpl>, - PhotoAuthorizationFetcher - > - - container.register(MonthlyVM.self, scope: .transient) { resolver in - guard - let fetchMonthlyUseCase = resolver.resolve( - FetchMonthlyCalendarDaysUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let requestPhotoAuthUseCase = resolver.resolve( - RequestPhotoAuthorizationUseCase.self - ), - let fetchFoodRecordsUseCase = resolver.resolve( - FetchFoodRecordsUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let getNicknameUseCase = resolver.resolve(GetNicknameUseCase.self) - else { - fatalError("MonthlyCalendarViewModel dependencies not registered") - } - - return MonthlyCalendarViewModel( - fetchMonthlyCalendarDaysUseCase: fetchMonthlyUseCase, - requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, - fetchFoodRecordsUseCase: fetchFoodRecordsUseCase, - getNicknameUseCase: getNicknameUseCase - ) - } - - } + fileprivate func registerPresentation() {} fileprivate func makeAppFlowController() -> AppFlowController { typealias RecordRepo = FoodRecordRepositoryImpl> + typealias AssetFetcher = FoodImageAssetFetcher // Login guard let finalizeUseCase = try? container.resolve(FinalizeAppleLoginUseCase.self) else { fatalError("FinalizeAppleLoginUseCase not registered") } - // Calendar - typealias WeeklyVM = WeeklyCalendarViewModel< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher, - PhotoAuthorizationFetcher, - PushNotificationObserver - > - typealias MonthlyVM = MonthlyCalendarViewModel< - FoodRecordRepositoryImpl>, - PhotoAuthorizationFetcher - > - guard let weeklyVM = try? container.resolve(WeeklyVM.self), - let monthlyVM = try? container.resolve(MonthlyVM.self), - let imageProvider = try? container.resolve(UIImageLoader.self) else { - fatalError("CalendarSceneFactory dependencies not registered") - } - // Insight typealias FetchInsightUC = FetchInsightUseCase< InsightRepositoryImpl> @@ -579,10 +487,9 @@ extension SceneDelegate { } // ImagePicker - typealias FetchImageAssetUC = FetchFoodImageAssetUseCase< - FoodImageAssetFetcher - > - guard let fetchImageAssetUseCase = try? container.resolve(FetchImageAssetUC.self) else { + typealias FetchImageAssetUC = FetchFoodImageAssetUseCase + guard let fetchImageAssetUseCase = try? container.resolve(FetchImageAssetUC.self), + let imageProvider = try? container.resolve(UIImageLoader.self) else { fatalError("ImagePickerCoordinator dependencies not registered") } @@ -611,9 +518,28 @@ extension SceneDelegate { fatalError("AddressSearchSceneFactory dependencies not registered") } + // Calendar + guard + let requestPhotoAuthUseCase = try? container.resolve(RequestPhotoAuthorizationUseCase.self), + let loadWeeklyUseCase = try? container.resolve(LoadWeeklyRecordUseCase.self), + let coachmarkStorage = try? container.resolve(CoachmarkStoring.self), + let fetchMonthlyUseCase = try? container.resolve(FetchMonthlyCalendarDaysUseCase.self) + else { + fatalError("CalendarSceneFactory dependencies not registered") + } + let factories = Factories( login: LoginSceneFactory(useCase: finalizeUseCase), - calendar: CalendarSceneFactory(weeklyVM: weeklyVM, monthlyVM: monthlyVM), + calendar: CalendarSceneFactory( + requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, + loadWeeklyCalendarDataUseCase: loadWeeklyUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver, + getNicknameUseCase: getNicknameUseCase, + coachmarkStorage: coachmarkStorage, + fetchMonthlyCalendarDaysUseCase: fetchMonthlyUseCase, + fetchFoodRecordsUseCase: fetchRecordsUseCase + ), insight: InsightSceneFactory(useCase: fetchInsightUseCase), myPage: MyPageSceneFactory( updateDeviceUseCase: updateDeviceUseCase, diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift index 263760e8..faf50a6e 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -12,26 +12,56 @@ public protocol CalendarSceneProducing { } public final class CalendarSceneFactory: CalendarSceneProducing { - public typealias WeeklyVM = WeeklyCalendarViewModel< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher, - PhotoAuthorizationFetcher, - PushNotificationObserver - > - public typealias MonthlyVM = MonthlyCalendarViewModel< - FoodRecordRepositoryImpl>, - PhotoAuthorizationFetcher - > + private typealias RecordRepo = FoodRecordRepositoryImpl> + private typealias AssetFetcher = FoodImageAssetFetcher - private let weeklyVM: WeeklyVM - private let monthlyVM: MonthlyVM + private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase + private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let pushNotificationObserver: PushNotificationObserver + private let getNicknameUseCase: GetNicknameUseCase + private let coachmarkStorage: any CoachmarkStoring + private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase + private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase - public init(weeklyVM: WeeklyVM, monthlyVM: MonthlyVM) { - self.weeklyVM = weeklyVM - self.monthlyVM = monthlyVM + public init( + requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, + loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase< + FoodRecordRepositoryImpl>, + FoodImageAssetFetcher + >, + saveFoodRecordUseCase: SaveFoodRecordUseCase>>, + pushNotificationObserver: PushNotificationObserver, + getNicknameUseCase: GetNicknameUseCase, + coachmarkStorage: any CoachmarkStoring, + fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase>>, + fetchFoodRecordsUseCase: FetchFoodRecordsUseCase>> + ) { + self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase + self.loadWeeklyCalendarDataUseCase = loadWeeklyCalendarDataUseCase + self.saveFoodRecordUseCase = saveFoodRecordUseCase + self.pushNotificationObserver = pushNotificationObserver + self.getNicknameUseCase = getNicknameUseCase + self.coachmarkStorage = coachmarkStorage + self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase + self.fetchFoodRecordsUseCase = fetchFoodRecordsUseCase } public func makeScene() -> CalendarViewController { + let weeklyVM = WeeklyCalendarViewModel( + requestPhotoAuthorizationUseCase: requestPhotoAuthorizationUseCase, + loadWeeklyCalendarDataUseCase: loadWeeklyCalendarDataUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver, + getNicknameUseCase: getNicknameUseCase, + coachmarkStorage: coachmarkStorage + ) + let monthlyVM = MonthlyCalendarViewModel( + fetchMonthlyCalendarDaysUseCase: fetchMonthlyCalendarDaysUseCase, + requestPhotoAuthorizationUseCase: requestPhotoAuthorizationUseCase, + fetchFoodRecordsUseCase: fetchFoodRecordsUseCase, + getNicknameUseCase: getNicknameUseCase + ) let weeklyVC = WeeklyCalendarViewController(viewModel: weeklyVM) let monthlyVC = MonthlyCalendarViewController(viewModel: monthlyVM) return CalendarViewController(weeklyVC: weeklyVC, monthlyVC: monthlyVC) From 9853b0ee7675d6c31739ea4ef6e00e8a7669f6c6 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 16:59:40 +0900 Subject: [PATCH 73/97] =?UTF-8?q?chore:=20=EC=93=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MockFoodRecordDetailViewController.swift | 86 ------------------- 1 file changed, 86 deletions(-) delete mode 100644 FoodDiary/Presentation/Sources/FoodRecordDetail/MockFoodRecordDetailViewController.swift diff --git a/FoodDiary/Presentation/Sources/FoodRecordDetail/MockFoodRecordDetailViewController.swift b/FoodDiary/Presentation/Sources/FoodRecordDetail/MockFoodRecordDetailViewController.swift deleted file mode 100644 index 983b30fa..00000000 --- a/FoodDiary/Presentation/Sources/FoodRecordDetail/MockFoodRecordDetailViewController.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Combine -import DesignSystem -import Domain -import SnapKit -import UIKit - -public final class MockFoodRecordDetailViewController: UIViewController { - - // MARK: - Properties - private let date: Date - - // MARK: - UI Components - - private lazy var closeButton: UIButton = { - let button = UIButton(type: .system) - button.setImage(UIImage(systemName: "xmark"), for: .normal) - button.tintColor = .white - button.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - return button - }() - - private let dateLabel: UILabel = { - let label = UILabel() - label.textAlignment = .center - return label - }() - - // MARK: - Init - - public init(date: Date) { - self.date = date - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Lifecycle - - public override func viewDidLoad() { - super.viewDidLoad() - setupUI() - setupConstraints() - configureContent() - } - - // MARK: - Setup - - private func setupUI() { - view.backgroundColor = DesignSystemAsset.sdBase.color - view.addSubview(closeButton) - view.addSubview(dateLabel) - } - - private func setupConstraints() { - closeButton.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide).inset(20) - $0.trailing.equalToSuperview().inset(20) - $0.width.height.equalTo(44) - } - - dateLabel.snp.makeConstraints { - $0.top.equalTo(closeButton.snp.bottom).offset(40) - $0.leading.trailing.equalToSuperview().inset(20) - } - } - - private func configureContent() { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ko_KR") - formatter.dateFormat = "yyyy년 M월 d일" - let dateText = formatter.string(from: date) - - dateLabel.setText(dateText, style: .hd20, color: .white) - - // TODO: FoodRecord 표시 UI 구현 - } - - // MARK: - Actions - - @objc private func closeButtonTapped() { - dismiss(animated: true) - } -} From eda6bf77e0f6a6d49b01535b7c8d97fee49fb375 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 18:01:27 +0900 Subject: [PATCH 74/97] =?UTF-8?q?refactor:=20FlowEmitting/SceneProducing?= =?UTF-8?q?=20=EA=B3=BC=EB=8F=84=ED=95=9C=20=EC=B6=94=EC=83=81=ED=99=94=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowEmitting 프로토콜(DetailFlowEmitting, EditFlowEmitting, CalendarFlowEmitting) 제거 → Coordinator가 구체 VC 타입의 flowPublisher에 직접 접근 - SceneProducing 프로토콜 반환 타입을 UIViewController에서 구체 VC 타입으로 변경 → 런타임 다운캐스팅(as? any FlowEmitting) 제거 - Factories 구조체 프로퍼티를 any SceneProducing에서 구체 Factory 타입으로 변경 → 컴파일 타임에 타입 정보 보존 --- .../AddressSearchSceneFactory.swift | 10 ++---- .../Calendar/CalendarViewController.swift | 12 ++++--- .../Calendar/Coordinator/CalendarFlow.swift | 3 -- .../Coordinator/CalendarSceneFactory.swift | 13 ++++---- .../MonthlyCalendarViewController.swift | 3 -- .../WeeklyCalendarViewController.swift | 3 -- .../Sources/Core/SceneFactories.swift | 32 +++++++++---------- .../Coordinator/DetailCoordinator.swift | 22 ++++++------- .../Detail/Coordinator/DetailFlow.swift | 3 -- .../Coordinator/DetailSceneFactory.swift | 11 +++---- .../Sources/Detail/DetailViewController.swift | 3 -- .../Coordinator/EditCoordinator.swift | 18 +++++------ .../EditFoodRecord/Coordinator/EditFlow.swift | 3 -- .../Coordinator/EditSceneFactory.swift | 12 +++---- .../EditFoodRecordViewController.swift | 3 -- .../Coordinator/ImagePickerSceneFactory.swift | 10 ++---- .../Coordinator/InsightSceneFactory.swift | 8 ++--- .../Login/Coordinator/LoginSceneFactory.swift | 6 +--- .../Coordinator/MyPageSceneFactory.swift | 6 +--- 19 files changed, 65 insertions(+), 116 deletions(-) diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift index 31358947..81be461a 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift @@ -7,22 +7,16 @@ import Data import Domain import UIKit -// MARK: - Protocol - -public protocol AddressSearchSceneProducing { - func makeScene(input: AddressSearchSceneInput) -> UIViewController -} - // MARK: - Factory -public final class AddressSearchSceneFactory: AddressSearchSceneProducing { +public final class AddressSearchSceneFactory { private let searchAddressUseCase: SearchAddressUseCase public init(searchAddressUseCase: SearchAddressUseCase) { self.searchAddressUseCase = searchAddressUseCase } - public func makeScene(input: AddressSearchSceneInput) -> UIViewController { + public func makeScene(input: AddressSearchSceneInput) -> AddressSearchViewController { let viewModel = AddressSearchViewModel( searchAddressUseCase: searchAddressUseCase, diaryId: input.diaryId diff --git a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift index fc250ac3..731c02f5 100644 --- a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift @@ -31,15 +31,17 @@ public final class CalendarViewController: UIViewController { currentModeSubject.eraseToAnyPublisher() } - public init( - weeklyVC: W, - monthlyVC: M + public init( + weeklyVC: UIViewController, + monthlyVC: UIViewController, + weeklyFlowPublisher: AnyPublisher, + monthlyFlowPublisher: AnyPublisher ) { self.weeklyVC = weeklyVC self.monthlyVC = monthlyVC super.init(nibName: nil, bundle: nil) - - Publishers.Merge(weeklyVC.flowPublisher, monthlyVC.flowPublisher) + + Publishers.Merge(weeklyFlowPublisher, monthlyFlowPublisher) .sink { [weak self] flow in self?.flowSubject.send(flow) } .store(in: &cancellables) } diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift index 37256ceb..0fa77c2d 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift @@ -10,6 +10,3 @@ public enum CalendarFlow { case pushImagePicker(ImagePickerSceneInput) } -public protocol CalendarFlowEmitting: AnyObject { - var flowPublisher: AnyPublisher { get } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift index faf50a6e..d4f30e16 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -7,11 +7,7 @@ import Data import Domain import UIKit -public protocol CalendarSceneProducing { - func makeScene() -> CalendarViewController -} - -public final class CalendarSceneFactory: CalendarSceneProducing { +public final class CalendarSceneFactory { private typealias RecordRepo = FoodRecordRepositoryImpl> private typealias AssetFetcher = FoodImageAssetFetcher @@ -64,6 +60,11 @@ public final class CalendarSceneFactory: CalendarSceneProducing { ) let weeklyVC = WeeklyCalendarViewController(viewModel: weeklyVM) let monthlyVC = MonthlyCalendarViewController(viewModel: monthlyVM) - return CalendarViewController(weeklyVC: weeklyVC, monthlyVC: monthlyVC) + return CalendarViewController( + weeklyVC: weeklyVC, + monthlyVC: monthlyVC, + weeklyFlowPublisher: weeklyVC.flowPublisher, + monthlyFlowPublisher: monthlyVC.flowPublisher + ) } } diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index 3eede711..951c6611 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -345,9 +345,6 @@ public final class MonthlyCalendarViewController< } } -// MARK: - CalendarFlowEmitting - -extension MonthlyCalendarViewController: CalendarFlowEmitting {} // MARK: - Constants diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index 9a93104f..1ec8aa1b 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -348,6 +348,3 @@ public final class WeeklyCalendarViewController< } } -// MARK: - CalendarFlowEmitting - -extension WeeklyCalendarViewController: CalendarFlowEmitting {} diff --git a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift index 5f1d4f99..d66c8383 100644 --- a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift +++ b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift @@ -6,24 +6,24 @@ import UIKit public struct Factories { - public let login: any LoginSceneProducing - public let calendar: any CalendarSceneProducing - public let insight: any InsightSceneProducing - public let myPage: any MyPageSceneProducing - public let detail: any DetailSceneProducing - public let imagePicker: any ImagePickerSceneProducing - public let edit: any EditSceneProducing - public let addressSearch: any AddressSearchSceneProducing + public let login: LoginSceneFactory + public let calendar: CalendarSceneFactory + public let insight: InsightSceneFactory + public let myPage: MyPageSceneFactory + public let detail: DetailSceneFactory + public let imagePicker: ImagePickerSceneFactory + public let edit: EditSceneFactory + public let addressSearch: AddressSearchSceneFactory public init( - login: any LoginSceneProducing, - calendar: any CalendarSceneProducing, - insight: any InsightSceneProducing, - myPage: any MyPageSceneProducing, - detail: any DetailSceneProducing, - imagePicker: any ImagePickerSceneProducing, - edit: any EditSceneProducing, - addressSearch: any AddressSearchSceneProducing + login: LoginSceneFactory, + calendar: CalendarSceneFactory, + insight: InsightSceneFactory, + myPage: MyPageSceneFactory, + detail: DetailSceneFactory, + imagePicker: ImagePickerSceneFactory, + edit: EditSceneFactory, + addressSearch: AddressSearchSceneFactory ) { self.login = login self.calendar = calendar diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift index 90aa3ccb..eacfad04 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift @@ -25,19 +25,17 @@ public final class DetailCoordinator: Coordinator { let vc = factories.detail.makeDetailScene(input: input) vc.hidesBottomBarWhenPushed = true navigationController?.pushViewController(vc, animated: true) - if let flowVC = vc as? any DetailFlowEmitting { - flowVC.flowPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] event in - switch event { - case .pushEdit(let input): - self?.pushEdit(input: input) - case .pushImagePicker(let input): - self?.pushImagePicker(input: input) - } + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .pushEdit(let input): + self?.pushEdit(input: input) + case .pushImagePicker(let input): + self?.pushImagePicker(input: input) } - .store(in: &cancellables) - } + } + .store(in: &cancellables) } private func pushImagePicker(input: ImagePickerSceneInput) { diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift index e7a2e409..11eca1d6 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift @@ -10,6 +10,3 @@ public enum DetailFlow { case pushImagePicker(ImagePickerSceneInput) } -public protocol DetailFlowEmitting: AnyObject { - var flowPublisher: AnyPublisher { get } -} diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift index a7d3a438..bee4c59a 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift @@ -7,11 +7,7 @@ import Data import Domain import UIKit -public protocol DetailSceneProducing { - func makeDetailScene(input: DetailSceneInput) -> UIViewController -} - -public final class DetailSceneFactory: DetailSceneProducing { +public final class DetailSceneFactory { private typealias RecordRepo = FoodRecordRepositoryImpl> private let fetchRecordsUseCase: FetchFoodRecordsUseCase @@ -31,7 +27,10 @@ public final class DetailSceneFactory: DetailSceneProducing { self.pushNotificationObserver = pushNotificationObserver } - public func makeDetailScene(input: DetailSceneInput) -> UIViewController { + public func makeDetailScene(input: DetailSceneInput) -> DetailViewController< + FoodRecordRepositoryImpl>, + PushNotificationObserver + > { let viewModel = DetailViewModel( initialDate: input.date, initialRecords: input.records, diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index cb426b65..8878bc8b 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -601,6 +601,3 @@ public final class DetailViewController< } } -// MARK: - DetailFlowEmitting - -extension DetailViewController: DetailFlowEmitting {} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift index 4ab897b5..07ee9bbf 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift @@ -27,17 +27,15 @@ public final class EditCoordinator: Coordinator { guard let nav = navigationController else { return } let vc = factories.edit.makeScene(input: input) nav.pushViewController(vc, animated: true) - if let flowVC = vc as? any EditFlowEmitting { - flowVC.flowPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] event in - switch event { - case .presentAddressSearch(let input): - self?.presentAddressSearch(input: input) - } + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .presentAddressSearch(let input): + self?.presentAddressSearch(input: input) } - .store(in: &cancellables) - } + } + .store(in: &cancellables) } private func presentAddressSearch(input: AddressSearchSceneInput) { diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift index 14f5c5fa..8ef78884 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift @@ -9,6 +9,3 @@ public enum EditFlow { case presentAddressSearch(AddressSearchSceneInput) } -public protocol EditFlowEmitting: AnyObject { - var flowPublisher: AnyPublisher { get } -} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift index c2c652af..954f63b6 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift @@ -7,15 +7,9 @@ import Data import Domain import UIKit -// MARK: - Protocol - -public protocol EditSceneProducing { - func makeScene(input: EditSceneInput) -> UIViewController -} - // MARK: - Factory -public final class EditSceneFactory: EditSceneProducing { +public final class EditSceneFactory { private typealias RecordRepo = FoodRecordRepositoryImpl> private let updateFoodRecordUseCase: UpdateFoodRecordUseCase @@ -29,7 +23,9 @@ public final class EditSceneFactory: EditSceneProducing { self.deleteFoodRecordUseCase = deleteFoodRecordUseCase } - public func makeScene(input: EditSceneInput) -> UIViewController { + public func makeScene(input: EditSceneInput) -> EditFoodRecordViewController< + FoodRecordRepositoryImpl> + > { let viewModel = EditFoodRecordViewModel( record: input.record, updateFoodRecordUseCase: updateFoodRecordUseCase, diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift index 94c46e77..80187a45 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift @@ -491,6 +491,3 @@ public final class EditFoodRecordViewController< } } -// MARK: - EditFlowEmitting - -extension EditFoodRecordViewController: EditFlowEmitting {} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift index a200e901..2e01465a 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift @@ -8,15 +8,9 @@ import Domain import Photos import UIKit -// MARK: - Protocol - -public protocol ImagePickerSceneProducing { - func makeScene(input: ImagePickerSceneInput) -> UIViewController -} - // MARK: - Factory -public final class ImagePickerSceneFactory: ImagePickerSceneProducing { +public final class ImagePickerSceneFactory { private let imageProvider: UIImageLoader private let fetchUseCase: FetchFoodImageAssetUseCase> @@ -28,7 +22,7 @@ public final class ImagePickerSceneFactory: ImagePickerSceneProducing { self.fetchUseCase = fetchUseCase } - public func makeScene(input: ImagePickerSceneInput) -> UIViewController { + public func makeScene(input: ImagePickerSceneInput) -> ImagePickerViewController { let date = input.date let fetchUseCase = self.fetchUseCase diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift index fde05045..912e6d38 100644 --- a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift @@ -7,11 +7,7 @@ import Data import Domain import UIKit -public protocol InsightSceneProducing { - func makeInsightScene() -> UIViewController -} - -public final class InsightSceneFactory: InsightSceneProducing { +public final class InsightSceneFactory { public typealias UseCase = FetchInsightUseCase>> private let useCase: UseCase @@ -20,7 +16,7 @@ public final class InsightSceneFactory: InsightSceneProducing { self.useCase = useCase } - public func makeInsightScene() -> UIViewController { + public func makeInsightScene() -> InsightViewController>> { InsightViewController(viewModel: InsightViewModel(fetchInsightUseCase: useCase)) } } diff --git a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift index 35b2e0d9..3d4be652 100644 --- a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift @@ -5,11 +5,7 @@ import Domain -public protocol LoginSceneProducing { - func makeLoginScene() -> LoginViewController -} - -public final class LoginSceneFactory: LoginSceneProducing { +public final class LoginSceneFactory { private let useCase: FinalizeAppleLoginUseCase public init(useCase: FinalizeAppleLoginUseCase) { diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift index 1b13e661..27283313 100644 --- a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift @@ -5,11 +5,7 @@ import Domain -public protocol MyPageSceneProducing { - func makeMyPageScene() -> MyPageViewController -} - -public final class MyPageSceneFactory: MyPageSceneProducing { +public final class MyPageSceneFactory { private let updateDeviceUseCase: UpdateDeviceNotificationSettingUseCase private let notificationAuthProvider: NotificationAuthorizationProviding private let logoutUseCase: LogoutUseCase From 02583c888d9a4e838cfe12ef66cf8da038e88238 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 18:23:03 +0900 Subject: [PATCH 75/97] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppCoordinator.swift | 2 - FoodDiary/App/Sources/MainCoordinator.swift | 12 +++--- .../AddressSearchCoordinator.swift | 4 -- .../Coordinator/CalendarCoordinator.swift | 42 +++++++++++-------- .../Sources/Core/Coordinator.swift | 1 - .../Coordinator/DetailCoordinator.swift | 19 ++++++--- .../Coordinator/DetailSceneFactory.swift | 2 +- .../Coordinator/EditCoordinator.swift | 18 +++++--- .../Coordinator/ImagePickerCoordinator.swift | 5 +-- .../Coordinator/InsightCoordinator.swift | 4 +- .../Coordinator/InsightSceneFactory.swift | 2 +- .../Login/Coordinator/LoginCoordinator.swift | 4 +- .../Login/Coordinator/LoginSceneFactory.swift | 2 +- .../Coordinator/MyPageCoordinator.swift | 6 +-- .../Coordinator/MyPageSceneFactory.swift | 2 +- 15 files changed, 69 insertions(+), 56 deletions(-) diff --git a/FoodDiary/App/Sources/AppCoordinator.swift b/FoodDiary/App/Sources/AppCoordinator.swift index 6ab4b793..f45773fb 100644 --- a/FoodDiary/App/Sources/AppCoordinator.swift +++ b/FoodDiary/App/Sources/AppCoordinator.swift @@ -18,8 +18,6 @@ final class AppCoordinator: Coordinator { init(factories: Factories) { self.factories = factories } - - func start() {} } // MARK: - Screen Routing diff --git a/FoodDiary/App/Sources/MainCoordinator.swift b/FoodDiary/App/Sources/MainCoordinator.swift index fec98774..887bc36e 100644 --- a/FoodDiary/App/Sources/MainCoordinator.swift +++ b/FoodDiary/App/Sources/MainCoordinator.swift @@ -49,12 +49,14 @@ final class MainCoordinator: Coordinator { } } -extension MainCoordinator { +// MARK: - Screen Routing + +private extension MainCoordinator { func pushMyPageVC() { - let myPageCoordinator = MyPageCoordinator(factories: factories, navigationController: navigationController) - myPageCoordinator.parentCoordinator = self - addChild(myPageCoordinator) - myPageCoordinator.start() + let coord = MyPageCoordinator(factories: factories, navigationController: navigationController) + coord.parentCoordinator = self + addChild(coord) + coord.start() } } diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift index 19462cca..e543c962 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift @@ -5,8 +5,6 @@ import UIKit -// MARK: - Coordinator - public final class AddressSearchCoordinator: Coordinator { public var childCoordinators: [any Coordinator] = [] public weak var parentCoordinator: (any Coordinator)? @@ -19,8 +17,6 @@ public final class AddressSearchCoordinator: Coordinator { self.navigationController = navigationController } - public func start() {} - public func start(input: AddressSearchSceneInput) { guard let presentingVC = navigationController?.topViewController else { return } let vc = factories.addressSearch.makeScene(input: input) diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift index 068914b3..76fb93af 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift @@ -6,29 +6,34 @@ import Combine import UIKit -// MARK: - CalendarCoordinator - public final class CalendarCoordinator: Coordinator { public var childCoordinators: [any Coordinator] = [] public weak var parentCoordinator: (any Coordinator)? - public weak var navigationController: UINavigationController? private let factories: Factories + public weak var navigationController: UINavigationController? private var cancellables = Set() public init(factories: Factories) { self.factories = factories } - public func start() {} - public func configure(navigationController: UINavigationController?) { self.navigationController = navigationController } public func makeViewController() -> CalendarViewController { let calendarVC = factories.calendar.makeScene() - calendarVC.flowPublisher + flowBind(vc: calendarVC) + return calendarVC + } +} + +// MARK: - Screen Routing + +private extension CalendarCoordinator { + func flowBind(vc: CalendarViewController) { + vc.flowPublisher .receive(on: DispatchQueue.main) .sink { [weak self] event in switch event { @@ -39,26 +44,27 @@ public final class CalendarCoordinator: Coordinator { } } .store(in: &cancellables) - return calendarVC } +} - private func pushDetail(input: DetailSceneInput) { - let detailCoordinator = DetailCoordinator( +private extension CalendarCoordinator { + func pushDetail(input: DetailSceneInput) { + let coord = DetailCoordinator( factories: factories, navigationController: navigationController ) - detailCoordinator.parentCoordinator = self - addChild(detailCoordinator) - detailCoordinator.start(input: input) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) } - - private func pushImagePicker(input: ImagePickerSceneInput) { - let imagePickerCoordinator = ImagePickerCoordinator( + + func pushImagePicker(input: ImagePickerSceneInput) { + let coord = ImagePickerCoordinator( factories: factories, navigationController: navigationController ) - imagePickerCoordinator.parentCoordinator = self - addChild(imagePickerCoordinator) - imagePickerCoordinator.start(input: input) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) } } diff --git a/FoodDiary/Presentation/Sources/Core/Coordinator.swift b/FoodDiary/Presentation/Sources/Core/Coordinator.swift index 0d36e469..a60ed151 100644 --- a/FoodDiary/Presentation/Sources/Core/Coordinator.swift +++ b/FoodDiary/Presentation/Sources/Core/Coordinator.swift @@ -7,7 +7,6 @@ import UIKit public protocol Coordinator: AnyObject { var childCoordinators: [any Coordinator] { get set } - func start() } public extension Coordinator { diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift index eacfad04..e83429f5 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift @@ -5,6 +5,7 @@ import Combine import UIKit +import Data public final class DetailCoordinator: Coordinator { public var childCoordinators: [any Coordinator] = [] @@ -19,12 +20,18 @@ public final class DetailCoordinator: Coordinator { self.navigationController = navigationController } - public func start() {} - public func start(input: DetailSceneInput) { - let vc = factories.detail.makeDetailScene(input: input) + let vc = factories.detail.makeScene(input: input) vc.hidesBottomBarWhenPushed = true + flowBind(vc: vc) navigationController?.pushViewController(vc, animated: true) + } +} + +// MARK: - Screen Routing + +private extension DetailCoordinator { + func flowBind(vc: DetailViewController>, PushNotificationObserver>) { vc.flowPublisher .receive(on: DispatchQueue.main) .sink { [weak self] event in @@ -37,8 +44,10 @@ public final class DetailCoordinator: Coordinator { } .store(in: &cancellables) } +} - private func pushImagePicker(input: ImagePickerSceneInput) { +private extension DetailCoordinator { + func pushImagePicker(input: ImagePickerSceneInput) { let coord = ImagePickerCoordinator( factories: factories, navigationController: navigationController @@ -48,7 +57,7 @@ public final class DetailCoordinator: Coordinator { coord.start(input: input) } - private func pushEdit(input: EditSceneInput) { + func pushEdit(input: EditSceneInput) { let coord = EditCoordinator( factories: factories, navigationController: navigationController diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift index bee4c59a..0ab4e9e9 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift @@ -27,7 +27,7 @@ public final class DetailSceneFactory { self.pushNotificationObserver = pushNotificationObserver } - public func makeDetailScene(input: DetailSceneInput) -> DetailViewController< + public func makeScene(input: DetailSceneInput) -> DetailViewController< FoodRecordRepositoryImpl>, PushNotificationObserver > { diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift index 07ee9bbf..ed0252a5 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift @@ -5,6 +5,7 @@ import Combine import UIKit +import Data // MARK: - Coordinator @@ -21,12 +22,17 @@ public final class EditCoordinator: Coordinator { self.navigationController = navigationController } - public func start() {} - public func start(input: EditSceneInput) { - guard let nav = navigationController else { return } let vc = factories.edit.makeScene(input: input) - nav.pushViewController(vc, animated: true) + flowBind(vc: vc) + navigationController?.pushViewController(vc, animated: true) + } +} + +// MARK: - Screen Routing + +private extension EditCoordinator { + func flowBind(vc: EditFoodRecordViewController>>) { vc.flowPublisher .receive(on: DispatchQueue.main) .sink { [weak self] event in @@ -37,8 +43,10 @@ public final class EditCoordinator: Coordinator { } .store(in: &cancellables) } +} - private func presentAddressSearch(input: AddressSearchSceneInput) { +private extension EditCoordinator { + func presentAddressSearch(input: AddressSearchSceneInput) { let coord = AddressSearchCoordinator( factories: factories, navigationController: navigationController diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift index f559a0ae..99dbb84d 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift @@ -20,11 +20,8 @@ public final class ImagePickerCoordinator: Coordinator { self.navigationController = navigationController } - public func start() {} - public func start(input: ImagePickerSceneInput) { - guard let nav = navigationController else { return } let vc = factories.imagePicker.makeScene(input: input) - nav.pushViewController(vc, animated: true) + navigationController?.pushViewController(vc, animated: true) } } diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift index 1868f131..95bad911 100644 --- a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift @@ -15,9 +15,7 @@ public final class InsightCoordinator: Coordinator { self.factories = factories } - public func start() {} - public func makeViewController() -> UIViewController { - factories.insight.makeInsightScene() + factories.insight.makeScene() } } diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift index 912e6d38..f23d244d 100644 --- a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift @@ -16,7 +16,7 @@ public final class InsightSceneFactory { self.useCase = useCase } - public func makeInsightScene() -> InsightViewController>> { + public func makeScene() -> InsightViewController>> { InsightViewController(viewModel: InsightViewModel(fetchInsightUseCase: useCase)) } } diff --git a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift index e3cdb321..60bd3729 100644 --- a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift @@ -17,7 +17,7 @@ public final class LoginCoordinator: Coordinator { } public func start() { - let loginVC = factories.login.makeLoginScene() - sceneTransitioner?.transition(to: loginVC) + let vc = factories.login.makeScene() + sceneTransitioner?.transition(to: vc) } } diff --git a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift index 3d4be652..97aa625e 100644 --- a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift @@ -12,7 +12,7 @@ public final class LoginSceneFactory { self.useCase = useCase } - public func makeLoginScene() -> LoginViewController { + public func makeScene() -> LoginViewController { LoginViewController(viewModel: LoginViewModel(finalizeAppleLoginUseCase: useCase)) } } diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift index ba50aa37..b440ebfc 100644 --- a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift @@ -18,8 +18,8 @@ public final class MyPageCoordinator: Coordinator { } public func start() { - let myPageVC = factories.myPage.makeMyPageScene() - myPageVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(myPageVC, animated: true) + let vc = factories.myPage.makeScene() + vc.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(vc, animated: true) } } diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift index 27283313..b58bc2df 100644 --- a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift @@ -29,7 +29,7 @@ public final class MyPageSceneFactory { self.getAppVersionUseCase = getAppVersionUseCase } - public func makeMyPageScene() -> MyPageViewController { + public func makeScene() -> MyPageViewController { MyPageViewController(viewModel: MyPageViewModel( updateDeviceNotificationSettingUseCase: updateDeviceUseCase, notificationAuthorizationProvider: notificationAuthProvider, From 1a281bef876404d08ee3a6f9d2556652fc58271e Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Sun, 12 Apr 2026 19:09:34 +0900 Subject: [PATCH 76/97] =?UTF-8?q?fix:=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EB=88=84=EC=88=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AddressSearchViewController.swift | 14 +++++++++++ .../AddressSearchCoordinator.swift | 24 ++++++++++++++++++ .../Coordinator/AddressSearchFlow.swift | 10 ++++++++ .../Coordinator/DetailCoordinator.swift | 6 +++++ .../Detail/Coordinator/DetailFlow.swift | 1 + .../Sources/Detail/DetailViewController.swift | 8 +++++- .../Coordinator/EditCoordinator.swift | 6 +++++ .../EditFoodRecord/Coordinator/EditFlow.swift | 1 + .../EditFoodRecordViewController.swift | 7 ++++++ .../Coordinator/ImagePickerCoordinator.swift | 22 ++++++++++++++++ .../Coordinator/MyPageCoordinator.swift | 25 +++++++++++++++++++ .../MyPage/Coordinator/MyPageFlow.swift | 10 ++++++++ .../Sources/MyPage/MyPageViewController.swift | 14 +++++++++++ 13 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchFlow.swift create mode 100644 FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageFlow.swift diff --git a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift index 4ff81883..c4d87a0e 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift @@ -32,6 +32,13 @@ public final class AddressSearchViewController< private let viewModel: AddressSearchViewModel private let onAddressSelected: ((AddressSearchResult) -> Void)? private var cancellables = Set() + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } private var containerHeightConstraint: Constraint? // MARK: - TableView Handler @@ -136,6 +143,13 @@ public final class AddressSearchViewController< searchTextField.becomeFirstResponder() } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed || isMovingFromParent { + flowSubject.send(.finish) + } + } + public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let contentHeight = resultsTableView.contentSize.height diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift index e543c962..08f86073 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift @@ -3,6 +3,8 @@ // Presentation // +import Combine +import Data import UIKit public final class AddressSearchCoordinator: Coordinator { @@ -11,6 +13,7 @@ public final class AddressSearchCoordinator: Coordinator { private let factories: Factories private weak var navigationController: UINavigationController? + private var cancellables = Set() public init(factories: Factories, navigationController: UINavigationController?) { self.factories = factories @@ -20,6 +23,27 @@ public final class AddressSearchCoordinator: Coordinator { public func start(input: AddressSearchSceneInput) { guard let presentingVC = navigationController?.topViewController else { return } let vc = factories.addressSearch.makeScene(input: input) + flowBind(vc: vc) presentingVC.present(vc, animated: true) } } + +// MARK: - Screen Routing + +private extension AddressSearchCoordinator { + func flowBind(vc: AddressSearchViewController) { + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .finish: + self?.finish() + } + } + .store(in: &cancellables) + } + + func finish() { + parentCoordinator?.removeChild(self) + } +} diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchFlow.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchFlow.swift new file mode 100644 index 00000000..21b6e3a8 --- /dev/null +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchFlow.swift @@ -0,0 +1,10 @@ +// +// AddressSearchFlow.swift +// Presentation +// + +import Foundation + +public enum AddressSearchFlow { + case finish +} diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift index e83429f5..c47a536c 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift @@ -40,6 +40,8 @@ private extension DetailCoordinator { self?.pushEdit(input: input) case .pushImagePicker(let input): self?.pushImagePicker(input: input) + case .finish: + self?.finish() } } .store(in: &cancellables) @@ -66,4 +68,8 @@ private extension DetailCoordinator { addChild(coord) coord.start(input: input) } + + func finish() { + parentCoordinator?.removeChild(self) + } } diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift index 11eca1d6..72230eb1 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift @@ -8,5 +8,6 @@ import Combine public enum DetailFlow { case pushEdit(EditSceneInput) case pushImagePicker(ImagePickerSceneInput) + case finish } diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index 8878bc8b..ebe18b9f 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -168,6 +168,13 @@ public final class DetailViewController< } } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent { + flowSubject.send(.finish) + } + } + // MARK: - Setup private func setupNavigation() { @@ -202,7 +209,6 @@ public final class DetailViewController< } private func dismissDetail() { - onDismissWithDate?(viewModel.state.currentDate) if shouldPopToRoot { navigationController?.popToRootViewController(animated: true) } else { diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift index ed0252a5..70270e17 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift @@ -39,6 +39,8 @@ private extension EditCoordinator { switch event { case .presentAddressSearch(let input): self?.presentAddressSearch(input: input) + case .finish: + self?.finish() } } .store(in: &cancellables) @@ -55,4 +57,8 @@ private extension EditCoordinator { addChild(coord) coord.start(input: input) } + + func finish() { + parentCoordinator?.removeChild(self) + } } diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift index 8ef78884..e9ba0a67 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift @@ -7,5 +7,6 @@ import Combine public enum EditFlow { case presentAddressSearch(AddressSearchSceneInput) + case finish } diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift index 80187a45..ef1f134b 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift @@ -149,6 +149,13 @@ public final class EditFoodRecordViewController< super.viewWillDisappear(animated) } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent { + flowSubject.send(.finish) + } + } + // MARK: - Setup private func setupNavigation() { diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift index 99dbb84d..650d04f7 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift @@ -3,7 +3,10 @@ // Presentation // +import Combine +import Data import Domain +import Photos import UIKit // MARK: - Coordinator @@ -14,6 +17,7 @@ public final class ImagePickerCoordinator: Coordinator { private let factories: Factories private weak var navigationController: UINavigationController? + private var cancellables = Set() public init(factories: Factories, navigationController: UINavigationController?) { self.factories = factories @@ -22,6 +26,24 @@ public final class ImagePickerCoordinator: Coordinator { public func start(input: ImagePickerSceneInput) { let vc = factories.imagePicker.makeScene(input: input) + flowBind(vc: vc) navigationController?.pushViewController(vc, animated: true) } } + +// MARK: - Screen Routing + +private extension ImagePickerCoordinator { + func flowBind(vc: ImagePickerViewController) { + vc.resultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.finish() + } + .store(in: &cancellables) + } + + func finish() { + parentCoordinator?.removeChild(self) + } +} diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift index b440ebfc..c5a26172 100644 --- a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift @@ -3,6 +3,7 @@ // Presentation // +import Combine import UIKit public final class MyPageCoordinator: Coordinator { @@ -11,6 +12,7 @@ public final class MyPageCoordinator: Coordinator { private let factories: Factories private weak var navigationController: UINavigationController? + private var cancellables = Set() public init(factories: Factories, navigationController: UINavigationController?) { self.factories = factories @@ -20,6 +22,29 @@ public final class MyPageCoordinator: Coordinator { public func start() { let vc = factories.myPage.makeScene() vc.hidesBottomBarWhenPushed = true + flowBind(vc: vc) navigationController?.pushViewController(vc, animated: true) } } + +// MARK: - Screen Routing + +private extension MyPageCoordinator { + func flowBind(vc: MyPageViewController) { + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .finish: + self?.finish() + } + } + .store(in: &cancellables) + } +} + +private extension MyPageCoordinator { + func finish() { + parentCoordinator?.removeChild(self) + } +} diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageFlow.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageFlow.swift new file mode 100644 index 00000000..c5068c62 --- /dev/null +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageFlow.swift @@ -0,0 +1,10 @@ +// +// MyPageFlow.swift +// Presentation +// + +import Foundation + +public enum MyPageFlow { + case finish +} diff --git a/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift b/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift index 3cd8af5f..94e8e58b 100644 --- a/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift +++ b/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift @@ -59,6 +59,13 @@ public final class MyPageViewController: UIViewController { private let viewModel: MyPageViewModel + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } + // MARK: - State private var cancellables = Set() @@ -108,6 +115,13 @@ public final class MyPageViewController: UIViewController { NotificationCenter.default.removeObserver(self) } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent { + flowSubject.send(.finish) + } + } + public override func viewDidLoad() { super.viewDidLoad() setupNavigation() From ee18a906de046e0f9340c32cf56d60ffd562ed03 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Fri, 17 Apr 2026 20:57:50 +0900 Subject: [PATCH 77/97] =?UTF-8?q?fix:=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EB=88=84=EC=88=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppCoordinator.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/FoodDiary/App/Sources/AppCoordinator.swift b/FoodDiary/App/Sources/AppCoordinator.swift index f45773fb..14057223 100644 --- a/FoodDiary/App/Sources/AppCoordinator.swift +++ b/FoodDiary/App/Sources/AppCoordinator.swift @@ -24,6 +24,8 @@ final class AppCoordinator: Coordinator { extension AppCoordinator { func pushLoginVC() { + childCoordinators.removeAll() + let loginCoordinator = LoginCoordinator(factories: factories) loginCoordinator.sceneTransitioner = sceneTransitioner loginCoordinator.parentCoordinator = self @@ -33,6 +35,8 @@ extension AppCoordinator { } func pushMainVC() { + childCoordinators.removeAll() + let mainCoordinator = MainCoordinator(factories: factories) mainCoordinator.sceneTransitioner = sceneTransitioner mainCoordinator.parentCoordinator = self From e4b55758fd7981a40fe1c949469e29b2d643983a Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 21 Apr 2026 17:03:46 +0900 Subject: [PATCH 78/97] =?UTF-8?q?chore:=20Sentry=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tuist/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index e2dc6cb3..1acb1277 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -18,6 +18,6 @@ let package = Package( .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.6.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.0.0"), - .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.0.0"), + .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.40.0"), ] ) From 6cfd0b75ec8135defb7b005c03dd53fc2043e6d2 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 21 Apr 2026 17:10:44 +0900 Subject: [PATCH 79/97] =?UTF-8?q?chore:=20Sentry=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tuist/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 1acb1277..97fbdccc 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -18,6 +18,6 @@ let package = Package( .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.6.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.0.0"), - .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "8.40.0"), + .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "9.5.0"), ] ) From 1fbba6d4e623d3424c8d8ecb24e249c9a34bb63c Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 21 Apr 2026 16:58:50 +0900 Subject: [PATCH 80/97] =?UTF-8?q?chore:=20WeeklyCalendarViewController=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20Data=20import=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index 1ec8aa1b..3a55f6d6 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -4,7 +4,6 @@ // import Combine -import Data import DesignSystem import Domain import SnapKit From 009dd1a35ab8eb180779ee30d52fcd9fc1e8d04f Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 21 Apr 2026 16:58:53 +0900 Subject: [PATCH 81/97] =?UTF-8?q?feat:=20=EA=B6=8C=ED=95=9C=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=20=EC=95=88=EB=82=B4=20=ED=99=94=EB=A9=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(PermissionViewController,=20Coordinator,=20SceneFa?= =?UTF-8?q?ctory)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Core/SceneFactories.swift | 5 +- .../Coordinator/PermissionCoordinator.swift | 28 ++++++ .../Coordinator/PermissionSceneFactory.swift | 12 +++ .../Permission/PermissionViewController.swift | 98 +++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionCoordinator.swift create mode 100644 FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionSceneFactory.swift create mode 100644 FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift diff --git a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift index d66c8383..b443cf90 100644 --- a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift +++ b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift @@ -14,6 +14,7 @@ public struct Factories { public let imagePicker: ImagePickerSceneFactory public let edit: EditSceneFactory public let addressSearch: AddressSearchSceneFactory + public let permission: PermissionSceneFactory public init( login: LoginSceneFactory, @@ -23,7 +24,8 @@ public struct Factories { detail: DetailSceneFactory, imagePicker: ImagePickerSceneFactory, edit: EditSceneFactory, - addressSearch: AddressSearchSceneFactory + addressSearch: AddressSearchSceneFactory, + permission: PermissionSceneFactory ) { self.login = login self.calendar = calendar @@ -33,5 +35,6 @@ public struct Factories { self.imagePicker = imagePicker self.edit = edit self.addressSearch = addressSearch + self.permission = permission } } diff --git a/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionCoordinator.swift b/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionCoordinator.swift new file mode 100644 index 00000000..8ded2773 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionCoordinator.swift @@ -0,0 +1,28 @@ +// +// PermissionCoordinator.swift +// Presentation +// + +import UIKit + +public final class PermissionCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var presentingViewController: UIViewController? + + public init( + factories: Factories, + presentingViewController: UIViewController? + ) { + self.factories = factories + self.presentingViewController = presentingViewController + } + + public func start() { + let vc = factories.permission.makeScene() + vc.modalPresentationStyle = .fullScreen + presentingViewController?.present(vc, animated: true) + } +} diff --git a/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionSceneFactory.swift b/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionSceneFactory.swift new file mode 100644 index 00000000..0291ffb9 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionSceneFactory.swift @@ -0,0 +1,12 @@ +// +// PermissionSceneFactory.swift +// Presentation +// + +public final class PermissionSceneFactory { + public init() {} + + public func makeScene() -> PermissionViewController { + PermissionViewController() + } +} diff --git a/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift b/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift new file mode 100644 index 00000000..a39b0cfd --- /dev/null +++ b/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift @@ -0,0 +1,98 @@ +// +// PermissionViewController.swift +// Presentation +// + +import DesignSystem +import SnapKit +import UIKit + +/// 권한이 거부된 상태에서 표시되는 전체화면 안내 뷰 +public final class PermissionViewController: UIViewController { + + // MARK: - UI + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = "앱 사용을 위해\n접근 권한을 허용해주세요" + label.numberOfLines = 0 + label.textAlignment = .center + label.font = .systemFont(ofSize: 24, weight: .bold) + label.textColor = DesignSystemAsset.gray050.color + return label + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.text = "일부 권한이 거부되어 서비스 이용이\n제한될 수 있습니다." + label.numberOfLines = 0 + label.textAlignment = .center + label.font = .systemFont(ofSize: 14, weight: .regular) + label.textColor = DesignSystemAsset.gray400.color + return label + }() + + private let settingsButton: MumukPrimaryButton = { + let button = MumukPrimaryButton() + button.configure(title: "설정으로 이동") + return button + }() + + // MARK: - Init + + public init() { + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupAction() + } +} + +// MARK: - Setup + +private extension PermissionViewController { + func setupUI() { + view.backgroundColor = DesignSystemAsset.sdBase.color + + view.addSubview(titleLabel) + view.addSubview(descriptionLabel) + view.addSubview(settingsButton) + + titleLabel.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.centerY.equalToSuperview().offset(-60) + $0.leading.trailing.equalToSuperview().inset(24) + } + + descriptionLabel.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.top.equalTo(titleLabel.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(24) + } + + settingsButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(24) + $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + $0.height.equalTo(50) + } + } + + func setupAction() { + settingsButton.addTarget(self, action: #selector(didTapSettings), for: .touchUpInside) + } + + @objc func didTapSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} From 6540c28104df3188c1a7083c72c9fef97482f421 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 21 Apr 2026 16:58:56 +0900 Subject: [PATCH 82/97] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B1=B0=EB=B6=80=20=EC=8B=9C=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=ED=91=9C=EC=8B=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppCoordinator.swift | 11 +++++++++ FoodDiary/App/Sources/AppFlowController.swift | 24 +++++++++++++++---- FoodDiary/App/Sources/SceneDelegate.swift | 10 ++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/FoodDiary/App/Sources/AppCoordinator.swift b/FoodDiary/App/Sources/AppCoordinator.swift index 14057223..2c4d2055 100644 --- a/FoodDiary/App/Sources/AppCoordinator.swift +++ b/FoodDiary/App/Sources/AppCoordinator.swift @@ -50,6 +50,17 @@ extension AppCoordinator { } } +// MARK: - Permission + +extension AppCoordinator { + func presentPermissionVC(from presenter: UIViewController) { + let coord = PermissionCoordinator(factories: factories, presentingViewController: presenter) + coord.parentCoordinator = self + addChild(coord) + coord.start() + } +} + // MARK: - Deep Link Navigation extension AppCoordinator { diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index c3bdaf99..b9567d12 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -25,17 +25,20 @@ final class AppFlowController: UIViewController, SceneTransitioning { private var splashView: SplashView? private let coordinator: AppCoordinator private let transitionHandler: ViewTransitionHandling + private let photoAuthFetcher: PhotoAuthorizationFetcher public init( appCoordinator: AppCoordinator, loginSession: LoginSession, container: DIContainer, - transitionHandler: ViewTransitionHandling + transitionHandler: ViewTransitionHandling, + photoAuthFetcher: PhotoAuthorizationFetcher ) { self.coordinator = appCoordinator self.loginSession = loginSession self.container = container self.transitionHandler = transitionHandler + self.photoAuthFetcher = photoAuthFetcher super.init(nibName: nil, bundle: nil) } @@ -64,6 +67,8 @@ final class AppFlowController: UIViewController, SceneTransitioning { loginSession.logoutPublisher .receive(on: DispatchQueue.main) .sink { [weak self] in + // 로그아웃 시 열려있는 모달(권한 화면 등)을 닫은 후 로그인 화면으로 전환 + self?.dismiss(animated: false) self?.coordinator.pushLoginVC() } .store(in: &cancellables) @@ -104,7 +109,7 @@ final class AppFlowController: UIViewController, SceneTransitioning { } await MainActor.run { [weak self] in guard let self else { return } - loginResult.isFirst ? showOnboarding() : coordinator.pushMainVC() + loginResult.isFirst ? showOnboarding() : pushMain() } } } @@ -117,12 +122,23 @@ final class AppFlowController: UIViewController, SceneTransitioning { .sink { [weak self] _ in guard let self else { return } registerForRemoteNotificationsAfterLogin() - coordinator.pushMainVC() + pushMain() _ = cancellable } transition(to: UINavigationController(rootViewController: onboardingVC)) } + private func pushMain() { + coordinator.pushMainVC() + checkPhotoPermission() + } + + private func checkPhotoPermission() { + let status = photoAuthFetcher.authorizationStatus() + guard status == .denied || status == .restricted else { return } + coordinator.presentPermissionVC(from: self) + } + private func navigateToDetailFromDeepLink(diaryDateString: String) { coordinator.navigateToDetail(diaryDateString: diaryDateString) } @@ -185,7 +201,7 @@ extension AppFlowController { fileprivate func routeToAppropriateScreen(isLogin: Bool) { if isLogin { - coordinator.pushMainVC() + pushMain() registerForRemoteNotificationsAfterLogin() } else { coordinator.pushLoginVC() diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 75e7ca85..09c8846d 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -528,6 +528,10 @@ extension SceneDelegate { fatalError("CalendarSceneFactory dependencies not registered") } + guard let photoAuthFetcher = try? container.resolve(PhotoAuthorizationFetcher.self) else { + fatalError("PhotoAuthorizationFetcher not registered") + } + let factories = Factories( login: LoginSceneFactory(useCase: finalizeUseCase), calendar: CalendarSceneFactory( @@ -560,7 +564,8 @@ extension SceneDelegate { updateFoodRecordUseCase: updateFoodRecordUseCase, deleteFoodRecordUseCase: deleteFoodRecordUseCase ), - addressSearch: AddressSearchSceneFactory(searchAddressUseCase: searchAddressUseCase) + addressSearch: AddressSearchSceneFactory(searchAddressUseCase: searchAddressUseCase), + permission: PermissionSceneFactory() ) guard let loginSession = try? container.resolve(LoginSession.self) else { @@ -573,7 +578,8 @@ extension SceneDelegate { appCoordinator: appCoordinator, loginSession: loginSession, container: container, - transitionHandler: transitionHandler + transitionHandler: transitionHandler, + photoAuthFetcher: photoAuthFetcher ) appCoordinator.sceneTransitioner = appFlowController return appFlowController From 6bd6289575ff4ef19454e53a07d49121e2ead906 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 21 Apr 2026 20:18:28 +0900 Subject: [PATCH 83/97] =?UTF-8?q?design:=20=EC=97=90=EC=85=8B=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 --- .../Contents.json | 23 ++++++++++++++++++ .../icon-permission-photo.png | Bin 0 -> 1179 bytes .../icon-permission-photo@2x.png | Bin 0 -> 1684 bytes .../icon-permission-photo@3x.png | Bin 0 -> 2260 bytes 4 files changed, 23 insertions(+) create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/Contents.json create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo.png create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@2x.png create mode 100644 FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@3x.png diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/Contents.json new file mode 100644 index 00000000..7bbfd977 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon-permission-photo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon-permission-photo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon-permission-photo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo.png new file mode 100644 index 0000000000000000000000000000000000000000..61cc1855291216bf34591ed0128e37b74fcbf759 GIT binary patch literal 1179 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEa{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBC{d9b;hE;^%b*2hb1<+l zN-=;;U<6`2Mrk1AfjnEKjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^| z#0uTKVr7USFmqf|i<65o3raHc^AtelCMM;Vme?vOfh>Xps5^5D;1=Z-LwyDGpMFJR zfxe-hfj-=1phg>@AFZ5=QWHz^i$e1AbL`wQ({mGT^wEW(_SooyEJtz-#HV0UpjYj< zfI4BpX2-?yKjs7j1CxfQi(`lf@7u8b*&=}g|L(oJm*&{iTi+0IgPo=O@Fla*QxTC% z4CZnqDXUx*;cO}j^?0l)v_?pOXG}+jBBzVU#TY@4i7O^HNcdR_?~&grn_hN(cWwFh zo!Vx-78~AFZ#KSfZk+#Z@A8ZnZy2t6`A>Om<*W;T(SBvXUq;6 z?h4tI=e!w~p&O?}dp~$2x?uhOf4b8eQ=N5fxA3mlIzBbRz*OLvNRD&xyhBgy9gi>i z_gZyQe04o{LAI>7U}`~8=e_ey@8X!^mM!{Y{c16%SNno9wF}C4WmM;$MkG3xS z!h0b@$l}M6dhWDbPL1OZt+w|(*0fEYptGs&du*(GvGRdi$!zyoQ`KMp`mkW?1LNtb zw!9n(5hAls?K*RZrLLAe&Y$rn=lyeb%sDp6pSCCR=dTeihz@!t_hB~&N5W>6sy#dz zI-FH?vyDw|_W6rfo^pRr(Y++1{P&b+7+pU^Ti}|CM<>c)I$ztaD0e0ssUAk}v=O literal 0 HcmV?d00001 diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@2x.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1ee85816f706b7196b60bf06ca3c48e3134b8b47 GIT binary patch literal 1684 zcmZ8h2{@E#9Dj##o5+!Iq%pZh+Z^aHSFRYxh)E1O48}cXm?;x>!$?{kHlkLZMz#^5 zRfDB*6)Bb4t?fc2MM2e>#{t+537>}p zKs*9~5${+_02z8AWHY^@>2zQRVRZn9nE-_Y3<@gDbOC$7Y=QDuUI~CbaR5GF;|FC0 zL@AbqtSpAjS3{gwX`vb~R{j@53gDNk`VvYSJX#1JfFFDm0Xs=4u7a#W;sTihCf%19 z$>m^KQQQbNR>3=JJiJ&Ueq0LJLdu zE9-ih&=vj4az}mFA?Y366Ae9~Ri#0kKdZ~6(&EtS#^)z)Pfp6`DtlM;T`NoHA_@oQ zp7e!0T{rZeJ2r4Mz>#!XN82p>0vru@4O9=W+3j6rtSY+Ume=Wt4&v$}3mE1DwkHB>nR!P(r|Cf?yG@4Xd^hkBhh~H6KRDxZwlTepUbb!(eS@w zE}kU@n;%F&Z@b7!qgEb#>`VnmEZZ$+SGY{rz?Q`y8;keQLx&&kZLzB(n%N~q@Tf&ER0k&xPFukfR3$KPc5msx)J5wZU|er`2kOR@ zZ*aRK1g+D)nSNL`%N*Iu#zQx8a{h%gca+3xyE`6PEiE>hLci|+3+ORM7VC`c@3@HP zeUws@2bV0qy5vo?&HEm83PySQ8Z5MQ&M`0Ncu}myxXXxsYyx}5Ypm%b{)WnvWZOo` z<%~sEyW2WYReh(v(!duVcuBv43m+jj$G3pSNyl!Pdww+BVp6GW6J7$ro;uOnlEm8s=Cc# z?Vx$Y-OgqY71MGLU)8ktO$Waup3l`al!s3wzuOzUdRH~SS$8ReEej+BEPwOdJPZ3$ z5YUSmT|3)oV%z)7Wa>bEm+|Fc{EFU&tr(~4xAhBdNsh^_Udf8-m4=LGIs{oR(chxI zz3_3a$6L7hHdx)#;ad`)6LCeK4`Vz1(~zfZ?^NH_6Tg0(flTm~CB~P~>Nk25BFjr{ zq(4hH(GaFucK5rtA6aA4Bq|MJ8>M=WM@{FL5Is&aeZc*V<+F=MKcv5rx1!`(=VqPS zlJntD0uC^$JP`KUHOoN=BRFkN1;mTpg*j zW$Daey1qfTiYUZ)%>71F+m`~lwGt|bmFUS_Pk2nK@FIDqj@JCH`0skH_j9jv4bT1u D6t#*I literal 0 HcmV?d00001 diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@3x.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..2c8cca2acd6d94691be39730b15ee870f4a2defe GIT binary patch literal 2260 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 V*sg(D*0%QFcCc}_uD0|`{u96tqjUfO literal 0 HcmV?d00001 From fe2bc367db477721344b122bddd759d6ea18df48 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 21 Apr 2026 20:19:06 +0900 Subject: [PATCH 84/97] =?UTF-8?q?design:=20=EA=B6=8C=ED=95=9C=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=20=EC=95=88=EB=82=B4=20=ED=99=94=EB=A9=B4=20=EB=94=94?= =?UTF-8?q?=ED=85=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 --- .../Permission/PermissionViewController.swift | 112 +++++++++++++++--- 1 file changed, 94 insertions(+), 18 deletions(-) diff --git a/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift b/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift index a39b0cfd..b4f5f529 100644 --- a/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift +++ b/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift @@ -7,34 +7,73 @@ import DesignSystem import SnapKit import UIKit -/// 권한이 거부된 상태에서 표시되는 전체화면 안내 뷰 +/// 권한 요청 화면 public final class PermissionViewController: UIViewController { // MARK: - UI private let titleLabel: UILabel = { let label = UILabel() - label.text = "앱 사용을 위해\n접근 권한을 허용해주세요" - label.numberOfLines = 0 - label.textAlignment = .center - label.font = .systemFont(ofSize: 24, weight: .bold) + label.numberOfLines = 2 + label.textAlignment = .left + label.setText("앱 사용을 위해\n접근 권한을 허용해주세요", style: .hd18, lineSpacing: 6) label.textColor = DesignSystemAsset.gray050.color return label }() - private let descriptionLabel: UILabel = { + private let sectionLabel: UILabel = { let label = UILabel() - label.text = "일부 권한이 거부되어 서비스 이용이\n제한될 수 있습니다." - label.numberOfLines = 0 - label.textAlignment = .center - label.font = .systemFont(ofSize: 14, weight: .regular) + label.textAlignment = .left + label.setText("필수적 접근 권한", style: .p14) + label.textColor = DesignSystemAsset.gray400.color + return label + }() + + private let permissionCardView: UIView = { + let view = UIView() + view.backgroundColor = .white.withAlphaComponent(0.02) + view.layer.cornerRadius = 16 + return view + }() + + private let iconContainerView: UIView = { + let view = UIView() + view.layer.cornerRadius = 10 + return view + }() + + private let permissionIconImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = DesignSystemAsset.iconPermissionPhoto.image + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let permissionNameLabel: UILabel = { + let label = UILabel() + label.textColor = DesignSystemAsset.gray050.color + label.setText("사진", style: .p15) + return label + }() + + private let permissionDescriptionLabel: UILabel = { + let label = UILabel() + label.setText("기록 시 사진 사용", style: .p15) + label.textColor = DesignSystemAsset.gray400.color + return label + }() + + private let warningLabel: UILabel = { + let label = UILabel() + label.textAlignment = .left + label.setText("권한 허용이 되지 않는다면 앱을 사용할 수 없습니다.", style: .p12) label.textColor = DesignSystemAsset.gray400.color return label }() private let settingsButton: MumukPrimaryButton = { let button = MumukPrimaryButton() - button.configure(title: "설정으로 이동") + button.configure(title: "동의하고 시작하기") return button }() @@ -65,25 +104,62 @@ private extension PermissionViewController { view.backgroundColor = DesignSystemAsset.sdBase.color view.addSubview(titleLabel) - view.addSubview(descriptionLabel) + view.addSubview(sectionLabel) + view.addSubview(permissionCardView) + view.addSubview(warningLabel) view.addSubview(settingsButton) + permissionCardView.addSubview(iconContainerView) + iconContainerView.addSubview(permissionIconImageView) + permissionCardView.addSubview(permissionNameLabel) + permissionCardView.addSubview(permissionDescriptionLabel) + titleLabel.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.centerY.equalToSuperview().offset(-60) + $0.top.equalTo(view.safeAreaLayoutGuide).offset(60) + $0.leading.trailing.equalToSuperview().inset(24) + } + + sectionLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(40) + $0.leading.equalToSuperview().inset(24) + } + + permissionCardView.snp.makeConstraints { + $0.top.equalTo(sectionLabel.snp.bottom).offset(16) $0.leading.trailing.equalToSuperview().inset(24) + $0.height.equalTo(72) + } + + iconContainerView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(16) + $0.centerY.equalToSuperview() + $0.size.equalTo(40) + } + + permissionIconImageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(22) + } + + permissionNameLabel.snp.makeConstraints { + $0.leading.equalTo(iconContainerView.snp.trailing).offset(2) + $0.centerY.equalToSuperview() + } + + permissionDescriptionLabel.snp.makeConstraints { + $0.leading.equalTo(permissionNameLabel.snp.trailing).offset(8) + $0.centerY.equalToSuperview() } - descriptionLabel.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.top.equalTo(titleLabel.snp.bottom).offset(16) + warningLabel.snp.makeConstraints { + $0.top.equalTo(permissionCardView.snp.bottom).offset(16) $0.leading.trailing.equalToSuperview().inset(24) } settingsButton.snp.makeConstraints { $0.leading.trailing.equalToSuperview().inset(24) $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) - $0.height.equalTo(50) + $0.height.equalTo(56) } } From 71070f88f80d53d6e7c053791026aaac3b70e57c Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Tue, 21 Apr 2026 20:30:02 +0900 Subject: [PATCH 85/97] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppFlowController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index b9567d12..9814c9b2 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -67,8 +67,6 @@ final class AppFlowController: UIViewController, SceneTransitioning { loginSession.logoutPublisher .receive(on: DispatchQueue.main) .sink { [weak self] in - // 로그아웃 시 열려있는 모달(권한 화면 등)을 닫은 후 로그인 화면으로 전환 - self?.dismiss(animated: false) self?.coordinator.pushLoginVC() } .store(in: &cancellables) From ee66e9c736fa6bf5d469f6b49db6e5924896d13b Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 22 Apr 2026 13:10:51 +0900 Subject: [PATCH 86/97] =?UTF-8?q?chore:=20=EB=94=94=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index 97e274ca..5cd4358b 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -150,7 +150,6 @@ public final class MonthlyCalendarViewModel< do { for try await monthDays in fetchMonthlyCalendarDaysUseCase.execute(for: period, currentMonth: date) { - try await Task.sleep(for: .seconds(1)) state.monthDays = monthDays state.numberOfWeeks = monthDays.count / 7 } From 64ec806372514f0c52555a9a64bb6abaaeaeecdb Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 22 Apr 2026 13:46:41 +0900 Subject: [PATCH 87/97] =?UTF-8?q?feat:=20Gradient=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/MonthlyCalendarDayCell.swift | 14 +++++++- .../Components/DayCellView.swift | 28 ++++++++++----- .../Sources/Core/GradientBackgroundView.swift | 34 +++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 FoodDiary/Presentation/Sources/Core/GradientBackgroundView.swift diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift index 2a4fe246..3ab1254b 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift @@ -57,6 +57,12 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { return view }() + private lazy var gradientBackgroundView: GradientBackgroundView = { + let view = GradientBackgroundView(cornerRadius: Constants.cornerRadius) + view.isHidden = true + return view + }() + // MARK: - Init override init(frame: CGRect) { @@ -82,6 +88,7 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { dashedBorderView.layer.cornerRadius = Constants.cornerRadius contentView.addSubview(containerView) + containerView.addSubview(gradientBackgroundView) containerView.addSubview(stackView) } @@ -103,6 +110,10 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { polaroidImageCardsView.snp.makeConstraints { $0.height.equalTo(polaroidImageCardsView.snp.width) } + + gradientBackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } } // MARK: - Configuration @@ -134,10 +145,11 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { dashedBorderView.isHidden = false polaroidImageCardsView.isHidden = true polaroidImageCardsView.alpha = 1 + gradientBackgroundView.isHidden = true } private func applyTodayStyle() { - containerView.backgroundColor = .primary + gradientBackgroundView.isHidden = false containerView.layer.borderWidth = Constants.todayBorderWidth containerView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor containerView.applyGlow( diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift index ca5bcc3a..7533aa77 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift @@ -27,6 +27,12 @@ final class DayCellView: UIView { return view }() + private lazy var gradientBackgroundView: GradientBackgroundView = { + let view = GradientBackgroundView(cornerRadius: Constants.cornerRadius) + view.isHidden = true + return view + }() + private let dayOfWeekLabel: UILabel = { let label = UILabel() label.textAlignment = .center @@ -64,12 +70,17 @@ final class DayCellView: UIView { private func setupUI() { addSubview(containerView) + containerView.addSubview(gradientBackgroundView) containerView.addSubview(dayOfWeekLabel) containerView.addSubview(dayNumberLabel) containerView.addSubview(recordIndicator) containerView.snp.makeConstraints { $0.edges.equalToSuperview().inset(2) } + gradientBackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + recordIndicator.snp.makeConstraints { $0.top.equalToSuperview().offset(8) $0.centerX.equalToSuperview() @@ -109,9 +120,12 @@ final class DayCellView: UIView { } private func applyStyle(dayOfWeek: String, dayNumber: String, isToday: Bool, isFuture: Bool, isSelected: Bool) { + containerView.backgroundColor = .clear + containerView.layer.cornerRadius = Constants.cornerRadius + if isSelected { - containerView.backgroundColor = DesignSystemAsset.primary.color - containerView.layer.cornerRadius = Constants.cornerRadius + gradientBackgroundView.isHidden = false + gradientBackgroundView.alpha = 1 containerView.applyGlow( glowColor: .primary, borderColor: UIColor.white.withAlphaComponent(0.3), @@ -121,22 +135,20 @@ final class DayCellView: UIView { dayNumberLabel.setText(dayNumber, style: .p12, color: .white) recordIndicator.backgroundColor = .white } else if isFuture { - containerView.backgroundColor = .clear - containerView.layer.cornerRadius = Constants.cornerRadius + gradientBackgroundView.isHidden = true containerView.removeGlow() dayOfWeekLabel.setText(dayOfWeek, style: .p12, color: .gray700) dayNumberLabel.setText(dayNumber, style: .p12, color: .gray700) recordIndicator.isHidden = true } else if isToday { - containerView.backgroundColor = DesignSystemAsset.primary.color.withAlphaComponent(0.2) - containerView.layer.cornerRadius = Constants.cornerRadius + gradientBackgroundView.isHidden = false + gradientBackgroundView.alpha = 0.2 containerView.removeGlow() dayOfWeekLabel.setText(dayOfWeek, style: .p12, color: .gray300) dayNumberLabel.setText(dayNumber, style: .p12, color: .white) recordIndicator.backgroundColor = DesignSystemAsset.primary.color } else { - containerView.backgroundColor = .clear - containerView.layer.cornerRadius = Constants.cornerRadius + gradientBackgroundView.isHidden = true containerView.removeGlow() dayOfWeekLabel.setText(dayOfWeek, style: .p12, color: .gray300) dayNumberLabel.setText(dayNumber, style: .p12, color: .white) diff --git a/FoodDiary/Presentation/Sources/Core/GradientBackgroundView.swift b/FoodDiary/Presentation/Sources/Core/GradientBackgroundView.swift new file mode 100644 index 00000000..c1200b07 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/GradientBackgroundView.swift @@ -0,0 +1,34 @@ +// +// GradientBackgroundView.swift +// Presentation +// + +import DesignSystem +import UIKit + +final class GradientBackgroundView: UIView { + + private let gradientLayer = CAGradientLayer() + + init(cornerRadius: CGFloat) { + super.init(frame: .zero) + gradientLayer.colors = [ + DesignSystemAsset.primary.color.cgColor, + DesignSystemAsset.primaryLight.color.cgColor, + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + gradientLayer.cornerRadius = cornerRadius + layer.insertSublayer(gradientLayer, at: 0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } +} From 9d0b5c182c30ea4324ce51e8900e2ca3ecc9db7a Mon Sep 17 00:00:00 2001 From: enebin Date: Thu, 23 Apr 2026 23:44:16 +0900 Subject: [PATCH 88/97] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=ED=91=B8=EC=8B=9C=202=ED=9A=8C=20=EC=88=98?= =?UTF-8?q?=EC=8B=A0=20=EC=8B=9C=20=EC=95=B1=20=EC=8A=A4=ED=86=A0=EC=96=B4?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/SceneDelegate.swift | 13 ++++++++ .../Core/Review/AnalysisCountStorage.swift | 31 +++++++++++++++++++ .../Core/Review/AnalysisCountStoring.swift | 13 ++++++++ .../CheckAppReviewEligibilityUseCase.swift | 30 ++++++++++++++++++ .../Coordinator/CalendarSceneFactory.swift | 6 +++- .../ViewModel/WeeklyCalendarViewModel.swift | 10 +++++- .../WeeklyCalendarViewController.swift | 8 +++++ 7 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 FoodDiary/Data/Sources/Core/Review/AnalysisCountStorage.swift create mode 100644 FoodDiary/Domain/Sources/Core/Review/AnalysisCountStoring.swift create mode 100644 FoodDiary/Domain/Sources/UseCase/CheckAppReviewEligibilityUseCase.swift diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 09c8846d..58d1effa 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -194,6 +194,10 @@ extension SceneDelegate { container.register(CoachmarkStoring.self) { _ in CoachmarkStorage() } + + container.register(AnalysisCountStorage.self) { _ in + AnalysisCountStorage() + } } fileprivate func registerDomain() { @@ -375,6 +379,13 @@ extension SceneDelegate { return GetNicknameUseCase(nicknameStorage: nicknameStorage) } + container.register(CheckAppReviewEligibilityUseCase.self) { resolver in + guard let storage = resolver.resolve(AnalysisCountStorage.self) else { + fatalError("AnalysisCountStorage not registered") + } + return CheckAppReviewEligibilityUseCase(analysisCountStorage: storage) + } + container.register(GetAppVersionUseCase.self) { _ in let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" return GetAppVersionUseCase(appVersion: version) @@ -523,6 +534,7 @@ extension SceneDelegate { let requestPhotoAuthUseCase = try? container.resolve(RequestPhotoAuthorizationUseCase.self), let loadWeeklyUseCase = try? container.resolve(LoadWeeklyRecordUseCase.self), let coachmarkStorage = try? container.resolve(CoachmarkStoring.self), + let checkAppReviewUseCase = try? container.resolve(CheckAppReviewEligibilityUseCase.self), let fetchMonthlyUseCase = try? container.resolve(FetchMonthlyCalendarDaysUseCase.self) else { fatalError("CalendarSceneFactory dependencies not registered") @@ -541,6 +553,7 @@ extension SceneDelegate { pushNotificationObserver: pushNotificationObserver, getNicknameUseCase: getNicknameUseCase, coachmarkStorage: coachmarkStorage, + checkAppReviewEligibilityUseCase: checkAppReviewUseCase, fetchMonthlyCalendarDaysUseCase: fetchMonthlyUseCase, fetchFoodRecordsUseCase: fetchRecordsUseCase ), diff --git a/FoodDiary/Data/Sources/Core/Review/AnalysisCountStorage.swift b/FoodDiary/Data/Sources/Core/Review/AnalysisCountStorage.swift new file mode 100644 index 00000000..db3a81f1 --- /dev/null +++ b/FoodDiary/Data/Sources/Core/Review/AnalysisCountStorage.swift @@ -0,0 +1,31 @@ +// +// AnalysisCountStorage.swift +// Data +// + +import Domain +import Foundation + +public struct AnalysisCountStorage: AnalysisCountStoring { + private let countKey = "analysis_push_count" + private let reviewRequestedKey = "app_review_requested" + + public init() {} + + public func getCount() -> Int { + UserDefaults.standard.integer(forKey: countKey) + } + + public func incrementCount() { + let current = getCount() + UserDefaults.standard.set(current + 1, forKey: countKey) + } + + public func hasRequestedReview() -> Bool { + UserDefaults.standard.bool(forKey: reviewRequestedKey) + } + + public func setReviewRequested() { + UserDefaults.standard.set(true, forKey: reviewRequestedKey) + } +} diff --git a/FoodDiary/Domain/Sources/Core/Review/AnalysisCountStoring.swift b/FoodDiary/Domain/Sources/Core/Review/AnalysisCountStoring.swift new file mode 100644 index 00000000..0d8e4f6d --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Review/AnalysisCountStoring.swift @@ -0,0 +1,13 @@ +// +// AnalysisCountStoring.swift +// Domain +// + +import Foundation + +public protocol AnalysisCountStoring { + func getCount() -> Int + func incrementCount() + func hasRequestedReview() -> Bool + func setReviewRequested() +} diff --git a/FoodDiary/Domain/Sources/UseCase/CheckAppReviewEligibilityUseCase.swift b/FoodDiary/Domain/Sources/UseCase/CheckAppReviewEligibilityUseCase.swift new file mode 100644 index 00000000..22f34a73 --- /dev/null +++ b/FoodDiary/Domain/Sources/UseCase/CheckAppReviewEligibilityUseCase.swift @@ -0,0 +1,30 @@ +// +// CheckAppReviewEligibilityUseCase.swift +// Domain +// + +import Foundation + +public struct CheckAppReviewEligibilityUseCase { + private let analysisCountStorage: AnalysisCountStoring + private let requiredCount: Int + + public init( + analysisCountStorage: AnalysisCountStoring, + requiredCount: Int = 2 + ) { + self.analysisCountStorage = analysisCountStorage + self.requiredCount = requiredCount + } + + /// 카운트를 증가시키고, 리뷰 요청 조건 충족 여부를 반환 + public func execute() -> Bool { + guard !analysisCountStorage.hasRequestedReview() else { return false } + analysisCountStorage.incrementCount() + if analysisCountStorage.getCount() >= requiredCount { + analysisCountStorage.setReviewRequested() + return true + } + return false + } +} diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift index d4f30e16..6a4d9edf 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -17,6 +17,7 @@ public final class CalendarSceneFactory { private let pushNotificationObserver: PushNotificationObserver private let getNicknameUseCase: GetNicknameUseCase private let coachmarkStorage: any CoachmarkStoring + private let checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase @@ -30,6 +31,7 @@ public final class CalendarSceneFactory { pushNotificationObserver: PushNotificationObserver, getNicknameUseCase: GetNicknameUseCase, coachmarkStorage: any CoachmarkStoring, + checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase, fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase>>, fetchFoodRecordsUseCase: FetchFoodRecordsUseCase>> ) { @@ -39,6 +41,7 @@ public final class CalendarSceneFactory { self.pushNotificationObserver = pushNotificationObserver self.getNicknameUseCase = getNicknameUseCase self.coachmarkStorage = coachmarkStorage + self.checkAppReviewEligibilityUseCase = checkAppReviewEligibilityUseCase self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase self.fetchFoodRecordsUseCase = fetchFoodRecordsUseCase } @@ -50,7 +53,8 @@ public final class CalendarSceneFactory { saveFoodRecordUseCase: saveFoodRecordUseCase, pushNotificationObserver: pushNotificationObserver, getNicknameUseCase: getNicknameUseCase, - coachmarkStorage: coachmarkStorage + coachmarkStorage: coachmarkStorage, + checkAppReviewEligibilityUseCase: checkAppReviewEligibilityUseCase ) let monthlyVM = MonthlyCalendarViewModel( fetchMonthlyCalendarDaysUseCase: fetchMonthlyCalendarDaysUseCase, diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift index c6e56c7c..033e1ed8 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift @@ -51,6 +51,7 @@ public final class WeeklyCalendarViewModel< private let pushNotificationObserver: PushObserver private let getNicknameUseCase: GetNicknameUseCase private let coachmarkStorage: any CoachmarkStoring + private let checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase // MARK: - Init @@ -60,7 +61,8 @@ public final class WeeklyCalendarViewModel< saveFoodRecordUseCase: SaveFoodRecordUseCase, pushNotificationObserver: PushObserver, getNicknameUseCase: GetNicknameUseCase, - coachmarkStorage: any CoachmarkStoring + coachmarkStorage: any CoachmarkStoring, + checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase ) { self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase self.loadWeeklyCalendarDataUseCase = loadWeeklyCalendarDataUseCase @@ -68,6 +70,7 @@ public final class WeeklyCalendarViewModel< self.pushNotificationObserver = pushNotificationObserver self.getNicknameUseCase = getNicknameUseCase self.coachmarkStorage = coachmarkStorage + self.checkAppReviewEligibilityUseCase = checkAppReviewEligibilityUseCase let cal = Calendar.current self.calendar = cal @@ -305,6 +308,10 @@ public final class WeeklyCalendarViewModel< if notificationDate == selectedDate { await updateDateContent(for: state.selectedDate) } + + if checkAppReviewEligibilityUseCase.execute() { + eventSubject.send(.requestAppReview) + } } } @@ -342,6 +349,7 @@ extension WeeklyCalendarViewModel { case uploadCompleted(date: Date, mealType: MealType) case saveFailed(Error) case loadFailed(Error) + case requestAppReview } } diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index 3a55f6d6..de73e40b 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -7,6 +7,7 @@ import Combine import DesignSystem import Domain import SnapKit +import StoreKit import UIKit private enum Constants { @@ -246,6 +247,8 @@ public final class WeeklyCalendarViewController< self?.showSaveErrorAlert(error) case .loadFailed(let error): self?.showLoadErrorAlert(error) + case .requestAppReview: + self?.requestAppReview() } } .store(in: &cancellables) @@ -345,5 +348,10 @@ public final class WeeklyCalendarViewController< alert.addAction(UIAlertAction(title: "확인", style: .default)) present(alert, animated: true) } + + private func requestAppReview() { + guard let windowScene = view.window?.windowScene else { return } + AppStore.requestReview(in: windowScene) + } } From 0494ce831c124cf01ea319e7850914a3a3392255 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Mon, 27 Apr 2026 19:17:22 +0900 Subject: [PATCH 89/97] =?UTF-8?q?refactor:=20iOS=20=EC=B5=9C=EC=86=8C?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Project.swift | 2 +- FoodDiary/DI/Project.swift | 2 +- FoodDiary/Data/Project.swift | 4 ++-- FoodDiary/DesignSystem/Project.swift | 2 +- FoodDiary/Domain/Project.swift | 4 ++-- FoodDiary/Presentation/Project.swift | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/FoodDiary/App/Project.swift b/FoodDiary/App/Project.swift index 4a00cf41..8a9d2c1b 100644 --- a/FoodDiary/App/Project.swift +++ b/FoodDiary/App/Project.swift @@ -17,7 +17,7 @@ let project = Project( destinations: [.iPhone], product: .app, bundleId: "com.fooddiary.ios.app", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), infoPlist: .sceneDelegateApp(), sources: ["Sources/**"], resources: ["Resources/**"], diff --git a/FoodDiary/DI/Project.swift b/FoodDiary/DI/Project.swift index 97c2d7ef..080318f2 100644 --- a/FoodDiary/DI/Project.swift +++ b/FoodDiary/DI/Project.swift @@ -15,7 +15,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.di", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], dependencies: [ .external(name: "Swinject"), diff --git a/FoodDiary/Data/Project.swift b/FoodDiary/Data/Project.swift index 8c9a0d66..5b3cda15 100644 --- a/FoodDiary/Data/Project.swift +++ b/FoodDiary/Data/Project.swift @@ -15,7 +15,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.data", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], resources: ["Resources/**"], dependencies: [ @@ -29,7 +29,7 @@ let project = Project( destinations: [.iPhone], product: .unitTests, bundleId: "com.fooddiary.data.tests", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Tests/**"], resources: ["Tests/Resources/**"], dependencies: [ diff --git a/FoodDiary/DesignSystem/Project.swift b/FoodDiary/DesignSystem/Project.swift index 005902f6..3bddb1dc 100644 --- a/FoodDiary/DesignSystem/Project.swift +++ b/FoodDiary/DesignSystem/Project.swift @@ -15,7 +15,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.designsystem", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], resources: ["Resources/**"], dependencies: [ diff --git a/FoodDiary/Domain/Project.swift b/FoodDiary/Domain/Project.swift index 29f31154..00784fd2 100644 --- a/FoodDiary/Domain/Project.swift +++ b/FoodDiary/Domain/Project.swift @@ -15,7 +15,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.domain", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], dependencies: [] ), @@ -24,7 +24,7 @@ let project = Project( destinations: [.iPhone], product: .unitTests, bundleId: "com.fooddiary.domain.tests", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Tests/**"], dependencies: [ .target(name: "Domain") diff --git a/FoodDiary/Presentation/Project.swift b/FoodDiary/Presentation/Project.swift index 88461f36..aaa0c48a 100644 --- a/FoodDiary/Presentation/Project.swift +++ b/FoodDiary/Presentation/Project.swift @@ -16,7 +16,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.presentation", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], resources: ["Resources/**"], dependencies: [ @@ -32,7 +32,7 @@ let project = Project( destinations: [.iPhone], product: .unitTests, bundleId: "com.fooddiary.presentation.tests", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Tests/**"], dependencies: [ .target(name: "Presentation") From a1d97e70474b2e09d85f3c3fdfa35ddbb3b59f82 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Thu, 23 Apr 2026 23:21:41 +0900 Subject: [PATCH 90/97] =?UTF-8?q?refactor:=20=EC=A0=9C=EB=84=A4=EB=A6=AD?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppFlowController.swift | 24 +- FoodDiary/App/Sources/SceneDelegate.swift | 244 +++++------------- .../FoodImageAssetFetcher.swift | 27 +- .../Core/TokenManager/AuthTokenStorage.swift | 6 +- .../Core/TokenManager/KeychainServicing.swift | 2 +- .../Data/Sources/Network/HTTPClienting.swift | 2 +- .../Sources/Repository/AuthRepository.swift | 8 +- .../Repository/DeviceRepositoryImpl.swift | 8 +- .../Repository/FoodRecordRepositoryImpl.swift | 11 +- .../Repository/InsightRepositoryImpl.swift | 8 +- .../Sources/Repository/TokenRepository.swift | 14 +- .../Sources/Repository/UIImageLoader.swift | 7 +- .../Repository/UserRepositoryImpl.swift | 8 +- .../InitialLaunch/InitialLaunchStoring.swift | 2 +- .../Core/TokenManager/AuthTokenStoring.swift | 2 +- .../Sources/Entity/FoodImageAsset.swift | 6 +- .../Repository/FoodImageAssetRepository.swift | 4 +- .../RenderableImageRepository.swift | 8 +- .../UseCase/DeleteFoodRecordUseCase.swift | 6 +- .../UseCase/FetchFoodImageAssetUseCase.swift | 8 +- .../UseCase/FetchFoodRecordsUseCase.swift | 6 +- .../Sources/UseCase/FetchInsightUseCase.swift | 6 +- .../FetchMonthlyCalendarDaysUseCase.swift | 6 +- .../UseCase/FetchUserProfileUseCase.swift | 8 +- .../UseCase/LoadWeeklyRecordUseCase.swift | 19 +- .../RequestPhotoAuthorizationUseCase.swift | 6 +- .../UseCase/SaveFoodRecordUseCase.swift | 8 +- .../UseCase/SearchAddressUseCase.swift | 6 +- .../UseCase/UpdateFoodRecordUseCase.swift | 6 +- .../UseCase/ValidateAccessTokenUseCase.swift | 8 +- .../FoodImageAssetFetchUseCaseTests.swift | 2 +- .../Mock/MockFoodImageAssetRepository.swift | 6 +- .../AddressSearchViewController.swift | 12 +- .../AddressSearchViewModel.swift | 6 +- .../AddressSearchCoordinator.swift | 2 +- .../AddressSearchSceneFactory.swift | 6 +- .../Coordinator/CalendarSceneFactory.swift | 26 +- .../MonthlyCalendarViewController.swift | 9 +- .../ViewModel/MonthlyCalendarViewModel.swift | 17 +- .../ViewModel/WeeklyCalendarViewModel.swift | 27 +- .../WeeklyCalendarViewController.swift | 14 +- .../Coordinator/DetailCoordinator.swift | 2 +- .../Coordinator/DetailSceneFactory.swift | 19 +- .../Sources/Detail/DetailViewController.swift | 13 +- .../Sources/Detail/DetailViewModel.swift | 21 +- .../Coordinator/EditCoordinator.swift | 2 +- .../Coordinator/EditSceneFactory.swift | 14 +- .../EditFoodRecordViewController.swift | 10 +- .../EditFoodRecordViewModel.swift | 10 +- .../Coordinator/ImagePickerCoordinator.swift | 2 +- .../Coordinator/ImagePickerSceneFactory.swift | 12 +- .../ImagePicker/ImagePickerResult.swift | 4 +- .../ImagePickerViewController.swift | 35 ++- .../Coordinator/InsightSceneFactory.swift | 8 +- .../Insight/InsightViewController.swift | 6 +- .../Sources/Insight/InsightViewModel.swift | 6 +- 56 files changed, 282 insertions(+), 493 deletions(-) diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index 9814c9b2..3cdc4950 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -15,9 +15,6 @@ import UIKit import UserNotifications final class AppFlowController: UIViewController, SceneTransitioning { - private typealias AssetFetcher = FoodImageAssetFetcher - private typealias FetchUseCase = FetchFoodImageAssetUseCase - private var networkCancellable: AnyCancellable? private var cancellables = Set() private let container: DIContainer @@ -187,7 +184,7 @@ extension AppFlowController { } private func prefetchFoodImageAssets() { - guard let useCase = try? container.resolve(FetchUseCase.self) else { return } + guard let useCase = try? container.resolve(FetchFoodImageAssetUseCase.self) else { return } useCase.prefetch(forPreviousWeeks: 2, of: Date()) } @@ -237,31 +234,16 @@ extension AppFlowController { } fileprivate func validateToken() async -> Bool { - guard - let validateAccessTokenUseCase = try? container.resolve( - ValidateAccessTokenUseCase< - TokenRepositoryImpl, InitialLaunchStorage> - >.self - ) - else { + guard let validateAccessTokenUseCase = try? container.resolve(ValidateAccessTokenUseCase.self) else { fatalError("ValidateAccessTokenUseCase Failed Resolve") } - return await validateAccessTokenUseCase.execute() } fileprivate func fetchUserProfile() async throws { - guard - let fetchUserProfileUseCase = try? container.resolve( - FetchUserProfileUseCase< - UserRepositoryImpl>, - NicknameStorage - >.self - ) - else { + guard let fetchUserProfileUseCase = try? container.resolve(FetchUserProfileUseCase.self) else { fatalError("FetchUserProfileUseCase Failed Resolve") } - try await fetchUserProfileUseCase.execute() } diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 58d1effa..480837b6 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -59,33 +59,27 @@ extension SceneDelegate { HTTPClient() } - container.register(AuthTokenStorage.self) { resolver in + container.register(AuthTokenStorage.self) { resolver in guard let service = resolver.resolve(KeychainService.self) else { fatalError("KeychainService not registered") } - return AuthTokenStorage(keychainService: service) } container.register(AuthRepository.self) { resolver in - guard let storage = resolver.resolve(AuthTokenStorage.self) else { - fatalError("AuthTokenStorage not registered") - } - - guard let client = resolver.resolve(HTTPClient.self) else { - fatalError("HTTPClient not registered") + guard let storage = resolver.resolve(AuthTokenStorage.self), + let client = resolver.resolve(HTTPClient.self) else { + fatalError("AuthRepositoryImpl dependencies not registered") } - return AuthRepositoryImpl(httpClient: client, tokenStorage: storage) } container.register(TokenRepository.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self), + let storage = resolver.resolve(AuthTokenStorage.self), let launchStorage = resolver.resolve(InitialLaunchStorage.self) else { fatalError("TokenRepositoryImpl dependencies not registered") } - return TokenRepositoryImpl(httpClient: client, storage: storage, launchStorage: launchStorage) } @@ -112,12 +106,10 @@ extension SceneDelegate { ClassificationCacheManager() } - container.register(FoodImageAssetFetcher.self) { - resolver in + container.register(FoodImageAssetFetcher.self) { resolver in guard let classifier = resolver.resolve(TFLiteFoodClassifier.self), - let imageRepository = resolver.resolve(UIImageLoader.self), - let cache = resolver.resolve(ClassificationCacheManager.self) - else { + let imageRepository = resolver.resolve(UIImageLoader.self), + let cache = resolver.resolve(ClassificationCacheManager.self) else { fatalError("FoodImageAssetFetcher dependencies not registered") } return FoodImageAssetFetcher( @@ -127,13 +119,10 @@ extension SceneDelegate { ) } - container.register( - FoodRecordRepositoryImpl>.self - ) { resolver in + container.register(FoodRecordRepositoryImpl.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self), - let imageConverter = resolver.resolve(PHAssetConverter.self) - else { + let storage = resolver.resolve(AuthTokenStorage.self), + let imageConverter = resolver.resolve(PHAssetConverter.self) else { fatalError("FoodRecordRepositoryImpl dependencies not registered") } let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? "" @@ -152,8 +141,7 @@ extension SceneDelegate { container.register(AddressSearchRepositoryImpl.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self) - else { + let storage = resolver.resolve(AuthTokenStorage.self) else { fatalError("AddressSearchRepositoryImpl dependencies not registered") } return AddressSearchRepositoryImpl(httpClient: client, tokenStorage: storage) @@ -161,7 +149,7 @@ extension SceneDelegate { container.register(DeviceRepository.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self) else { + let storage = resolver.resolve(AuthTokenStorage.self) else { fatalError("DeviceRepository dependencies not registered") } return DeviceRepositoryImpl(httpClient: client, tokenStorage: storage) @@ -169,15 +157,15 @@ extension SceneDelegate { container.register(UserRepository.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self) else { + let storage = resolver.resolve(AuthTokenStorage.self) else { fatalError("UserRepositoryImpl dependencies not registered") } return UserRepositoryImpl(httpClient: client, tokenStorage: storage) } - container.register(InsightRepositoryImpl>.self) { resolver in + container.register(InsightRepositoryImpl.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self) else { + let storage = resolver.resolve(AuthTokenStorage.self) else { fatalError("InsightRepositoryImpl dependencies not registered") } return InsightRepositoryImpl(httpClient: client, tokenStorage: storage) @@ -207,16 +195,13 @@ extension SceneDelegate { container.register(FinalizeAppleLoginUseCase.self) { resolver in guard let repository = resolver.resolve(AuthRepository.self), - let pushTokenStorage = resolver.resolve(PushTokenStoring.self), - let notificationAuthProvider = resolver.resolve( - NotificationAuthorizationProviding.self), - let launchStorage = resolver.resolve(InitialLaunchStorage.self), - let loginSession = resolver.resolve(LoginSession.self), - let deviceId = UIDevice.current.identifierForVendor?.uuidString - else { + let pushTokenStorage = resolver.resolve(PushTokenStoring.self), + let notificationAuthProvider = resolver.resolve(NotificationAuthorizationProviding.self), + let launchStorage = resolver.resolve(InitialLaunchStorage.self), + let loginSession = resolver.resolve(LoginSession.self), + let deviceId = UIDevice.current.identifierForVendor?.uuidString else { fatalError("FinalizeAppleLoginUseCase dependencies not registered") } - return FinalizeAppleLoginUseCase( authRepository: repository, deviceId: deviceId, @@ -228,104 +213,51 @@ extension SceneDelegate { ) } - container.register( - ValidateAccessTokenUseCase< - TokenRepositoryImpl, InitialLaunchStorage> - >.self - ) { resolver in + container.register(ValidateAccessTokenUseCase.self) { resolver in guard let repository = resolver.resolve(TokenRepository.self) else { fatalError("TokenRepository not registered") } - - guard - let concreteRepository = repository - as? TokenRepositoryImpl, InitialLaunchStorage> - else { - fatalError("TokenRepository is not of expected type") - } - - return ValidateAccessTokenUseCase(repository: concreteRepository) + return ValidateAccessTokenUseCase(repository: repository) } - container.register( - FetchFoodImageAssetUseCase> - .self - ) { resolver in - guard - let repository = resolver.resolve( - FoodImageAssetFetcher.self) - else { + container.register(FetchFoodImageAssetUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodImageAssetFetcher.self) else { fatalError("FoodImageAssetFetcher not registered") } return FetchFoodImageAssetUseCase(repository: repository) } - container.register( - RequestPhotoAuthorizationUseCase.self - ) { resolver in + container.register(RequestPhotoAuthorizationUseCase.self) { resolver in guard let repository = resolver.resolve(PhotoAuthorizationFetcher.self) else { fatalError("PhotoAuthorizationFetcher not registered") } return RequestPhotoAuthorizationUseCase(repository: repository) } - container.register( - SaveFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let recordRepo = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(SaveFoodRecordUseCase.self) { resolver in + guard let recordRepo = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("SaveFoodRecordUseCase dependencies not registered") } return SaveFoodRecordUseCase(repository: recordRepo) } - container.register( - FetchMonthlyCalendarDaysUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(FetchMonthlyCalendarDaysUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") } return FetchMonthlyCalendarDaysUseCase(repository: repository) } - container.register( - FetchFoodRecordsUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(FetchFoodRecordsUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") } return FetchFoodRecordsUseCase(repository: repository) } - container.register( - LoadWeeklyRecordUseCase< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher - >.self - ) { resolver in - guard - let recordRepo = resolver.resolve( - FoodRecordRepositoryImpl>.self), - let fetchAssetUseCase = resolver.resolve( - FetchFoodImageAssetUseCase< - FoodImageAssetFetcher - >.self - ) - else { + container.register(LoadWeeklyRecordUseCase.self) { resolver in + guard let recordRepo = resolver.resolve(FoodRecordRepositoryImpl.self), + let fetchAssetUseCase = resolver.resolve(FetchFoodImageAssetUseCase.self) else { fatalError("LoadWeeklyRecordUseCase dependencies not registered") } return LoadWeeklyRecordUseCase( @@ -335,37 +267,21 @@ extension SceneDelegate { ) } - container.register( - UpdateFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(UpdateFoodRecordUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") } return UpdateFoodRecordUseCase(repository: repository) } - container.register( - DeleteFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(DeleteFoodRecordUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") } return DeleteFoodRecordUseCase(repository: repository) } - container.register( - SearchAddressUseCase.self - ) { resolver in + container.register(SearchAddressUseCase.self) { resolver in guard let addressRepo = resolver.resolve(AddressSearchRepositoryImpl.self) else { fatalError("AddressSearchRepositoryImpl not registered") } @@ -407,58 +323,32 @@ extension SceneDelegate { return WithdrawUserUseCase(authRepository: authRepository, loginSession: loginSession) } - container.register( - FetchInsightUseCase< - InsightRepositoryImpl> - >.self - ) { resolver in - guard let repository = resolver.resolve( - InsightRepositoryImpl>.self - ) else { + container.register(FetchInsightUseCase.self) { resolver in + guard let repository = resolver.resolve(InsightRepositoryImpl.self) else { fatalError("FetchInsightUseCase dependencies not registered") } return FetchInsightUseCase(repository: repository) } - container.register( - FetchUserProfileUseCase< - UserRepositoryImpl>, - NicknameStorage - >.self - ) { resolver in + container.register(FetchUserProfileUseCase.self) { resolver in guard let repository = resolver.resolve(UserRepository.self), - let concreteRepository = repository - as? UserRepositoryImpl>, - let nicknameStorage = resolver.resolve(NicknameStoring.self), - let concreteNicknameStorage = nicknameStorage as? NicknameStorage - else { - fatalError("FetchUserProfileUseCase dependencies not registered or unexpected type") + let nicknameStorage = resolver.resolve(NicknameStoring.self) else { + fatalError("FetchUserProfileUseCase dependencies not registered") } - return FetchUserProfileUseCase( - repository: concreteRepository, - nicknameStorage: concreteNicknameStorage - ) + return FetchUserProfileUseCase(repository: repository, nicknameStorage: nicknameStorage) } - container.register( - UpdateDeviceNotificationSettingUseCase.self - ) { resolver in + container.register(UpdateDeviceNotificationSettingUseCase.self) { resolver in guard let repository = resolver.resolve(DeviceRepository.self), let pushTokenProvider = resolver.resolve(PushTokenStoring.self), let notificationAuthProvider = resolver.resolve(NotificationAuthorizationProviding.self), let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { fatalError("UpdateDeviceNotificationSettingUseCase dependencies not registered") } - - guard let concreteRepository = repository as? DeviceRepositoryImpl> else { - fatalError("DeviceRepository is not of expected type") - } - let deviceID = UIDevice.current.identifierForVendor?.uuidString let osVersion = UIDevice.current.systemVersion - return UpdateDeviceNotificationSettingUseCase( - repository: concreteRepository, + repository: repository, notificationAuthorizationProvider: notificationAuthProvider, pushTokenProvider: pushTokenProvider, appVersion: appVersion, @@ -471,23 +361,14 @@ extension SceneDelegate { fileprivate func registerPresentation() {} fileprivate func makeAppFlowController() -> AppFlowController { - typealias RecordRepo = FoodRecordRepositoryImpl> - typealias AssetFetcher = FoodImageAssetFetcher - - // Login guard let finalizeUseCase = try? container.resolve(FinalizeAppleLoginUseCase.self) else { fatalError("FinalizeAppleLoginUseCase not registered") } - // Insight - typealias FetchInsightUC = FetchInsightUseCase< - InsightRepositoryImpl> - > - guard let fetchInsightUseCase = try? container.resolve(FetchInsightUC.self) else { + guard let fetchInsightUseCase = try? container.resolve(FetchInsightUseCase.self) else { fatalError("FetchInsightUseCase not registered") } - // MyPage guard let updateDeviceUseCase = try? container.resolve(UpdateDeviceNotificationSettingUseCase.self), let notificationAuthProvider = try? container.resolve(NotificationAuthorizationProviding.self), let logoutUseCase = try? container.resolve(LogoutUseCase.self), @@ -497,9 +378,7 @@ extension SceneDelegate { fatalError("MyPageSceneFactory dependencies not registered") } - // ImagePicker - typealias FetchImageAssetUC = FetchFoodImageAssetUseCase - guard let fetchImageAssetUseCase = try? container.resolve(FetchImageAssetUC.self), + guard let fetchImageAssetUseCase = try? container.resolve(FetchFoodImageAssetUseCase.self), let imageProvider = try? container.resolve(UIImageLoader.self) else { fatalError("ImagePickerCoordinator dependencies not registered") } @@ -509,33 +388,28 @@ extension SceneDelegate { fetchUseCase: fetchImageAssetUseCase ) - // Detail - guard - let fetchRecordsUseCase = try? container.resolve(FetchFoodRecordsUseCase.self), - let saveFoodRecordUseCase = try? container.resolve(SaveFoodRecordUseCase.self), - let deleteFoodRecordUseCase = try? container.resolve(DeleteFoodRecordUseCase.self), - let pushNotificationObserver = try? container.resolve(PushNotificationObserver.self) - else { + guard let fetchRecordsUseCase = try? container.resolve(FetchFoodRecordsUseCase.self), + let saveFoodRecordUseCase = try? container.resolve(SaveFoodRecordUseCase.self), + let deleteFoodRecordUseCase = try? container.resolve(DeleteFoodRecordUseCase.self), + let pushNotificationObserver = try? container.resolve(PushNotificationObserver.self) else { fatalError("DetailSceneFactory dependencies not registered") } - // Edit - guard let updateFoodRecordUseCase = try? container.resolve(UpdateFoodRecordUseCase.self) else { + guard let updateFoodRecordUseCase = try? container.resolve(UpdateFoodRecordUseCase.self) else { fatalError("EditSceneFactory dependencies not registered") } - // AddressSearch - guard let searchAddressUseCase = try? container.resolve(SearchAddressUseCase.self) else { + guard let searchAddressUseCase = try? container.resolve(SearchAddressUseCase.self) else { fatalError("AddressSearchSceneFactory dependencies not registered") } // Calendar guard - let requestPhotoAuthUseCase = try? container.resolve(RequestPhotoAuthorizationUseCase.self), - let loadWeeklyUseCase = try? container.resolve(LoadWeeklyRecordUseCase.self), + let requestPhotoAuthUseCase = try? container.resolve(RequestPhotoAuthorizationUseCase.self), + let loadWeeklyUseCase = try? container.resolve(LoadWeeklyRecordUseCase.self), let coachmarkStorage = try? container.resolve(CoachmarkStoring.self), let checkAppReviewUseCase = try? container.resolve(CheckAppReviewEligibilityUseCase.self), - let fetchMonthlyUseCase = try? container.resolve(FetchMonthlyCalendarDaysUseCase.self) + let fetchMonthlyUseCase = try? container.resolve(FetchMonthlyCalendarDaysUseCase.self) else { fatalError("CalendarSceneFactory dependencies not registered") } diff --git a/FoodDiary/Data/Sources/Core/FoodImageAsset/FoodImageAssetFetcher.swift b/FoodDiary/Data/Sources/Core/FoodImageAsset/FoodImageAssetFetcher.swift index 4644ba23..70b4b991 100644 --- a/FoodDiary/Data/Sources/Core/FoodImageAsset/FoodImageAssetFetcher.swift +++ b/FoodDiary/Data/Sources/Core/FoodImageAsset/FoodImageAssetFetcher.swift @@ -11,12 +11,9 @@ import Photos import UIKit /// 사진 라이브러리에서 음식 사진을 시간순으로 가져오는 `Repository` 구현체 -public struct FoodImageAssetFetcher< - FoodClassifier: FoodClassifierRepresentable, - ImageRepo: RenderableImageRepository ->: FoodImageAssetRepository where ImageRepo.Asset == PHAsset { - private let foodClassifier: FoodClassifier - private let imageRepository: ImageRepo +public struct FoodImageAssetFetcher: FoodImageAssetRepository { + private let foodClassifier: any FoodClassifierRepresentable + private let imageRepository: any RenderableImageRepository private let cache: ClassificationCacheManager private let imageTargetSize: CGSize private let logger = Logger(label: "com.fooddiary.asset-fetcher") @@ -27,8 +24,8 @@ public struct FoodImageAssetFetcher< /// - cache: 분류 결과 캐시 /// - imageTargetSize: ML 분류용 이미지 크기 (기본: 224x224) public init( - foodClassifier: FoodClassifier, - imageRepository: ImageRepo, + foodClassifier: any FoodClassifierRepresentable, + imageRepository: any RenderableImageRepository, cache: ClassificationCacheManager, imageTargetSize: CGSize = CGSize(width: 224, height: 224) ) { @@ -41,7 +38,7 @@ public struct FoodImageAssetFetcher< public func fetchFoodImageAssets( from startDate: Date, to endDate: Date? - ) async throws -> [Date: [FoodImageAsset]] { + ) async throws -> [Date: [FoodImageAsset]] { let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) guard status == .authorized || status == .limited else { logger.error("[Asset Fetch] 사진 라이브러리 권한 없음 (status: \(status.rawValue))") @@ -103,8 +100,6 @@ public struct FoodImageAssetFetcher< } } -public typealias PHFoodImageAsset = FoodImageAsset - // MARK: - Photo Fetching extension FoodImageAssetFetcher { @@ -150,7 +145,7 @@ extension FoodImageAssetFetcher { extension FoodImageAssetFetcher { fileprivate func classifyAllSections(_ sections: [PhotoSection]) async throws -> [Date: - [PHFoodImageAsset]] + [FoodImageAsset]] { let results = try await mapEach(sections) { section in let photos = try await self.classifyPhotosInSection(section) @@ -161,7 +156,7 @@ extension FoodImageAssetFetcher { } fileprivate func classifyPhotosInSection(_ section: PhotoSection) async throws - -> [PHFoodImageAsset] + -> [FoodImageAsset] { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MM/dd" @@ -170,7 +165,7 @@ extension FoodImageAssetFetcher { let start = clock.now return try await withThrowingTaskGroup( - of: (originalIndex: Int, photo: PHFoodImageAsset).self + of: (originalIndex: Int, photo: FoodImageAsset).self ) { group in for (index, asset) in section.assets.enumerated() { group.addTask { @@ -178,7 +173,7 @@ extension FoodImageAssetFetcher { } } - var results: [(originalIndex: Int, photo: PHFoodImageAsset)] = [] + var results: [(originalIndex: Int, photo: FoodImageAsset)] = [] for try await result in group { results.append(result) } @@ -193,7 +188,7 @@ extension FoodImageAssetFetcher { } } - fileprivate func classifyAsset(_ asset: PHAsset) async throws -> PHFoodImageAsset { + fileprivate func classifyAsset(_ asset: PHAsset) async throws -> FoodImageAsset { let identifier = asset.localIdentifier if let cached = await cache.get(identifier) { diff --git a/FoodDiary/Data/Sources/Core/TokenManager/AuthTokenStorage.swift b/FoodDiary/Data/Sources/Core/TokenManager/AuthTokenStorage.swift index 0b8768f5..6efc7fdb 100644 --- a/FoodDiary/Data/Sources/Core/TokenManager/AuthTokenStorage.swift +++ b/FoodDiary/Data/Sources/Core/TokenManager/AuthTokenStorage.swift @@ -9,11 +9,11 @@ import Foundation import Domain /// Keychain을 사용한 인증 토큰 저장소 구현체 -public struct AuthTokenStorage: AuthTokenStoring { - private let keychainService: Service +public struct AuthTokenStorage: AuthTokenStoring { + private let keychainService: any KeychainServicing private let tokenKey = "access_token" - public init(keychainService: Service) { + public init(keychainService: any KeychainServicing) { self.keychainService = keychainService } diff --git a/FoodDiary/Data/Sources/Core/TokenManager/KeychainServicing.swift b/FoodDiary/Data/Sources/Core/TokenManager/KeychainServicing.swift index 231bcc2b..5e6aacca 100644 --- a/FoodDiary/Data/Sources/Core/TokenManager/KeychainServicing.swift +++ b/FoodDiary/Data/Sources/Core/TokenManager/KeychainServicing.swift @@ -7,7 +7,7 @@ import Foundation -public protocol KeychainServicing { +public protocol KeychainServicing: Sendable { func save(key: String, value: String) -> Bool func load(key: String) -> String? func delete(key: String) -> Bool diff --git a/FoodDiary/Data/Sources/Network/HTTPClienting.swift b/FoodDiary/Data/Sources/Network/HTTPClienting.swift index 534464e0..c7bba17e 100644 --- a/FoodDiary/Data/Sources/Network/HTTPClienting.swift +++ b/FoodDiary/Data/Sources/Network/HTTPClienting.swift @@ -7,7 +7,7 @@ import Foundation -public protocol HTTPClienting { +public protocol HTTPClienting: Sendable { func request(_ request: some Requestable, accessToken: String?) async throws -> T func request(_ request: some Requestable, accessToken: String?) async throws } diff --git a/FoodDiary/Data/Sources/Repository/AuthRepository.swift b/FoodDiary/Data/Sources/Repository/AuthRepository.swift index d8dacb16..1ba16c94 100644 --- a/FoodDiary/Data/Sources/Repository/AuthRepository.swift +++ b/FoodDiary/Data/Sources/Repository/AuthRepository.swift @@ -8,11 +8,11 @@ import Domain import Foundation -public struct AuthRepositoryImpl: AuthRepository { - let httpClient: Client - let tokenStorage: Storage +public struct AuthRepositoryImpl: AuthRepository { + let httpClient: any HTTPClienting + let tokenStorage: any AuthTokenStoring - public init(httpClient: Client, tokenStorage: Storage) { + public init(httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring) { self.httpClient = httpClient self.tokenStorage = tokenStorage } diff --git a/FoodDiary/Data/Sources/Repository/DeviceRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/DeviceRepositoryImpl.swift index 187c2725..2726e1eb 100644 --- a/FoodDiary/Data/Sources/Repository/DeviceRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/DeviceRepositoryImpl.swift @@ -8,11 +8,11 @@ import Domain import Foundation -public struct DeviceRepositoryImpl: DeviceRepository { - private let httpClient: Client - private let tokenStorage: Storage +public struct DeviceRepositoryImpl: DeviceRepository { + private let httpClient: any HTTPClienting + private let tokenStorage: any AuthTokenStoring - public init(httpClient: Client, tokenStorage: Storage) { + public init(httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring) { self.httpClient = httpClient self.tokenStorage = tokenStorage } diff --git a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift index 35202819..1946d260 100644 --- a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift @@ -9,12 +9,9 @@ import Photos import UIKit /// FoodRecordRepository 구현체 -public struct FoodRecordRepositoryImpl< - Client: HTTPClienting & Sendable, - Storage: AuthTokenStoring & Sendable ->: FoodRecordRepository { - private let httpClient: Client - private let tokenStorage: Storage +public struct FoodRecordRepositoryImpl: FoodRecordRepository { + private let httpClient: any HTTPClienting + private let tokenStorage: any AuthTokenStoring private let deviceId: String private let imageConverter: PHAssetConverter private let calendar = Calendar.current @@ -27,7 +24,7 @@ public struct FoodRecordRepositoryImpl< #endif public init( - httpClient: Client, tokenStorage: Storage, deviceId: String, + httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring, deviceId: String, imageConverter: PHAssetConverter ) { self.httpClient = httpClient diff --git a/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift index 8eef292f..b0a80455 100644 --- a/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift @@ -6,11 +6,11 @@ import Domain import Foundation -public struct InsightRepositoryImpl: InsightRepository { - private let httpClient: Client - private let tokenStorage: Storage +public struct InsightRepositoryImpl: InsightRepository { + private let httpClient: any HTTPClienting + private let tokenStorage: any AuthTokenStoring - public init(httpClient: Client, tokenStorage: Storage) { + public init(httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring) { self.httpClient = httpClient self.tokenStorage = tokenStorage } diff --git a/FoodDiary/Data/Sources/Repository/TokenRepository.swift b/FoodDiary/Data/Sources/Repository/TokenRepository.swift index 604b5642..168a3acd 100644 --- a/FoodDiary/Data/Sources/Repository/TokenRepository.swift +++ b/FoodDiary/Data/Sources/Repository/TokenRepository.swift @@ -8,16 +8,12 @@ import Domain import Foundation -public struct TokenRepositoryImpl< - Client: HTTPClienting, - Storage: AuthTokenStoring, - LaunchStorage: InitialLaunchStoring ->: TokenRepository { - let httpClient: Client - let storage: Storage - let launchStorage: LaunchStorage +public struct TokenRepositoryImpl: TokenRepository { + let httpClient: any HTTPClienting + let storage: any AuthTokenStoring + let launchStorage: any InitialLaunchStoring - public init(httpClient: Client, storage: Storage, launchStorage: LaunchStorage) { + public init(httpClient: any HTTPClienting, storage: any AuthTokenStoring, launchStorage: any InitialLaunchStoring) { self.httpClient = httpClient self.storage = storage self.launchStorage = launchStorage diff --git a/FoodDiary/Data/Sources/Repository/UIImageLoader.swift b/FoodDiary/Data/Sources/Repository/UIImageLoader.swift index 25ab1656..7ff6b80e 100644 --- a/FoodDiary/Data/Sources/Repository/UIImageLoader.swift +++ b/FoodDiary/Data/Sources/Repository/UIImageLoader.swift @@ -14,8 +14,11 @@ public struct UIImageLoader: RenderableImageRepository { self.imageConverter = imageLoader } - public func loadImage(for asset: PHAsset, targetSize: CGSize, preferFastDelivery: Bool) async throws -> UIImage { + public func loadImage(for asset: any ImageAssetable, targetSize: CGSize, preferFastDelivery: Bool) async throws -> UIImage { + guard let phAsset = asset as? PHAsset else { + throw FoodRecordError.imageConversionFailed + } let deliveryMode: PHImageRequestOptionsDeliveryMode = preferFastDelivery ? .fastFormat : .highQualityFormat - return try await imageConverter.convert(from: asset, targetSize: targetSize, deliveryMode: deliveryMode) + return try await imageConverter.convert(from: phAsset, targetSize: targetSize, deliveryMode: deliveryMode) } } diff --git a/FoodDiary/Data/Sources/Repository/UserRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/UserRepositoryImpl.swift index 83ae229b..81c3d87c 100644 --- a/FoodDiary/Data/Sources/Repository/UserRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/UserRepositoryImpl.swift @@ -8,11 +8,11 @@ import Domain import Foundation -public struct UserRepositoryImpl: UserRepository { - private let httpClient: Client - private let tokenStorage: Storage +public struct UserRepositoryImpl: UserRepository { + private let httpClient: any HTTPClienting + private let tokenStorage: any AuthTokenStoring - public init(httpClient: Client, tokenStorage: Storage) { + public init(httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring) { self.httpClient = httpClient self.tokenStorage = tokenStorage } diff --git a/FoodDiary/Domain/Sources/Core/InitialLaunch/InitialLaunchStoring.swift b/FoodDiary/Domain/Sources/Core/InitialLaunch/InitialLaunchStoring.swift index 1563f29b..6490937f 100644 --- a/FoodDiary/Domain/Sources/Core/InitialLaunch/InitialLaunchStoring.swift +++ b/FoodDiary/Domain/Sources/Core/InitialLaunch/InitialLaunchStoring.swift @@ -7,7 +7,7 @@ import Foundation -public protocol InitialLaunchStoring { +public protocol InitialLaunchStoring: Sendable { func get() -> Bool func set() } diff --git a/FoodDiary/Domain/Sources/Core/TokenManager/AuthTokenStoring.swift b/FoodDiary/Domain/Sources/Core/TokenManager/AuthTokenStoring.swift index 1663c96f..e425783b 100644 --- a/FoodDiary/Domain/Sources/Core/TokenManager/AuthTokenStoring.swift +++ b/FoodDiary/Domain/Sources/Core/TokenManager/AuthTokenStoring.swift @@ -8,7 +8,7 @@ import Foundation /// 인증 토큰(Access Token)을 저장하고 관리하는 프로토콜 -public protocol AuthTokenStoring { +public protocol AuthTokenStoring: Sendable { /// 저장된 인증 토큰을 가져옴 /// - Returns: 인증 토큰 문자열, 없을 경우 nil func get() -> String? diff --git a/FoodDiary/Domain/Sources/Entity/FoodImageAsset.swift b/FoodDiary/Domain/Sources/Entity/FoodImageAsset.swift index 743d3b50..8b34961e 100644 --- a/FoodDiary/Domain/Sources/Entity/FoodImageAsset.swift +++ b/FoodDiary/Domain/Sources/Entity/FoodImageAsset.swift @@ -7,15 +7,15 @@ import Foundation -public struct FoodImageAsset: Sendable, Equatable { - public let imageAsset: ImageAsset +public struct FoodImageAsset: Sendable, Equatable { + public let imageAsset: any ImageAssetable /// 음식일 확률 (0.0 ~ 1.0) public let foodProbability: Float public var id: String { imageAsset.id } public var creationDate: Date? { imageAsset.creationDate } - public init(imageAsset: ImageAsset, foodProbability: Float) { + public init(imageAsset: any ImageAssetable, foodProbability: Float) { self.imageAsset = imageAsset self.foodProbability = foodProbability } diff --git a/FoodDiary/Domain/Sources/Repository/FoodImageAssetRepository.swift b/FoodDiary/Domain/Sources/Repository/FoodImageAssetRepository.swift index 41fb9dcb..6a36024b 100644 --- a/FoodDiary/Domain/Sources/Repository/FoodImageAssetRepository.swift +++ b/FoodDiary/Domain/Sources/Repository/FoodImageAssetRepository.swift @@ -8,8 +8,6 @@ import Foundation public protocol FoodImageAssetRepository: Sendable { - associatedtype Asset: ImageAssetable - /// 날짜별 음식 사진 조회 (음식 확률 순 정렬) /// - Parameters: /// - startDate: 조회 시작 날짜 (필수) @@ -18,7 +16,7 @@ public protocol FoodImageAssetRepository: Sendable { func fetchFoodImageAssets( from startDate: Date, to endDate: Date? - ) async throws -> [Date: [FoodImageAsset]] + ) async throws -> [Date: [FoodImageAsset]] /// 현재 주 + 과거 N주 범위의 사진 데이터를 백그라운드에서 미리 로드하여 캐시 워밍 /// - Parameters: diff --git a/FoodDiary/Domain/Sources/Repository/RenderableImageRepository.swift b/FoodDiary/Domain/Sources/Repository/RenderableImageRepository.swift index 65c62978..3faf0e65 100644 --- a/FoodDiary/Domain/Sources/Repository/RenderableImageRepository.swift +++ b/FoodDiary/Domain/Sources/Repository/RenderableImageRepository.swift @@ -6,20 +6,18 @@ import UIKit /// 이미지 로딩을 담당하는 Repository 프로토콜 -public protocol RenderableImageRepository: Sendable { - associatedtype Asset: ImageAssetable - +public protocol RenderableImageRepository: Sendable { /// 지정된 Asset의 이미지를 로드합니다. /// - Parameters: /// - asset: 이미지 에셋 /// - targetSize: 요청 이미지 크기 /// - preferFastDelivery: true이면 품질보다 속도를 우선합니다 (ML 전처리 등) /// - Returns: 로드된 UIImage - func loadImage(for asset: Asset, targetSize: CGSize, preferFastDelivery: Bool) async throws -> UIImage + func loadImage(for asset: any ImageAssetable, targetSize: CGSize, preferFastDelivery: Bool) async throws -> UIImage } extension RenderableImageRepository { - public func loadImage(for asset: Asset, targetSize: CGSize) async throws -> UIImage { + public func loadImage(for asset: any ImageAssetable, targetSize: CGSize) async throws -> UIImage { try await loadImage(for: asset, targetSize: targetSize, preferFastDelivery: false) } } diff --git a/FoodDiary/Domain/Sources/UseCase/DeleteFoodRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/DeleteFoodRecordUseCase.swift index a7cd42b9..9c1da5c4 100644 --- a/FoodDiary/Domain/Sources/UseCase/DeleteFoodRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/DeleteFoodRecordUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct DeleteFoodRecordUseCase: Sendable { - private let repository: Repository +public struct DeleteFoodRecordUseCase: Sendable { + private let repository: any FoodRecordRepository - public init(repository: Repository) { + public init(repository: any FoodRecordRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/FetchFoodImageAssetUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchFoodImageAssetUseCase.swift index 78cd908a..66ebea69 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchFoodImageAssetUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchFoodImageAssetUseCase.swift @@ -7,10 +7,10 @@ import Foundation -public struct FetchFoodImageAssetUseCase { - private let repository: Repository +public struct FetchFoodImageAssetUseCase { + private let repository: any FoodImageAssetRepository - public init(repository: Repository) { + public init(repository: any FoodImageAssetRepository) { self.repository = repository } @@ -18,7 +18,7 @@ public struct FetchFoodImageAssetUseCase { from startDate: Date, to endDate: Date?, priority: TaskPriority = .utility - ) async throws -> [Date: [FoodImageAsset]] { + ) async throws -> [Date: [FoodImageAsset]] { try await Task(priority: priority) { try await repository.fetchFoodImageAssets(from: startDate, to: endDate) }.value diff --git a/FoodDiary/Domain/Sources/UseCase/FetchFoodRecordsUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchFoodRecordsUseCase.swift index 2204f561..abec85c5 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchFoodRecordsUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchFoodRecordsUseCase.swift @@ -6,10 +6,10 @@ import Foundation /// 특정 날짜의 음식 기록 조회 UseCase -public struct FetchFoodRecordsUseCase: Sendable { - private let repository: Repository +public struct FetchFoodRecordsUseCase: Sendable { + private let repository: any FoodRecordRepository - public init(repository: Repository) { + public init(repository: any FoodRecordRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift index 94189c00..907c3080 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct FetchInsightUseCase { - private let repository: Repository +public struct FetchInsightUseCase { + private let repository: any InsightRepository - public init(repository: Repository) { + public init(repository: any InsightRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift index e303c5fa..8b99f1fb 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift @@ -6,10 +6,10 @@ import Foundation /// 월간 캘린더 데이터 조회 UseCase -public struct FetchMonthlyCalendarDaysUseCase: Sendable { - private let repository: Repository +public struct FetchMonthlyCalendarDaysUseCase: Sendable { + private let repository: any FoodRecordRepository - public init(repository: Repository) { + public init(repository: any FoodRecordRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/FetchUserProfileUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchUserProfileUseCase.swift index b4959964..0b42d3c9 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchUserProfileUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchUserProfileUseCase.swift @@ -7,11 +7,11 @@ import Foundation -public struct FetchUserProfileUseCase { - private let repository: Repository - private let nicknameStorage: Storage +public struct FetchUserProfileUseCase { + private let repository: any UserRepository + private let nicknameStorage: any NicknameStoring - public init(repository: Repository, nicknameStorage: Storage) { + public init(repository: any UserRepository, nicknameStorage: any NicknameStoring) { self.repository = repository self.nicknameStorage = nicknameStorage } diff --git a/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift index 06f474f8..7f760bb8 100644 --- a/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift @@ -6,10 +6,7 @@ import Foundation /// 주간 캘린더 데이터 로딩을 담당하는 UseCase -public struct LoadWeeklyRecordUseCase< - RecordRepo: FoodRecordRepository, - AssetRepo: FoodImageAssetRepository ->: Sendable { +public struct LoadWeeklyRecordUseCase: Sendable { public struct WeekData: Sendable { public let weekDays: [WeeklyCalendarDay] public let monthText: String @@ -21,11 +18,11 @@ public struct LoadWeeklyRecordUseCase< } public struct DateData: Sendable { - public let photos: [FoodImageAsset] + public let photos: [FoodImageAsset] public let records: [FoodRecord] public let startOfDay: Date - public init(photos: [FoodImageAsset], records: [FoodRecord], startOfDay: Date) { + public init(photos: [FoodImageAsset], records: [FoodRecord], startOfDay: Date) { self.photos = photos self.records = records self.startOfDay = startOfDay @@ -33,13 +30,13 @@ public struct LoadWeeklyRecordUseCase< } private let calendar: Calendar - private let recordRepository: RecordRepo - private let fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase + private let recordRepository: any FoodRecordRepository + private let fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase public init( calendar: Calendar, - recordRepository: RecordRepo, - fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase + recordRepository: any FoodRecordRepository, + fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase ) { self.calendar = calendar self.recordRepository = recordRepository @@ -75,7 +72,7 @@ public struct LoadWeeklyRecordUseCase< } /// 특정 날짜의 사진만 로드 - public func loadPhotos(for date: Date) async throws -> [FoodImageAsset] { + public func loadPhotos(for date: Date) async throws -> [FoodImageAsset] { let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) diff --git a/FoodDiary/Domain/Sources/UseCase/RequestPhotoAuthorizationUseCase.swift b/FoodDiary/Domain/Sources/UseCase/RequestPhotoAuthorizationUseCase.swift index f60a2eef..d7d43ae9 100644 --- a/FoodDiary/Domain/Sources/UseCase/RequestPhotoAuthorizationUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/RequestPhotoAuthorizationUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct RequestPhotoAuthorizationUseCase { - private let repository: Repository +public struct RequestPhotoAuthorizationUseCase { + private let repository: any PhotoAuthorizationRepository - public init(repository: Repository) { + public init(repository: any PhotoAuthorizationRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift index e874de84..082fca04 100644 --- a/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift @@ -7,13 +7,11 @@ import Foundation /// Asset을 서버에 업로드하는 UseCase /// 분석 결과는 Remote Push로 수신 -public struct SaveFoodRecordUseCase< - RecordRepo: FoodRecordRepository ->: Sendable { - private let repository: RecordRepo +public struct SaveFoodRecordUseCase: Sendable { + private let repository: any FoodRecordRepository public init( - repository: RecordRepo + repository: any FoodRecordRepository ) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/SearchAddressUseCase.swift b/FoodDiary/Domain/Sources/UseCase/SearchAddressUseCase.swift index 16d7c4c4..1fe66eda 100644 --- a/FoodDiary/Domain/Sources/UseCase/SearchAddressUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/SearchAddressUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct SearchAddressUseCase: Sendable { - private let repository: Repository +public struct SearchAddressUseCase: Sendable { + private let repository: any AddressSearchRepository - public init(repository: Repository) { + public init(repository: any AddressSearchRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/UpdateFoodRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/UpdateFoodRecordUseCase.swift index fbcaa1aa..671e8f55 100644 --- a/FoodDiary/Domain/Sources/UseCase/UpdateFoodRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/UpdateFoodRecordUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct UpdateFoodRecordUseCase: Sendable { - private let repository: Repository +public struct UpdateFoodRecordUseCase: Sendable { + private let repository: any FoodRecordRepository - public init(repository: Repository) { + public init(repository: any FoodRecordRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/ValidateAccessTokenUseCase.swift b/FoodDiary/Domain/Sources/UseCase/ValidateAccessTokenUseCase.swift index 5ae70946..b59e9e6a 100644 --- a/FoodDiary/Domain/Sources/UseCase/ValidateAccessTokenUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/ValidateAccessTokenUseCase.swift @@ -7,10 +7,10 @@ import Foundation -public struct ValidateAccessTokenUseCase { - private let repository: Repository - - public init(repository: Repository) { +public struct ValidateAccessTokenUseCase { + private let repository: any TokenRepository + + public init(repository: any TokenRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Tests/FoodImageAssetFetchUseCaseTests.swift b/FoodDiary/Domain/Tests/FoodImageAssetFetchUseCaseTests.swift index 3dae8fe2..1adad6c7 100644 --- a/FoodDiary/Domain/Tests/FoodImageAssetFetchUseCaseTests.swift +++ b/FoodDiary/Domain/Tests/FoodImageAssetFetchUseCaseTests.swift @@ -68,7 +68,7 @@ private func createMockFoodImageAssets( startIndex: Int = 0, count: Int, probabilities: [Float] -) -> [FoodImageAsset] { +) -> [FoodImageAsset] { (0..]] = [:] - - func fetchFoodImageAssets(from startDate: Date, to endDate: Date?) async throws -> [Date: [FoodImageAsset]] { + func fetchFoodImageAssets(from startDate: Date, to endDate: Date?) async throws -> [Date: [FoodImageAsset]] { resultToReturn } diff --git a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift index c4d87a0e..69ef0060 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift @@ -23,13 +23,11 @@ enum AddressSearchConstants { static let resultCellHeight: CGFloat = 72 } -public final class AddressSearchViewController< - AddressRepo: AddressSearchRepository ->: UIViewController, UITextFieldDelegate { +public final class AddressSearchViewController: UIViewController, UITextFieldDelegate { // MARK: - Dependencies - private let viewModel: AddressSearchViewModel + private let viewModel: AddressSearchViewModel private let onAddressSelected: ((AddressSearchResult) -> Void)? private var cancellables = Set() @@ -114,7 +112,7 @@ public final class AddressSearchViewController< // MARK: - Init public init( - viewModel: AddressSearchViewModel, + viewModel: AddressSearchViewModel, onAddressSelected: ((AddressSearchResult) -> Void)? ) { self.viewModel = viewModel @@ -233,7 +231,7 @@ public final class AddressSearchViewController< // MARK: - Private Methods - private func updateUI(with state: AddressSearchViewModel.State) { + private func updateUI(with state: AddressSearchViewModel.State) { let displayResults: [AddressSearchResult] switch state.mode { @@ -268,7 +266,7 @@ public final class AddressSearchViewController< } } - private func handleEvent(_ event: AddressSearchViewModel.Event) { + private func handleEvent(_ event: AddressSearchViewModel.Event) { switch event { case .addressSelected(let result): onAddressSelected?(result) diff --git a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewModel.swift b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewModel.swift index 562a76d9..17ca299b 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewModel.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewModel.swift @@ -7,7 +7,7 @@ import Combine import Domain import Foundation -public final class AddressSearchViewModel { +public final class AddressSearchViewModel { // MARK: - Output @@ -28,13 +28,13 @@ public final class AddressSearchViewModel private let stateSubject: CurrentValueSubject private let eventSubject = PassthroughSubject() private var cancellables = Set() - private let searchAddressUseCase: SearchAddressUseCase + private let searchAddressUseCase: SearchAddressUseCase private let diaryId: Int // MARK: - Init public init( - searchAddressUseCase: SearchAddressUseCase, + searchAddressUseCase: SearchAddressUseCase, diaryId: Int ) { self.searchAddressUseCase = searchAddressUseCase diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift index 08f86073..192133c5 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift @@ -31,7 +31,7 @@ public final class AddressSearchCoordinator: Coordinator { // MARK: - Screen Routing private extension AddressSearchCoordinator { - func flowBind(vc: AddressSearchViewController) { + func flowBind(vc: AddressSearchViewController) { vc.flowPublisher .receive(on: DispatchQueue.main) .sink { [weak self] event in diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift index 81be461a..fb57329b 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift @@ -10,13 +10,13 @@ import UIKit // MARK: - Factory public final class AddressSearchSceneFactory { - private let searchAddressUseCase: SearchAddressUseCase + private let searchAddressUseCase: SearchAddressUseCase - public init(searchAddressUseCase: SearchAddressUseCase) { + public init(searchAddressUseCase: SearchAddressUseCase) { self.searchAddressUseCase = searchAddressUseCase } - public func makeScene(input: AddressSearchSceneInput) -> AddressSearchViewController { + public func makeScene(input: AddressSearchSceneInput) -> AddressSearchViewController { let viewModel = AddressSearchViewModel( searchAddressUseCase: searchAddressUseCase, diaryId: input.diaryId diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift index 6a4d9edf..0b36ebbc 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -8,32 +8,26 @@ import Domain import UIKit public final class CalendarSceneFactory { - private typealias RecordRepo = FoodRecordRepositoryImpl> - private typealias AssetFetcher = FoodImageAssetFetcher - - private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase - private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase - private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase + private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase private let pushNotificationObserver: PushNotificationObserver private let getNicknameUseCase: GetNicknameUseCase private let coachmarkStorage: any CoachmarkStoring private let checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase - private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase - private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase + private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase + private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase public init( - requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, - loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher - >, - saveFoodRecordUseCase: SaveFoodRecordUseCase>>, + requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, + loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, pushNotificationObserver: PushNotificationObserver, getNicknameUseCase: GetNicknameUseCase, coachmarkStorage: any CoachmarkStoring, checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase, - fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase>>, - fetchFoodRecordsUseCase: FetchFoodRecordsUseCase>> + fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, + fetchFoodRecordsUseCase: FetchFoodRecordsUseCase ) { self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase self.loadWeeklyCalendarDataUseCase = loadWeeklyCalendarDataUseCase diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index 951c6611..b1832561 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -10,10 +10,7 @@ import Domain import SnapKit import UIKit -public final class MonthlyCalendarViewController< - RecordRepo: FoodRecordRepository, - AuthRepo: PhotoAuthorizationRepository ->: UIViewController, UICollectionViewDelegate { +public final class MonthlyCalendarViewController: UIViewController, UICollectionViewDelegate { private enum Section: Hashable { case calendar @@ -21,7 +18,7 @@ public final class MonthlyCalendarViewController< // MARK: - Dependencies - private let viewModel: MonthlyCalendarViewModel + private let viewModel: MonthlyCalendarViewModel // MARK: - Flow @@ -75,7 +72,7 @@ public final class MonthlyCalendarViewController< // MARK: - Init public init( - viewModel: MonthlyCalendarViewModel + viewModel: MonthlyCalendarViewModel ) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index 5cd4358b..2dca5981 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -7,10 +7,7 @@ import Combine import Domain import Foundation -public final class MonthlyCalendarViewModel< - RecordRepo: FoodRecordRepository, - AuthRepo: PhotoAuthorizationRepository -> { +public final class MonthlyCalendarViewModel { // MARK: - Output var statePublisher: AnyPublisher { @@ -41,17 +38,17 @@ public final class MonthlyCalendarViewModel< // MARK: - Dependencies - private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase - private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase - private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase + private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase + private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase + private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase private let getNicknameUseCase: GetNicknameUseCase // MARK: - Init public init( - fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, - requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, - fetchFoodRecordsUseCase: FetchFoodRecordsUseCase, + fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, + requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, + fetchFoodRecordsUseCase: FetchFoodRecordsUseCase, getNicknameUseCase: GetNicknameUseCase ) { self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift index 033e1ed8..6fc1e49b 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift @@ -10,12 +10,7 @@ import UIKit // MARK: - ViewModel -public final class WeeklyCalendarViewModel< - RecordRepo: FoodRecordRepository, - AssetRepo: FoodImageAssetRepository, - AuthRepo: PhotoAuthorizationRepository, - PushObserver: PushNotificationObserving -> { +public final class WeeklyCalendarViewModel { // MARK: - Output public var statePublisher: AnyPublisher { @@ -45,10 +40,10 @@ public final class WeeklyCalendarViewModel< // MARK: - Dependencies - private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase - private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase - private let saveFoodRecordUseCase: SaveFoodRecordUseCase - private let pushNotificationObserver: PushObserver + private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase + private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let pushNotificationObserver: any PushNotificationObserving private let getNicknameUseCase: GetNicknameUseCase private let coachmarkStorage: any CoachmarkStoring private let checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase @@ -56,10 +51,10 @@ public final class WeeklyCalendarViewModel< // MARK: - Init public init( - requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, - loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, - saveFoodRecordUseCase: SaveFoodRecordUseCase, - pushNotificationObserver: PushObserver, + requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, + loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, + pushNotificationObserver: any PushNotificationObserving, getNicknameUseCase: GetNicknameUseCase, coachmarkStorage: any CoachmarkStoring, checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase @@ -234,7 +229,7 @@ public final class WeeklyCalendarViewModel< } @MainActor - private func savePhotosAsRecord(_ assets: [AssetRepo.Asset]) async { + private func savePhotosAsRecord(_ assets: [any ImageAssetable]) async { guard !assets.isEmpty else { return } do { @@ -337,7 +332,7 @@ extension WeeklyCalendarViewModel { case goToPreviousWeek case goToNextWeek case selectDate(Date) - case saveSelectedPhotos([AssetRepo.Asset]) + case saveSelectedPhotos([any ImageAssetable]) case handlePushNotification(AnalysisResultNotification) case refreshData(Date? = nil) case viewDidAppear diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index de73e40b..878baf2f 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -14,16 +14,11 @@ private enum Constants { static let horizontalInset: CGFloat = 20 } -public final class WeeklyCalendarViewController< - RecordRepo: FoodRecordRepository, - AssetRepo: FoodImageAssetRepository, - AuthRepo: PhotoAuthorizationRepository, - PushObserver: PushNotificationObserving ->: UIViewController { +public final class WeeklyCalendarViewController: UIViewController { // MARK: - Dependencies - private let viewModel: WeeklyCalendarViewModel + private let viewModel: WeeklyCalendarViewModel // MARK: - Flow @@ -61,7 +56,7 @@ public final class WeeklyCalendarViewController< // MARK: - Init public init( - viewModel: WeeklyCalendarViewModel + viewModel: WeeklyCalendarViewModel ) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -285,8 +280,7 @@ public final class WeeklyCalendarViewController< let date = viewModel.state.selectedDate flowSubject.send(.pushImagePicker( ImagePickerSceneInput(date: date) { [weak self] assets in - let typed = assets.compactMap { $0 as? AssetRepo.Asset } - self?.viewModel.input.send(.saveSelectedPhotos(typed)) + self?.viewModel.input.send(.saveSelectedPhotos(assets)) } )) } diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift index c47a536c..70b00e6c 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift @@ -31,7 +31,7 @@ public final class DetailCoordinator: Coordinator { // MARK: - Screen Routing private extension DetailCoordinator { - func flowBind(vc: DetailViewController>, PushNotificationObserver>) { + func flowBind(vc: DetailViewController) { vc.flowPublisher .receive(on: DispatchQueue.main) .sink { [weak self] event in diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift index 0ab4e9e9..b0296f50 100644 --- a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift @@ -8,17 +8,15 @@ import Domain import UIKit public final class DetailSceneFactory { - private typealias RecordRepo = FoodRecordRepositoryImpl> - - private let fetchRecordsUseCase: FetchFoodRecordsUseCase - private let saveFoodRecordUseCase: SaveFoodRecordUseCase - private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + private let fetchRecordsUseCase: FetchFoodRecordsUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase private let pushNotificationObserver: PushNotificationObserver public init( - fetchRecordsUseCase: FetchFoodRecordsUseCase>>, - saveFoodRecordUseCase: SaveFoodRecordUseCase>>, - deleteFoodRecordUseCase: DeleteFoodRecordUseCase>>, + fetchRecordsUseCase: FetchFoodRecordsUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase, pushNotificationObserver: PushNotificationObserver ) { self.fetchRecordsUseCase = fetchRecordsUseCase @@ -27,10 +25,7 @@ public final class DetailSceneFactory { self.pushNotificationObserver = pushNotificationObserver } - public func makeScene(input: DetailSceneInput) -> DetailViewController< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > { + public func makeScene(input: DetailSceneInput) -> DetailViewController { let viewModel = DetailViewModel( initialDate: input.date, initialRecords: input.records, diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index ebe18b9f..de1e5955 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -10,10 +10,7 @@ import Kingfisher import SnapKit import UIKit -public final class DetailViewController< - RecordRepo: FoodRecordRepository, - PushObserver: PushNotificationObserving ->: UIViewController { +public final class DetailViewController: UIViewController { // MARK: - Constants @@ -29,7 +26,7 @@ public final class DetailViewController< // MARK: - Dependencies - private let viewModel: DetailViewModel + private let viewModel: DetailViewModel private let onDismissWithDate: ((Date) -> Void)? // MARK: - Flow @@ -124,7 +121,7 @@ public final class DetailViewController< // MARK: - Init public init( - viewModel: DetailViewModel, + viewModel: DetailViewModel, initialScrollTarget: MealType? = nil, scrollToFirstRecord: Bool = true, shouldPopToRoot: Bool = false, @@ -573,7 +570,7 @@ public final class DetailViewController< } private func resolveScrollTarget( - _ state: DetailViewModel.State + _ state: DetailViewModel.State ) -> MealType? { if let mealType = pendingScrollTarget { pendingScrollTarget = nil @@ -585,7 +582,7 @@ public final class DetailViewController< return nil } - private func firstContentMealType(_ state: DetailViewModel.State) + private func firstContentMealType(_ state: DetailViewModel.State) -> MealType? { let orderedMealTypes: [MealType] = [.breakfast, .lunch, .dinner, .snack] diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift index e4792ed3..3b3b4ea9 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift @@ -7,10 +7,7 @@ import Combine import Domain import Foundation -public final class DetailViewModel< - RecordRepo: FoodRecordRepository, - PushObserver: PushNotificationObserving -> { +public final class DetailViewModel { // MARK: - Output @@ -41,20 +38,20 @@ public final class DetailViewModel< // MARK: - Dependencies - private let fetchRecordsUseCase: FetchFoodRecordsUseCase - private let saveFoodRecordUseCase: SaveFoodRecordUseCase - private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase - private let pushNotificationObserver: PushObserver + private let fetchRecordsUseCase: FetchFoodRecordsUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + private let pushNotificationObserver: any PushNotificationObserving // MARK: - Init public init( initialDate: Date, initialRecords: [FoodRecord], - fetchRecordsUseCase: FetchFoodRecordsUseCase, - saveFoodRecordUseCase: SaveFoodRecordUseCase, - deleteFoodRecordUseCase: DeleteFoodRecordUseCase, - pushNotificationObserver: PushObserver + fetchRecordsUseCase: FetchFoodRecordsUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase, + pushNotificationObserver: any PushNotificationObserving ) { self.fetchRecordsUseCase = fetchRecordsUseCase self.saveFoodRecordUseCase = saveFoodRecordUseCase diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift index 70270e17..88dab305 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift @@ -32,7 +32,7 @@ public final class EditCoordinator: Coordinator { // MARK: - Screen Routing private extension EditCoordinator { - func flowBind(vc: EditFoodRecordViewController>>) { + func flowBind(vc: EditFoodRecordViewController) { vc.flowPublisher .receive(on: DispatchQueue.main) .sink { [weak self] event in diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift index 954f63b6..283ebe67 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift @@ -10,22 +10,18 @@ import UIKit // MARK: - Factory public final class EditSceneFactory { - private typealias RecordRepo = FoodRecordRepositoryImpl> - - private let updateFoodRecordUseCase: UpdateFoodRecordUseCase - private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + private let updateFoodRecordUseCase: UpdateFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase public init( - updateFoodRecordUseCase: UpdateFoodRecordUseCase>>, - deleteFoodRecordUseCase: DeleteFoodRecordUseCase>> + updateFoodRecordUseCase: UpdateFoodRecordUseCase, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase ) { self.updateFoodRecordUseCase = updateFoodRecordUseCase self.deleteFoodRecordUseCase = deleteFoodRecordUseCase } - public func makeScene(input: EditSceneInput) -> EditFoodRecordViewController< - FoodRecordRepositoryImpl> - > { + public func makeScene(input: EditSceneInput) -> EditFoodRecordViewController { let viewModel = EditFoodRecordViewModel( record: input.record, updateFoodRecordUseCase: updateFoodRecordUseCase, diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift index ef1f134b..d24348ee 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift @@ -22,13 +22,11 @@ private enum EditFoodRecordConstants { static let buttonCornerRadius: CGFloat = 25 } -public final class EditFoodRecordViewController< - RecordRepo: FoodRecordRepository ->: UIViewController, UIGestureRecognizerDelegate { +public final class EditFoodRecordViewController: UIViewController, UIGestureRecognizerDelegate { // MARK: - Dependencies - private let viewModel: EditFoodRecordViewModel + private let viewModel: EditFoodRecordViewModel // MARK: - Flow @@ -116,7 +114,7 @@ public final class EditFoodRecordViewController< // MARK: - Init public init( - viewModel: EditFoodRecordViewModel + viewModel: EditFoodRecordViewModel ) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -395,7 +393,7 @@ public final class EditFoodRecordViewController< } } - private func handleEvent(_ event: EditFoodRecordViewModel.Event) { + private func handleEvent(_ event: EditFoodRecordViewModel.Event) { switch event { case .saveCompleted: ToastView.show(type: .infoUpdate) diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift index 8064910f..2e30b1d9 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift @@ -8,7 +8,7 @@ import Domain import Foundation import UIKit -public final class EditFoodRecordViewModel { +public final class EditFoodRecordViewModel { // MARK: - Output @@ -37,15 +37,15 @@ public final class EditFoodRecordViewModel { // MARK: - Dependencies - private let updateFoodRecordUseCase: UpdateFoodRecordUseCase - private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + private let updateFoodRecordUseCase: UpdateFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase // MARK: - Init public init( record: FoodRecord, - updateFoodRecordUseCase: UpdateFoodRecordUseCase, - deleteFoodRecordUseCase: DeleteFoodRecordUseCase + updateFoodRecordUseCase: UpdateFoodRecordUseCase, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase ) { self.updateFoodRecordUseCase = updateFoodRecordUseCase self.deleteFoodRecordUseCase = deleteFoodRecordUseCase diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift index 650d04f7..708c80da 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift @@ -34,7 +34,7 @@ public final class ImagePickerCoordinator: Coordinator { // MARK: - Screen Routing private extension ImagePickerCoordinator { - func flowBind(vc: ImagePickerViewController) { + func flowBind(vc: ImagePickerViewController) { vc.resultPublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift index 2e01465a..ad3fdde6 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift @@ -12,21 +12,21 @@ import UIKit public final class ImagePickerSceneFactory { private let imageProvider: UIImageLoader - private let fetchUseCase: FetchFoodImageAssetUseCase> + private let fetchUseCase: FetchFoodImageAssetUseCase public init( imageProvider: UIImageLoader, - fetchUseCase: FetchFoodImageAssetUseCase> + fetchUseCase: FetchFoodImageAssetUseCase ) { self.imageProvider = imageProvider self.fetchUseCase = fetchUseCase } - public func makeScene(input: ImagePickerSceneInput) -> ImagePickerViewController { + public func makeScene(input: ImagePickerSceneInput) -> ImagePickerViewController { let date = input.date let fetchUseCase = self.fetchUseCase - - let fetcher: () async throws -> (photos: [PHAsset], preselectedIds: Set) = { + + let fetcher: () async throws -> (photos: [any ImageAssetable], preselectedIds: Set) = { let calendar = Calendar.current let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! @@ -36,7 +36,7 @@ public final class ImagePickerSceneFactory { let preselectedIds = Set(assets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) return (photos, preselectedIds) } - + return ImagePickerViewController( imageProvider: imageProvider, configuration: .withMaxSelectionCount(10), diff --git a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerResult.swift b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerResult.swift index aaf446e8..cb7a5560 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerResult.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerResult.swift @@ -8,9 +8,9 @@ import Domain /// 이미지 피커 결과 -public enum ImagePickerResult { +public enum ImagePickerResult { /// 사진 선택 완료 - case selected([Asset]) + case selected([any ImageAssetable]) /// 취소 case cancelled } diff --git a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift index 81e040bc..b103f8f3 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift @@ -11,10 +11,7 @@ import UIKit // MARK: - ImagePickerViewController -public final class ImagePickerViewController< - Asset: ImageAssetable, - ImageProvider: RenderableImageRepository ->: +public final class ImagePickerViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { @@ -50,24 +47,24 @@ public final class ImagePickerViewController< // MARK: - Public Publisher /// 피커 결과 Publisher - public var resultPublisher: AnyPublisher, Never> { + public var resultPublisher: AnyPublisher { resultSubject.eraseToAnyPublisher() } // MARK: - Private Properties - private let resultSubject = PassthroughSubject, Never>() + private let resultSubject = PassthroughSubject() private var cancellables = Set() - private var photos: [Asset] = [] + private var photos: [any ImageAssetable] = [] private var preselectedFoodPhotoIds: Set = [] - private let imageProvider: ImageProvider + private let imageProvider: any RenderableImageRepository private let configuration: ImagePickerConfiguration - private var foodPhotos: [Asset] = [] + private var foodPhotos: [any ImageAssetable] = [] private var indexPathsByPhotoId: [String: [IndexPath]] = [:] - private let photosFetcher: () async throws -> (photos: [Asset], preselectedIds: Set) - private let onSelected: (([Asset]) -> Void)? + private let photosFetcher: () async throws -> (photos: [any ImageAssetable], preselectedIds: Set) + private let onSelected: (([any ImageAssetable]) -> Void)? // MARK: - State @@ -75,7 +72,7 @@ public final class ImagePickerViewController< // MARK: - Computed Properties - private func photosInSection(_ section: PhotoSection) -> [Asset] { + private func photosInSection(_ section: PhotoSection) -> [any ImageAssetable] { switch section { case .food: return foodPhotos case .all: return photos @@ -156,10 +153,10 @@ public final class ImagePickerViewController< // MARK: - Initialization public init( - imageProvider: ImageProvider, + imageProvider: any RenderableImageRepository, configuration: ImagePickerConfiguration = .default, - photosFetcher: @escaping () async throws -> (photos: [Asset], preselectedIds: Set), - onSelected: (([Asset]) -> Void)? = nil + photosFetcher: @escaping () async throws -> (photos: [any ImageAssetable], preselectedIds: Set), + onSelected: (([any ImageAssetable]) -> Void)? = nil ) { self.imageProvider = imageProvider self.configuration = configuration @@ -218,7 +215,7 @@ public final class ImagePickerViewController< } } - private func applyPhotos(_ photos: [Asset], preselectedIds: Set) { + private func applyPhotos(_ photos: [any ImageAssetable], preselectedIds: Set) { self.photos = photos self.preselectedFoodPhotoIds = preselectedIds let foodPhotos = photos.filter { preselectedIds.contains($0.id) } @@ -314,8 +311,8 @@ public final class ImagePickerViewController< } private static func makeIndexPathsByPhotoId( - allPhotos: [Asset], - foodPhotos: [Asset] + allPhotos: [any ImageAssetable], + foodPhotos: [any ImageAssetable] ) -> [String: [IndexPath]] { var indexPathsById: [String: [IndexPath]] = [:] @@ -451,7 +448,7 @@ public final class ImagePickerViewController< return header } - private func loadImage(for photo: Asset, cell: ImagePickerCell, at indexPath: IndexPath) { + private func loadImage(for photo: any ImageAssetable, cell: ImagePickerCell, at indexPath: IndexPath) { Task { do { let image = try await imageProvider.loadImage( diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift index f23d244d..5d6fc128 100644 --- a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift @@ -8,15 +8,13 @@ import Domain import UIKit public final class InsightSceneFactory { - public typealias UseCase = FetchInsightUseCase>> + private let useCase: FetchInsightUseCase - private let useCase: UseCase - - public init(useCase: UseCase) { + public init(useCase: FetchInsightUseCase) { self.useCase = useCase } - public func makeScene() -> InsightViewController>> { + public func makeScene() -> InsightViewController { InsightViewController(viewModel: InsightViewModel(fetchInsightUseCase: useCase)) } } diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift index e127df58..dd1cd6d3 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift @@ -16,7 +16,7 @@ private enum InsightConstants { static let contentInset: CGFloat = 20 } -public final class InsightViewController: UIViewController { +public final class InsightViewController: UIViewController { // MARK: - UI Components @@ -58,12 +58,12 @@ public final class InsightViewController: UIViewControl // MARK: - Properties - private let viewModel: InsightViewModel + private let viewModel: InsightViewModel private var cancellables = Set() // MARK: - Init - public init(viewModel: InsightViewModel) { + public init(viewModel: InsightViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift index 4d1caabb..f05e70c1 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift @@ -7,7 +7,7 @@ import Combine import Domain import Foundation -public final class InsightViewModel { +public final class InsightViewModel { // MARK: - Output @@ -36,11 +36,11 @@ public final class InsightViewModel { // MARK: - Dependencies - private let fetchInsightUseCase: FetchInsightUseCase + private let fetchInsightUseCase: FetchInsightUseCase // MARK: - Init - public init(fetchInsightUseCase: FetchInsightUseCase) { + public init(fetchInsightUseCase: FetchInsightUseCase) { self.fetchInsightUseCase = fetchInsightUseCase self.stateSubject = CurrentValueSubject(State()) setupBindings() From 487958ecedfb722f3169c1191b2e59cbc80dda44 Mon Sep 17 00:00:00 2001 From: enebin Date: Thu, 30 Apr 2026 22:19:15 +0900 Subject: [PATCH 91/97] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=81=84?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=20=ED=99=95=EC=9D=B8=20=EC=96=BC=EB=9F=BF?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알림이 켜진 상태에서 알림설정 행을 탭하면 AI 분석결과/기록 리마인드 알림을 받지 못하게 됨을 알리는 확인 얼럿을 띄운 뒤 설정 앱으로 이동한다. --- .../Sources/MyPage/MyPageViewController.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift b/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift index 94e8e58b..90df47dd 100644 --- a/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift +++ b/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift @@ -415,7 +415,11 @@ extension MyPageViewController: UITableViewDelegate { switch row { case .notificationSetting: - openAppSettings() + if viewModel.state.isNotificationEnabled { + showNotificationDisableConfirmAlert() + } else { + openAppSettings() + } case .privacy: openExternalLink("https://www.notion.so/enebin/311c690fca2b80bc8c89c5f7cc5cb35c?source=copy_link") case .customerInquiry: @@ -442,6 +446,19 @@ extension MyPageViewController: UITableViewDelegate { present(alert, animated: true) } + private func showNotificationDisableConfirmAlert() { + let alert = UIAlertController( + title: "알림을 끄시겠어요?", + message: "알림을 끄면 AI 분석결과와 기록 리마인드 알림을 받아 볼 수 없어요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "설정으로 이동", style: .default) { [weak self] _ in + self?.openAppSettings() + }) + present(alert, animated: true) + } + private func openAppSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) From 10bb24475f26fb0e2e6b0e1f9011320a15e4816c Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Thu, 30 Apr 2026 21:55:08 +0900 Subject: [PATCH 92/97] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9D=BC=20=EC=8B=9D?= =?UTF-8?q?=EB=8B=A8=20=EA=B8=B0=EB=A1=9D=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=8D=94=20=EB=A1=9C=EC=BB=AC=20=ED=91=B8=EC=8B=9C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppFlowController.swift | 17 +++++ FoodDiary/App/Sources/SceneDelegate.swift | 12 ++++ .../LocalNotificationScheduler.swift | 62 +++++++++++++++++++ .../LocalNotificationScheduling.swift | 11 ++++ .../ScheduleDailyReminderUseCase.swift | 27 ++++++++ 5 files changed, 129 insertions(+) create mode 100644 FoodDiary/Data/Sources/Core/Notification/LocalNotificationScheduler.swift create mode 100644 FoodDiary/Domain/Sources/Core/Notification/LocalNotificationScheduling.swift create mode 100644 FoodDiary/Domain/Sources/UseCase/ScheduleDailyReminderUseCase.swift diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index 3cdc4950..f0f24c65 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -51,6 +51,16 @@ final class AppFlowController: UIViewController, SceneTransitioning { setupAnalysisCompletionToast() setupDeepLinkHandling() setupLoginSessionObservation() + setupForegroundReminderScheduling() + } + + private func setupForegroundReminderScheduling() { + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.tryScheduleDailyReminder() + } + .store(in: &cancellables) } private func setupLoginSessionObservation() { @@ -210,6 +220,7 @@ extension AppFlowController { switch settings.authorizationStatus { case .authorized, .provisional, .ephemeral: UIApplication.shared.registerForRemoteNotifications() + tryScheduleDailyReminder() case .notDetermined: await requestSystemNotificationAuthorization() case .denied: @@ -228,11 +239,17 @@ extension AppFlowController { ) guard granted else { return } UIApplication.shared.registerForRemoteNotifications() + tryScheduleDailyReminder() } catch { print("알림 권한 요청 실패: \(error)") } } + fileprivate func tryScheduleDailyReminder() { + guard let useCase = try? container.resolve(ScheduleDailyReminderUseCase.self) else { return } + Task { await useCase.execute() } + } + fileprivate func validateToken() async -> Bool { guard let validateAccessTokenUseCase = try? container.resolve(ValidateAccessTokenUseCase.self) else { fatalError("ValidateAccessTokenUseCase Failed Resolve") diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 480837b6..0610826b 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -51,6 +51,10 @@ extension SceneDelegate { NotificationAuthorizationProvider() } + container.register(LocalNotificationScheduling.self) { _ in + LocalNotificationScheduler() + } + container.register(KeychainService.self) { _ in KeychainService() } @@ -356,6 +360,14 @@ extension SceneDelegate { osVersion: osVersion ) } + + container.register(ScheduleDailyReminderUseCase.self) { resolver in + guard let scheduler = resolver.resolve(LocalNotificationScheduling.self), + let provider = resolver.resolve(NotificationAuthorizationProviding.self) else { + fatalError("ScheduleDailyReminderUseCase dependencies not registered") + } + return ScheduleDailyReminderUseCase(scheduler: scheduler, authorizationProvider: provider) + } } fileprivate func registerPresentation() {} diff --git a/FoodDiary/Data/Sources/Core/Notification/LocalNotificationScheduler.swift b/FoodDiary/Data/Sources/Core/Notification/LocalNotificationScheduler.swift new file mode 100644 index 00000000..e7cea349 --- /dev/null +++ b/FoodDiary/Data/Sources/Core/Notification/LocalNotificationScheduler.swift @@ -0,0 +1,62 @@ +// +// LocalNotificationScheduler.swift +// Data +// + +import UserNotifications +import Domain + +/// 테스트 가능성을 위한 내부 추상화. 실제 구현은 UNUserNotificationCenter +protocol UserNotificationCentering: Sendable { + func add(_ request: UNNotificationRequest) async throws + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) +} + +extension UNUserNotificationCenter: UserNotificationCentering {} + +public final class LocalNotificationScheduler: LocalNotificationScheduling { + static let dailyReminderIdentifier = "daily_reminder" + + private let notificationCenter: any UserNotificationCentering + + public convenience init() { + self.init(notificationCenter: UNUserNotificationCenter.current()) + } + + init(notificationCenter: any UserNotificationCentering) { + self.notificationCenter = notificationCenter + } + + public func scheduleDailyReminder(hour: Int, minute: Int) async { + let content = UNMutableNotificationContent() + content.title = "오늘 먹은 음식, 기록해볼까요?" + content.body = "하루가 끝나기 전에 오늘의 식사를 남겨보세요." + content.sound = .default + content.userInfo = ["notification_type": "daily_reminder"] + + var dateComponents = DateComponents() + dateComponents.hour = hour + dateComponents.minute = minute + + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + + notificationCenter.removePendingNotificationRequests( + withIdentifiers: [Self.dailyReminderIdentifier] + ) + + let request = UNNotificationRequest( + identifier: Self.dailyReminderIdentifier, + content: content, + trigger: trigger + ) + try? await notificationCenter.add(request) + print("[LocalNotification] 매일 \(hour):\(String(format: "%02d", minute)) 리마인더 등록 완료") + } + + public func cancelDailyReminder() async { + notificationCenter.removePendingNotificationRequests( + withIdentifiers: [Self.dailyReminderIdentifier] + ) + print("[LocalNotification] 매일 리마인더 취소 완료") + } +} diff --git a/FoodDiary/Domain/Sources/Core/Notification/LocalNotificationScheduling.swift b/FoodDiary/Domain/Sources/Core/Notification/LocalNotificationScheduling.swift new file mode 100644 index 00000000..3dd41b1a --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Notification/LocalNotificationScheduling.swift @@ -0,0 +1,11 @@ +// +// LocalNotificationScheduling.swift +// Domain +// + +import Foundation + +public protocol LocalNotificationScheduling: Sendable { + func scheduleDailyReminder(hour: Int, minute: Int) async + func cancelDailyReminder() async +} diff --git a/FoodDiary/Domain/Sources/UseCase/ScheduleDailyReminderUseCase.swift b/FoodDiary/Domain/Sources/UseCase/ScheduleDailyReminderUseCase.swift new file mode 100644 index 00000000..c8731e95 --- /dev/null +++ b/FoodDiary/Domain/Sources/UseCase/ScheduleDailyReminderUseCase.swift @@ -0,0 +1,27 @@ +// +// ScheduleDailyReminderUseCase.swift +// Domain +// + +import Foundation + +public struct ScheduleDailyReminderUseCase: Sendable { + private let scheduler: any LocalNotificationScheduling + private let authorizationProvider: any NotificationAuthorizationProviding + + public init( + scheduler: any LocalNotificationScheduling, + authorizationProvider: any NotificationAuthorizationProviding + ) { + self.scheduler = scheduler + self.authorizationProvider = authorizationProvider + } + + public func execute() async { + if await authorizationProvider.isNotificationEnabled() { + await scheduler.scheduleDailyReminder(hour: 21, minute: 0) + } else { + await scheduler.cancelDailyReminder() + } + } +} From 29016779d36db70be1a062520935430ace1bc576 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Thu, 30 Apr 2026 21:55:11 +0900 Subject: [PATCH 93/97] =?UTF-8?q?refactor:=20AppDelegate=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B6=84=EA=B8=B0=EC=97=90=20daily=5Freminder=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FoodDiary/App/Sources/AppDelegate.swift | 13 +++++++++---- .../InfoPlist+Templates.swift | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/FoodDiary/App/Sources/AppDelegate.swift b/FoodDiary/App/Sources/AppDelegate.swift index ee9be486..79a9e7f4 100644 --- a/FoodDiary/App/Sources/AppDelegate.swift +++ b/FoodDiary/App/Sources/AppDelegate.swift @@ -141,8 +141,10 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { let userInfo = response.notification.request.content.userInfo - if userInfo["is_local_notification"] as? Bool == true { - // 로컬 알림 탭 → 딥링크로 상세 화면 이동 + if userInfo["notification_type"] as? String == "daily_reminder" { + // 매일 리마인더 탭 → 앱이 열리는 기본 동작만 수행 + } else if userInfo["is_local_notification"] as? Bool == true { + // 분석 완료 로컬 알림 탭 → 딥링크로 상세 화면 이동 if let diaryDate = userInfo["diary_date"] as? String { NotificationCenter.default.post( name: AppNotification.Push.deepLinkToDetail, @@ -164,8 +166,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { let userInfo = notification.request.content.userInfo - if userInfo["is_local_notification"] as? Bool == true { - // 로컬 알림이 포그라운드에서 도착 → 배너 표시 안 함 (토스트로 이미 처리됨) + if userInfo["notification_type"] as? String == "daily_reminder" { + // 매일 리마인더가 포그라운드에 도착 → 배너 표시 안 함 (이미 앱 사용 중) + completionHandler([]) + } else if userInfo["is_local_notification"] as? Bool == true { + // 분석 완료 로컬 알림이 포그라운드에서 도착 → 배너 표시 안 함 (토스트로 이미 처리됨) completionHandler([]) } else { handlePushNotification(userInfo) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift index 5bfee72f..57ce95d2 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift @@ -30,7 +30,7 @@ extension InfoPlist { "UIImageName": "", ], "NSPhotoLibraryUsageDescription": "음식 사진을 분류하기 위해 사진 라이브러리 접근 권한이 필요합니다.", - "NSUserNotificationsUsageDescription": "AI 분석 완료 알림을 받기 위해선 알림 권한이 필요합니다.", + "NSUserNotificationsUsageDescription": "AI 분석 완료 및 매일 식단 기록 리마인더 알림을 받기 위해선 알림 권한이 필요합니다.", "BASE_URL": "$(BASE_URL)", "SENTRY_DSN": "$(SENTRY_DSN)", "NSAppTransportSecurity": [ From 85cac77dfa0be700cf2fd3bed9bc81e7ca579880 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Thu, 30 Apr 2026 21:55:16 +0900 Subject: [PATCH 94/97] =?UTF-8?q?test:=20=EB=A7=A4=EC=9D=BC=20=EB=A6=AC?= =?UTF-8?q?=EB=A7=88=EC=9D=B8=EB=8D=94=20=EA=B4=80=EB=A0=A8=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LocalNotificationSchedulerTests.swift | 71 +++++++++++++++++++ .../Mock/MockUserNotificationCenter.swift | 24 +++++++ .../Mock/MockLocalNotificationScheduler.swift | 24 +++++++ ...ockNotificationAuthorizationProvider.swift | 15 ++++ .../ScheduleDailyReminderUseCaseTests.swift | 50 +++++++++++++ 5 files changed, 184 insertions(+) create mode 100644 FoodDiary/Data/Tests/LocalNotificationSchedulerTests.swift create mode 100644 FoodDiary/Data/Tests/Mock/MockUserNotificationCenter.swift create mode 100644 FoodDiary/Domain/Tests/Mock/MockLocalNotificationScheduler.swift create mode 100644 FoodDiary/Domain/Tests/Mock/MockNotificationAuthorizationProvider.swift create mode 100644 FoodDiary/Domain/Tests/ScheduleDailyReminderUseCaseTests.swift diff --git a/FoodDiary/Data/Tests/LocalNotificationSchedulerTests.swift b/FoodDiary/Data/Tests/LocalNotificationSchedulerTests.swift new file mode 100644 index 00000000..15474f26 --- /dev/null +++ b/FoodDiary/Data/Tests/LocalNotificationSchedulerTests.swift @@ -0,0 +1,71 @@ +// +// LocalNotificationSchedulerTests.swift +// Data +// + +@testable import Data +import Foundation +import Testing +import UserNotifications + +@Suite("LocalNotificationScheduler Tests") +struct LocalNotificationSchedulerTests { + + @Test("scheduleDailyReminder는 21:00 매일 반복되는 캘린더 트리거를 등록한다") + func scheduleDailyReminder_addsCalendarTriggerAt21WithRepeats() async throws { + let center = MockUserNotificationCenter() + let sut = LocalNotificationScheduler(notificationCenter: center) + + await sut.scheduleDailyReminder(hour: 21, minute: 0) + + // 동일 identifier에 대해 기존 pending 제거 후 새로 add 되었는지 + #expect(center.removedIdentifiers.count == 1) + #expect(center.removedIdentifiers.first == [LocalNotificationScheduler.dailyReminderIdentifier]) + + #expect(center.addedRequests.count == 1) + let request = try #require(center.addedRequests.first) + + #expect(request.identifier == LocalNotificationScheduler.dailyReminderIdentifier) + + // 매일 반복되는 캘린더 트리거인지 + let trigger = try #require(request.trigger as? UNCalendarNotificationTrigger) + #expect(trigger.repeats == true) + #expect(trigger.dateComponents.hour == 21) + #expect(trigger.dateComponents.minute == 0) + + // 컨텐츠 검증 — 알림이 실제로 표시될 때 사용자가 보는 부분 + #expect(request.content.title == "오늘 먹은 음식, 기록해볼까요?") + #expect(request.content.body == "하루가 끝나기 전에 오늘의 식사를 남겨보세요.") + #expect(request.content.sound == .default) + #expect(request.content.userInfo["notification_type"] as? String == "daily_reminder") + // 분석 결과 푸시 분기와의 충돌 방지: is_local_notification 키는 들어가면 안 됨 + #expect(request.content.userInfo["is_local_notification"] == nil) + } + + @Test("cancelDailyReminder는 등록된 리마인더를 제거하고 새로 추가하지 않는다") + func cancelDailyReminder_removesPendingRequestsWithoutAdding() async { + let center = MockUserNotificationCenter() + let sut = LocalNotificationScheduler(notificationCenter: center) + + await sut.cancelDailyReminder() + + #expect(center.removedIdentifiers.count == 1) + #expect(center.removedIdentifiers.first == [LocalNotificationScheduler.dailyReminderIdentifier]) + #expect(center.addedRequests.isEmpty) + } + + @Test("scheduleDailyReminder를 두 번 호출해도 동일 identifier로 덮어쓰기된다 (idempotent)") + func scheduleDailyReminder_calledTwice_isIdempotent() async { + let center = MockUserNotificationCenter() + let sut = LocalNotificationScheduler(notificationCenter: center) + + await sut.scheduleDailyReminder(hour: 21, minute: 0) + await sut.scheduleDailyReminder(hour: 21, minute: 0) + + #expect(center.removedIdentifiers.count == 2) + #expect(center.addedRequests.count == 2) + #expect(center.addedRequests.allSatisfy { + $0.identifier == LocalNotificationScheduler.dailyReminderIdentifier + }) + } +} diff --git a/FoodDiary/Data/Tests/Mock/MockUserNotificationCenter.swift b/FoodDiary/Data/Tests/Mock/MockUserNotificationCenter.swift new file mode 100644 index 00000000..41e5ddd7 --- /dev/null +++ b/FoodDiary/Data/Tests/Mock/MockUserNotificationCenter.swift @@ -0,0 +1,24 @@ +// +// MockUserNotificationCenter.swift +// Data +// + +@testable import Data +import UserNotifications + +final class MockUserNotificationCenter: UserNotificationCentering, @unchecked Sendable { + private(set) var addedRequests: [UNNotificationRequest] = [] + private(set) var removedIdentifiers: [[String]] = [] + var addError: Error? + + func add(_ request: UNNotificationRequest) async throws { + if let addError { + throw addError + } + addedRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + removedIdentifiers.append(identifiers) + } +} diff --git a/FoodDiary/Domain/Tests/Mock/MockLocalNotificationScheduler.swift b/FoodDiary/Domain/Tests/Mock/MockLocalNotificationScheduler.swift new file mode 100644 index 00000000..52904b66 --- /dev/null +++ b/FoodDiary/Domain/Tests/Mock/MockLocalNotificationScheduler.swift @@ -0,0 +1,24 @@ +// +// MockLocalNotificationScheduler.swift +// Domain +// + +@testable import Domain +import Foundation + +final class MockLocalNotificationScheduler: LocalNotificationScheduling, @unchecked Sendable { + private(set) var scheduleCallCount = 0 + private(set) var cancelCallCount = 0 + private(set) var lastScheduledHour: Int? + private(set) var lastScheduledMinute: Int? + + func scheduleDailyReminder(hour: Int, minute: Int) async { + scheduleCallCount += 1 + lastScheduledHour = hour + lastScheduledMinute = minute + } + + func cancelDailyReminder() async { + cancelCallCount += 1 + } +} diff --git a/FoodDiary/Domain/Tests/Mock/MockNotificationAuthorizationProvider.swift b/FoodDiary/Domain/Tests/Mock/MockNotificationAuthorizationProvider.swift new file mode 100644 index 00000000..801a786a --- /dev/null +++ b/FoodDiary/Domain/Tests/Mock/MockNotificationAuthorizationProvider.swift @@ -0,0 +1,15 @@ +// +// MockNotificationAuthorizationProvider.swift +// Domain +// + +@testable import Domain +import Foundation + +final class MockNotificationAuthorizationProvider: NotificationAuthorizationProviding, @unchecked Sendable { + var isEnabledToReturn: Bool = true + + func isNotificationEnabled() async -> Bool { + isEnabledToReturn + } +} diff --git a/FoodDiary/Domain/Tests/ScheduleDailyReminderUseCaseTests.swift b/FoodDiary/Domain/Tests/ScheduleDailyReminderUseCaseTests.swift new file mode 100644 index 00000000..ddc9823c --- /dev/null +++ b/FoodDiary/Domain/Tests/ScheduleDailyReminderUseCaseTests.swift @@ -0,0 +1,50 @@ +// +// ScheduleDailyReminderUseCaseTests.swift +// Domain +// + +@testable import Domain +import Foundation +import Testing + +@Suite("ScheduleDailyReminderUseCase Tests") +struct ScheduleDailyReminderUseCaseTests { + + @Test("알림 권한이 켜져 있으면 매일 21:00 리마인더가 등록된다") + func whenAuthorizationEnabled_schedulesDailyReminderAt21() async { + let scheduler = MockLocalNotificationScheduler() + let authProvider = MockNotificationAuthorizationProvider() + authProvider.isEnabledToReturn = true + + let sut = ScheduleDailyReminderUseCase( + scheduler: scheduler, + authorizationProvider: authProvider + ) + + await sut.execute() + + #expect(scheduler.scheduleCallCount == 1) + #expect(scheduler.cancelCallCount == 0) + #expect(scheduler.lastScheduledHour == 21) + #expect(scheduler.lastScheduledMinute == 0) + } + + @Test("알림 권한이 꺼져 있으면 리마인더가 등록되지 않고 취소된다") + func whenAuthorizationDisabled_cancelsReminderInsteadOfScheduling() async { + let scheduler = MockLocalNotificationScheduler() + let authProvider = MockNotificationAuthorizationProvider() + authProvider.isEnabledToReturn = false + + let sut = ScheduleDailyReminderUseCase( + scheduler: scheduler, + authorizationProvider: authProvider + ) + + await sut.execute() + + #expect(scheduler.scheduleCallCount == 0) + #expect(scheduler.cancelCallCount == 1) + #expect(scheduler.lastScheduledHour == nil) + #expect(scheduler.lastScheduledMinute == nil) + } +} From 74b29b6b6cc6a305f457664929ea4b5672cba69f Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Thu, 11 Jun 2026 13:09:35 +0900 Subject: [PATCH 95/97] =?UTF-8?q?feat:=20=EA=B3=B5=EC=9C=A0=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=ED=95=98=EB=8B=A8?= =?UTF-8?q?=EC=97=90=20=EB=AD=90=EB=A8=B9=EC=97=88=EC=A7=80=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Sources/Detail/DetailViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index de1e5955..aac598ff 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -478,7 +478,7 @@ public final class DetailViewController: UIViewController { } private func formatRecordForShare(_ name: String, _ url: String) -> String { - return "\(name) 맛을 기억하시나요?\n\n\(url)" + return "\(name) 맛을 기억하시나요?\n뭐먹었지에서 확인해보세요.\n\(url)\n\n 나도 시작하기\nhttps://mumuk.ai.kr/" } private func presentShareSheet(items: [Any]) { From 8f8d35484a1afb5b8e84e7e5a7f9dea304e55574 Mon Sep 17 00:00:00 2001 From: enebin Date: Thu, 11 Jun 2026 23:43:09 +0900 Subject: [PATCH 96/97] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=EC=97=90=20=EC=98=A4=EB=8A=98=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/WeeklyCalendarHeaderView.swift | 37 +++++++++++++++++++ .../ViewModel/WeeklyCalendarViewModel.swift | 8 ++++ .../WeeklyCalendarViewController.swift | 15 ++++++++ 3 files changed, 60 insertions(+) diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift index 049afd1f..ed512f84 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift @@ -17,6 +17,9 @@ final class WeeklyCalendarHeaderView: UIView { static let headerHeight: CGFloat = 44 static let navigationSpacing: CGFloat = 8 static let buttonSize: CGFloat = 44 + static let todayButtonSpacing: CGFloat = 8 + static let todayButtonCornerRadius: CGFloat = 12 + static let todayButtonInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) } // MARK: - Publishers @@ -29,8 +32,13 @@ final class WeeklyCalendarHeaderView: UIView { nextTapSubject.eraseToAnyPublisher() } + var todayTapPublisher: AnyPublisher { + todayTapSubject.eraseToAnyPublisher() + } + private let previousTapSubject = PassthroughSubject() private let nextTapSubject = PassthroughSubject() + private let todayTapSubject = PassthroughSubject() // MARK: - UI Components @@ -41,6 +49,20 @@ final class WeeklyCalendarHeaderView: UIView { return label }() + private let todayButton: UIButton = { + let button = UIButton() + button.setAttributedTitle( + Typography.p12.styled("오늘", color: DesignSystemAsset.primary.color), + for: .normal + ) + button.contentEdgeInsets = Constants.todayButtonInsets + button.layer.cornerRadius = Constants.todayButtonCornerRadius + button.layer.borderWidth = 1 + button.layer.borderColor = DesignSystemAsset.primary.color.cgColor + button.isHidden = true + return button + }() + private let previousButton: UIButton = { let button = UIButton() button.setImage(DesignSystemAsset.iconNext.image.withHorizontallyFlippedOrientation(), for: .normal) @@ -80,6 +102,7 @@ final class WeeklyCalendarHeaderView: UIView { private func setupUI() { addSubview(monthLabel) + addSubview(todayButton) addSubview(navigationStack) navigationStack.addArrangedSubview(previousButton) navigationStack.addArrangedSubview(nextButton) @@ -95,6 +118,11 @@ final class WeeklyCalendarHeaderView: UIView { $0.centerY.equalToSuperview() } + todayButton.snp.makeConstraints { + $0.leading.equalTo(monthLabel.snp.trailing).offset(Constants.todayButtonSpacing) + $0.centerY.equalToSuperview() + } + navigationStack.snp.makeConstraints { $0.trailing.equalToSuperview() $0.centerY.equalToSuperview() @@ -112,6 +140,7 @@ final class WeeklyCalendarHeaderView: UIView { private func setupActions() { previousButton.addTarget(self, action: #selector(previousTapped), for: .touchUpInside) nextButton.addTarget(self, action: #selector(nextTapped), for: .touchUpInside) + todayButton.addTarget(self, action: #selector(todayTapped), for: .touchUpInside) } // MARK: - Public Methods @@ -125,6 +154,10 @@ final class WeeklyCalendarHeaderView: UIView { nextButton.alpha = isEnabled ? 1.0 : 0.3 } + func setTodayButtonHidden(_ isHidden: Bool) { + todayButton.isHidden = isHidden + } + // MARK: - Actions @objc private func previousTapped() { @@ -134,4 +167,8 @@ final class WeeklyCalendarHeaderView: UIView { @objc private func nextTapped() { nextTapSubject.send() } + + @objc private func todayTapped() { + todayTapSubject.send() + } } diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift index 6fc1e49b..ca15e09f 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift @@ -147,6 +147,13 @@ public final class WeeklyCalendarViewModel { await loadWeekData(for: currentWeekBaseDate) await updateDateContent(for: state.selectedDate) + case .goToToday: + let today = calendar.startOfDay(for: Date()) + currentWeekBaseDate = today + state.selectedDate = today + await loadWeekData(for: currentWeekBaseDate) + await updateDateContent(for: today) + case .selectDate(let date): if !calendar.isDate(state.selectedDate, inSameDayAs: date) { state.selectedDate = date @@ -331,6 +338,7 @@ extension WeeklyCalendarViewModel { case requestPhotoAuthorization case goToPreviousWeek case goToNextWeek + case goToToday case selectDate(Date) case saveSelectedPhotos([any ImageAssetable]) case handlePushNotification(AnalysisResultNotification) diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index 878baf2f..8da63cf8 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -129,6 +129,12 @@ public final class WeeklyCalendarViewController: UIViewController { } .store(in: &cancellables) + headerView.todayTapPublisher + .sink { [weak self] in + self?.viewModel.input.send(.goToToday) + } + .store(in: &cancellables) + weekGridView.dateTapPublisher .sink { [weak self] date in self?.viewModel.input.send(.selectDate(date)) @@ -169,6 +175,15 @@ public final class WeeklyCalendarViewController: UIViewController { } .store(in: &cancellables) + viewModel.statePublisher + .map(\.selectedDate) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] date in + self?.headerView.setTodayButtonHidden(Calendar.current.isDateInToday(date)) + } + .store(in: &cancellables) + viewModel.statePublisher .map(\.canGoToNextWeek) .removeDuplicates() From a61e52f8ce5b42a6da7d76b698c328441dc32ce2 Mon Sep 17 00:00:00 2001 From: enebin Date: Fri, 12 Jun 2026 00:17:30 +0900 Subject: [PATCH 97/97] =?UTF-8?q?chore:=20MARKETING=5FVERSION=201.1.1?= =?UTF-8?q?=EB=A1=9C=20=EB=B2=94=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App Store Connect에 이미 1.1.0이 등록되어 train이 닫혀 있어 TestFlight 업로드가 거부됨. 이전 승인 버전보다 높은 1.1.1로 올림. Co-Authored-By: Claude Opus 4.8 (1M context) --- FoodDiary/App/Project.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FoodDiary/App/Project.swift b/FoodDiary/App/Project.swift index 8a9d2c1b..1ebcd16e 100644 --- a/FoodDiary/App/Project.swift +++ b/FoodDiary/App/Project.swift @@ -55,7 +55,7 @@ let project = Project( ], settings: .settings( base: [ - "MARKETING_VERSION": "1.0.0", + "MARKETING_VERSION": "1.1.1", "BASE_URL": "$(BASE_URL)", "SENTRY_DSN": "$(SENTRY_DSN)", "TARGETED_DEVICE_FAMILY": "1"