diff --git a/.gitignore b/.gitignore index 3d0546db..3fc88411 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ buildServer.json .claude/ .codex/ .mcp.json +PR.md diff --git a/FoodDiary/App/Project.swift b/FoodDiary/App/Project.swift index 8a9d2c1b..4423953e 100644 --- a/FoodDiary/App/Project.swift +++ b/FoodDiary/App/Project.swift @@ -55,7 +55,8 @@ let project = Project( ], settings: .settings( base: [ - "MARKETING_VERSION": "1.0.0", + "MARKETING_VERSION": "1.1.0", + "CURRENT_PROJECT_VERSION": "26050725", "BASE_URL": "$(BASE_URL)", "SENTRY_DSN": "$(SENTRY_DSN)", "TARGETED_DEVICE_FAMILY": "1" diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index 0610826b..c8593655 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -259,18 +259,6 @@ extension SceneDelegate { return FetchFoodRecordsUseCase(repository: repository) } - 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( - calendar: .current, - recordRepository: recordRepo, - fetchFoodImageAssetUseCase: fetchAssetUseCase - ) - } - container.register(UpdateFoodRecordUseCase.self) { resolver in guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") @@ -418,7 +406,7 @@ extension SceneDelegate { // Calendar guard let requestPhotoAuthUseCase = try? container.resolve(RequestPhotoAuthorizationUseCase.self), - let loadWeeklyUseCase = try? container.resolve(LoadWeeklyRecordUseCase.self), + let fetchImageAssetUseCase = try? container.resolve(FetchFoodImageAssetUseCase.self), let coachmarkStorage = try? container.resolve(CoachmarkStoring.self), let checkAppReviewUseCase = try? container.resolve(CheckAppReviewEligibilityUseCase.self), let fetchMonthlyUseCase = try? container.resolve(FetchMonthlyCalendarDaysUseCase.self) @@ -434,7 +422,7 @@ extension SceneDelegate { login: LoginSceneFactory(useCase: finalizeUseCase), calendar: CalendarSceneFactory( requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, - loadWeeklyCalendarDataUseCase: loadWeeklyUseCase, + fetchFoodImageAssetUseCase: fetchImageAssetUseCase, saveFoodRecordUseCase: saveFoodRecordUseCase, pushNotificationObserver: pushNotificationObserver, getNicknameUseCase: getNicknameUseCase, diff --git a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift index 1946d260..57b5ab96 100644 --- a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift @@ -128,11 +128,12 @@ public struct FoodRecordRepositoryImpl: FoodRecordRepository { result[date] = entry.value.photos.compactMap { URL(string: $0.url) } } - let current = await photoURLCache.get(for: dateRange) - if current != fresh { - await photoURLCache.set(fresh, for: dateRange) - continuation.yield(fresh) - } + // 이 호출의 응답은 항상 그대로 방출한다. + // 전역 캐시와 비교해서 동일하면 건너뛰는 방식은, 동시에 같은 기간을 요청한 + // 다른 호출(예: 콜드 스타트 시 loadInitialData/refreshCurrentMonth 경쟁)이 + // 먼저 캐시를 채워버리는 경우 이 호출의 결과가 통째로 유실되는 문제가 있었다. + await photoURLCache.set(fresh, for: dateRange) + continuation.yield(fresh) continuation.finish() } catch { diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/calendarTileBackground.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/calendarTileBackground.colorset/Contents.json new file mode 100644 index 00000000..6056dafb --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/calendarTileBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF9", + "green" : "0xF6", + "red" : "0xF3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailBadgeBackground.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailBadgeBackground.colorset/Contents.json new file mode 100644 index 00000000..29fb2da3 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailBadgeBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.700", + "blue" : "0x18", + "green" : "0x1E", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailBodyText.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailBodyText.colorset/Contents.json new file mode 100644 index 00000000..0d5c53b1 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailBodyText.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x3C", + "green" : "0x3C", + "red" : "0x3C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailCardBackground.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailCardBackground.colorset/Contents.json new file mode 100644 index 00000000..6056dafb --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailCardBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF9", + "green" : "0xF6", + "red" : "0xF3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailMutedText.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailMutedText.colorset/Contents.json new file mode 100644 index 00000000..e8140c0a --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailMutedText.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x80", + "green" : "0x80", + "red" : "0x80" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailPrimaryText.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailPrimaryText.colorset/Contents.json new file mode 100644 index 00000000..2f8b75a1 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailPrimaryText.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1E", + "green" : "0x1E", + "red" : "0x1E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailSectionText.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailSectionText.colorset/Contents.json new file mode 100644 index 00000000..44d234cd --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailSectionText.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2C", + "green" : "0x2C", + "red" : "0x2C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailStroke.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailStroke.colorset/Contents.json new file mode 100644 index 00000000..e7d0cebf --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/detailStroke.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE0", + "green" : "0xE0", + "red" : "0xE0" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/Contents.json new file mode 100644 index 00000000..17d64698 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pic.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pic 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pic 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic 1.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic 1.svg new file mode 100644 index 00000000..436ba867 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic 1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic 2.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic 2.svg new file mode 100644 index 00000000..436ba867 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic 2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic.svg new file mode 100644 index 00000000..436ba867 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/calendar-empty-meal.imageset/pic.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly 1.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly 1.png index 736f3ad0..2dce84ca 100644 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly 1.png and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly 1.png differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly 2.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly 2.png index 495eedd8..3ee6f863 100644 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly 2.png and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly 2.png differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly.png index c82de4f0..c6d0f7e3 100644 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly.png and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-monthly.imageset/icon-monthly.png differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Contents.json index dc38f479..1e69ac7c 100644 --- a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Contents.json +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "mini-logo.png", + "filename" : "Group 2147239449.svg", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "mini-logo@2x.png", + "filename" : "Group 2147239450.svg", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "mini-logo@3x.png", + "filename" : "Group 2147239451.svg", "idiom" : "universal", "scale" : "3x" } diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239449.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239449.svg new file mode 100644 index 00000000..93eb99c9 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239449.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239450.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239450.svg new file mode 100644 index 00000000..93eb99c9 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239450.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239451.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239451.svg new file mode 100644 index 00000000..93eb99c9 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/Group 2147239451.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo.png deleted file mode 100644 index 9d3d1634..00000000 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo.png and /dev/null differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo@2x.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo@2x.png deleted file mode 100644 index 63c57f3d..00000000 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo@2x.png and /dev/null differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo@3x.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo@3x.png deleted file mode 100644 index 5f02b0cf..00000000 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-nav-logo.imageset/mini-logo@3x.png and /dev/null differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/Contents.json new file mode 100644 index 00000000..43c5bf3c --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon-18.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon-18 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon-18 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18 1.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18 1.svg new file mode 100644 index 00000000..cc772e8d --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18 1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18 2.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18 2.svg new file mode 100644 index 00000000..cc772e8d --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18 2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18.svg new file mode 100644 index 00000000..cc772e8d --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-share.imageset/icon-18.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/Contents.json index 8cb55c11..43c5bf3c 100644 --- a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/Contents.json +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "icon_copy.png", + "filename" : "icon-18.svg", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "icon_copy@2x.png", + "filename" : "icon-18 1.svg", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "icon_copy@3x.png", + "filename" : "icon-18 2.svg", "idiom" : "universal", "scale" : "3x" } diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18 1.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18 1.svg new file mode 100644 index 00000000..c86cc9fd --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18 1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18 2.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18 2.svg new file mode 100644 index 00000000..c86cc9fd --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18 2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18.svg b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18.svg new file mode 100644 index 00000000..c86cc9fd --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon-18.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy.png deleted file mode 100644 index 62439889..00000000 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy.png and /dev/null differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy@2x.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy@2x.png deleted file mode 100644 index c89d88c8..00000000 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy@2x.png and /dev/null differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy@3x.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy@3x.png deleted file mode 100644 index 39653d9f..00000000 Binary files a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon_copy.imageset/icon_copy@3x.png and /dev/null differ diff --git a/FoodDiary/Domain/Sources/Entity/MealType.swift b/FoodDiary/Domain/Sources/Entity/MealType.swift index 3752f988..66bb569c 100644 --- a/FoodDiary/Domain/Sources/Entity/MealType.swift +++ b/FoodDiary/Domain/Sources/Entity/MealType.swift @@ -20,8 +20,8 @@ public enum MealType: String, Sendable, CaseIterable, Equatable, Codable { public static func classify(from hour: Int) -> MealType { switch hour { case 5..<11: return .breakfast - case 11..<15: return .lunch - case 15..<21: return .dinner + case 10..<18: return .lunch + case 17..<23: return .dinner default: return .snack } } @@ -37,4 +37,4 @@ public enum MealType: String, Sendable, CaseIterable, Equatable, Codable { } } } - \ No newline at end of file + diff --git a/FoodDiary/Domain/Sources/Entity/WeeklyCalendarDay.swift b/FoodDiary/Domain/Sources/Entity/WeeklyCalendarDay.swift deleted file mode 100644 index 72f6f3d6..00000000 --- a/FoodDiary/Domain/Sources/Entity/WeeklyCalendarDay.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// WeeklyCalendarDay.swift -// Domain -// - -import Foundation - -/// 주간 캘린더의 하루 데이터 -public struct WeeklyCalendarDay: Sendable, Equatable { - public let date: Date - public let dayOfWeek: String - public let dayNumber: String - public let isToday: Bool - public let isFuture: Bool - public var records: [FoodRecord] - - public init( - date: Date, - dayOfWeek: String, - dayNumber: String, - isToday: Bool, - isFuture: Bool, - records: [FoodRecord] - ) { - self.date = date - self.dayOfWeek = dayOfWeek - self.dayNumber = dayNumber - self.isToday = isToday - self.isFuture = isFuture - self.records = records - } -} - -// MARK: - Array Convenience - -public extension Array where Element == WeeklyCalendarDay { - func records(for date: Date, calendar: Calendar = .current) -> [FoodRecord] { - first { calendar.isDate($0.date, inSameDayAs: date) }?.records ?? [] - } -} diff --git a/FoodDiary/Domain/Sources/Extension/Calendar+MonthlyCalendar.swift b/FoodDiary/Domain/Sources/Extension/Calendar+MonthlyCalendar.swift index 4bbce3d2..18fc0362 100644 --- a/FoodDiary/Domain/Sources/Extension/Calendar+MonthlyCalendar.swift +++ b/FoodDiary/Domain/Sources/Extension/Calendar+MonthlyCalendar.swift @@ -9,7 +9,7 @@ public extension Calendar { /// 월간 캘린더 표시 기간 계산 (이전/다음 달 포함) func monthlyCalendarPeriod(for date: Date) -> DateInterval { var calendar = self - calendar.firstWeekday = 2 // 월요일 시작 + calendar.firstWeekday = 1 calendar.timeZone = TimeZone(identifier: "Asia/Seoul") ?? TimeZone.current guard let monthInterval = calendar.dateInterval(of: .month, for: date), @@ -35,12 +35,7 @@ public extension Calendar { ).day ?? 0 let remainder = daysFromStartToMonthEnd % 7 - var trailingCount = remainder > 0 ? 7 - remainder : 0 - - // 28일(4주)인 경우 다음 주 7일 추가 - if daysFromStartToMonthEnd == 28 { - trailingCount += 7 - } + let trailingCount = remainder > 0 ? 7 - remainder : 0 let endDate = calendar.date( byAdding: .day, diff --git a/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift deleted file mode 100644 index 7f760bb8..00000000 --- a/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// LoadWeeklyRecordUseCase.swift -// Domain -// - -import Foundation - -/// 주간 캘린더 데이터 로딩을 담당하는 UseCase -public struct LoadWeeklyRecordUseCase: Sendable { - public struct WeekData: Sendable { - public let weekDays: [WeeklyCalendarDay] - public let monthText: String - - public init(weekDays: [WeeklyCalendarDay], monthText: String) { - self.weekDays = weekDays - self.monthText = monthText - } - } - - public struct DateData: Sendable { - public let photos: [FoodImageAsset] - public let records: [FoodRecord] - public let startOfDay: Date - - public init(photos: [FoodImageAsset], records: [FoodRecord], startOfDay: Date) { - self.photos = photos - self.records = records - self.startOfDay = startOfDay - } - } - - private let calendar: Calendar - private let recordRepository: any FoodRecordRepository - private let fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase - - public init( - calendar: Calendar, - recordRepository: any FoodRecordRepository, - fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase - ) { - self.calendar = calendar - self.recordRepository = recordRepository - self.fetchFoodImageAssetUseCase = fetchFoodImageAssetUseCase - } - - /// 주간 데이터 로드 - public func loadWeekData( - for date: Date, - locale: Locale = Locale(identifier: "ko_KR") - ) async throws -> WeekData { - fetchFoodImageAssetUseCase.prefetch(forPreviousWeeks: 2, of: date) - - let (weekStart, weekEnd) = calendar.weekRange(for: date) - let weekDates = calendar.weekDates(from: weekStart) - let recordsByDate = try await recordRepository.fetchRecords(in: weekStart...weekEnd) - - let today = calendar.startOfDay(for: Date()) - let weekDays = weekDates.map { dayDate in - let startOfDay = calendar.startOfDay(for: dayDate) - return WeeklyCalendarDay( - date: dayDate, - dayOfWeek: dayDate.formatDayOfWeek(locale: locale), - dayNumber: dayDate.formatDayNumber(calendar: calendar), - isToday: calendar.isDateInToday(dayDate), - isFuture: startOfDay > today, - records: recordsByDate[startOfDay] ?? [] - ) - } - - let monthText = date.formatMonthText() - return WeekData(weekDays: weekDays, monthText: monthText) - } - - /// 특정 날짜의 사진만 로드 - 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) - - let photosByDate = try await fetchFoodImageAssetUseCase.execute( - from: startOfDay, - to: endOfDay - ) - - return photosByDate[startOfDay] ?? [] - } - - /// 특정 날짜 데이터 로드 - public func loadDateData(for date: Date) async throws -> DateData { - let startOfDay = calendar.startOfDay(for: date) - let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) - - let photosByDate = try await fetchFoodImageAssetUseCase.execute( - from: startOfDay, - to: endOfDay - ) - - let records = try await recordRepository.fetchRecords(for: date) - - return DateData( - photos: photosByDate[startOfDay] ?? [], - records: records, - startOfDay: startOfDay - ) - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift deleted file mode 100644 index 731c02f5..00000000 --- a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// CalendarViewController.swift -// Presentation -// -// Created by 강대훈 on 2/13/26. -// - -import Combine -import DesignSystem -import SnapKit -import UIKit - -public final class CalendarViewController: UIViewController { - public enum ViewMode { - case weekly - case monthly - } - - 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, - weeklyFlowPublisher: AnyPublisher, - monthlyFlowPublisher: AnyPublisher - ) { - self.weeklyVC = weeklyVC - self.monthlyVC = monthlyVC - super.init(nibName: nil, bundle: nil) - - Publishers.Merge(weeklyFlowPublisher, monthlyFlowPublisher) - .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") - } - - public override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .sdBase - showViewController(for: currentModeSubject.value) - } - - public func toggleViewMode() { - let newMode: ViewMode = currentModeSubject.value == .weekly ? .monthly : .weekly - currentModeSubject.send(newMode) - showViewController(for: newMode) - } - - private func showViewController(for mode: ViewMode) { - let targetVC = mode == .weekly ? weeklyVC : monthlyVC - - // 초기 로드: 애니메이션 없이 바로 추가 - guard let outgoingVC = currentChild else { - addChild(targetVC) - view.addSubview(targetVC.view) - targetVC.view.snp.makeConstraints { $0.edges.equalToSuperview() } - targetVC.didMove(toParent: self) - currentChild = targetVC - return - } - - addChild(targetVC) - targetVC.view.alpha = 0 - view.addSubview(targetVC.view) - targetVC.view.snp.makeConstraints { $0.edges.equalToSuperview() } - - UIView.animate(withDuration: 0.4) { - targetVC.view.alpha = 1 - outgoingVC.view.alpha = 0 - } completion: { _ in - outgoingVC.view.alpha = 1 - outgoingVC.willMove(toParent: nil) - outgoingVC.view.removeFromSuperview() - outgoingVC.removeFromParent() - targetVC.didMove(toParent: self) - self.currentChild = targetVC - } - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift index 76fb93af..9d87a53e 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift @@ -22,7 +22,7 @@ public final class CalendarCoordinator: Coordinator { self.navigationController = navigationController } - public func makeViewController() -> CalendarViewController { + public func makeViewController() -> MonthlyCalendarViewController { let calendarVC = factories.calendar.makeScene() flowBind(vc: calendarVC) return calendarVC @@ -32,7 +32,7 @@ public final class CalendarCoordinator: Coordinator { // MARK: - Screen Routing private extension CalendarCoordinator { - func flowBind(vc: CalendarViewController) { + func flowBind(vc: MonthlyCalendarViewController) { vc.flowPublisher .receive(on: DispatchQueue.main) .sink { [weak self] event in diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift index 0b36ebbc..05d57b57 100644 --- a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -9,7 +9,7 @@ import UIKit public final class CalendarSceneFactory { private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase - private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase + private let fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase private let saveFoodRecordUseCase: SaveFoodRecordUseCase private let pushNotificationObserver: PushNotificationObserver private let getNicknameUseCase: GetNicknameUseCase @@ -20,7 +20,7 @@ public final class CalendarSceneFactory { public init( requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, - loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, + fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase, saveFoodRecordUseCase: SaveFoodRecordUseCase, pushNotificationObserver: PushNotificationObserver, getNicknameUseCase: GetNicknameUseCase, @@ -30,7 +30,7 @@ public final class CalendarSceneFactory { fetchFoodRecordsUseCase: FetchFoodRecordsUseCase ) { self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase - self.loadWeeklyCalendarDataUseCase = loadWeeklyCalendarDataUseCase + self.fetchFoodImageAssetUseCase = fetchFoodImageAssetUseCase self.saveFoodRecordUseCase = saveFoodRecordUseCase self.pushNotificationObserver = pushNotificationObserver self.getNicknameUseCase = getNicknameUseCase @@ -40,29 +40,17 @@ public final class CalendarSceneFactory { self.fetchFoodRecordsUseCase = fetchFoodRecordsUseCase } - public func makeScene() -> CalendarViewController { - let weeklyVM = WeeklyCalendarViewModel( - requestPhotoAuthorizationUseCase: requestPhotoAuthorizationUseCase, - loadWeeklyCalendarDataUseCase: loadWeeklyCalendarDataUseCase, - saveFoodRecordUseCase: saveFoodRecordUseCase, - pushNotificationObserver: pushNotificationObserver, - getNicknameUseCase: getNicknameUseCase, - coachmarkStorage: coachmarkStorage, - checkAppReviewEligibilityUseCase: checkAppReviewEligibilityUseCase - ) + public func makeScene() -> MonthlyCalendarViewController { 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, - weeklyFlowPublisher: weeklyVC.flowPublisher, - monthlyFlowPublisher: monthlyVC.flowPublisher + getNicknameUseCase: getNicknameUseCase, + fetchFoodImageAssetUseCase: fetchFoodImageAssetUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver, + checkAppReviewEligibilityUseCase: checkAppReviewEligibilityUseCase ) + return MonthlyCalendarViewController(viewModel: monthlyVM) } } diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthPickerBottomSheetViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthPickerBottomSheetViewController.swift index bc91b6d6..b724d34a 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthPickerBottomSheetViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthPickerBottomSheetViewController.swift @@ -19,7 +19,6 @@ final class MonthPickerBottomSheetViewController: UIViewController { static let selectButtonHorizontalInset: CGFloat = 18 static let selectButtonBottomInset: CGFloat = 40 static let selectButtonHeight: CGFloat = 47 - static let selectButtonCornerRadius: CGFloat = 28 static let pickerLabelFontSize: CGFloat = 18 static let pickerRowHeight: CGFloat = 40 @@ -54,7 +53,7 @@ final class MonthPickerBottomSheetViewController: UIViewController { private lazy var closeButton: UIButton = { let button = UIButton(type: .system) button.setImage(UIImage(systemName: "xmark"), for: .normal) - button.tintColor = .white + button.tintColor = .black button.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) return button }() @@ -67,7 +66,7 @@ final class MonthPickerBottomSheetViewController: UIViewController { button.setTitleColor(.white, for: .normal) button.titleLabel?.font = .systemFont(ofSize: Constants.pickerLabelFontSize, weight: .semibold) button.backgroundColor = DesignSystemAsset.primary.color - button.layer.cornerRadius = Constants.selectButtonCornerRadius + button.clipsToBounds = true button.addTarget(self, action: #selector(selectButtonTapped), for: .touchUpInside) return button }() @@ -102,6 +101,11 @@ final class MonthPickerBottomSheetViewController: UIViewController { setupConstraints() setupPickerView() } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + selectButton.layer.cornerRadius = selectButton.bounds.height / 2 + } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -111,7 +115,7 @@ final class MonthPickerBottomSheetViewController: UIViewController { // MARK: - Setup private func setupUI() { - view.backgroundColor = DesignSystemAsset.sdBase.color + view.backgroundColor = .white view.addSubview(closeButton) view.addSubview(pickerView) view.addSubview(selectButton) @@ -215,7 +219,7 @@ extension MonthPickerBottomSheetViewController: UIPickerViewDelegate { let label = (view as? UILabel) ?? UILabel() label.textAlignment = .center label.font = .systemFont(ofSize: Constants.pickerLabelFontSize, weight: .regular) - label.textColor = .white + label.textColor = .black if component == 0 { label.text = "\(years[row])년" diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift index 3ab1254b..b93a7903 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift @@ -5,6 +5,7 @@ import DesignSystem import Domain +import Kingfisher import SnapKit import UIKit @@ -12,10 +13,8 @@ import UIKit final class MonthlyCalendarDayCell: UICollectionViewCell { private enum Constants { - static let cornerRadius: CGFloat = 8 - static let stackHorizontalInset: CGFloat = 4 - static let stackVerticalInset: CGFloat = 6 - static let todayBorderWidth: CGFloat = 1 + static let cornerRadius: CGFloat = 10 + static let borderWidth: CGFloat = 1.5 } static let reuseIdentifier = "MonthlyCalendarDayCell" @@ -24,45 +23,29 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { private let containerView: UIView = { let view = UIView() - view.backgroundColor = .clear + view.backgroundColor = .calendarTileBackground view.clipsToBounds = true view.layer.cornerRadius = Constants.cornerRadius return view }() - private lazy var stackView: UIStackView = { - let view = UIStackView(arrangedSubviews: [ - dayNumberLabel, dashedBorderView, polaroidImageCardsView, - ]) - view.backgroundColor = .clear - view.axis = .vertical - view.alignment = .center - view.spacing = 6 - view.clipsToBounds = true - view.layer.cornerRadius = Constants.cornerRadius - return view + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.isHidden = true + return imageView }() private let dayNumberLabel: UILabel = { let label = UILabel() label.textAlignment = .center + label.layer.shadowColor = UIColor.black.cgColor + label.layer.shadowOffset = .zero + label.layer.shadowRadius = 3 return label }() - private let dashedBorderView = DashedBorderView() - - private let polaroidImageCardsView: PolaroidImageCardsView = { - let view = PolaroidImageCardsView() - view.isHidden = true - return view - }() - - private lazy var gradientBackgroundView: GradientBackgroundView = { - let view = GradientBackgroundView(cornerRadius: Constants.cornerRadius) - view.isHidden = true - return view - }() - // MARK: - Init override init(frame: CGRect) { @@ -84,12 +67,9 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { // MARK: - Setup private func setupUI() { - dashedBorderView.clipsToBounds = true - dashedBorderView.layer.cornerRadius = Constants.cornerRadius - contentView.addSubview(containerView) - containerView.addSubview(gradientBackgroundView) - containerView.addSubview(stackView) + containerView.addSubview(imageView) + containerView.addSubview(dayNumberLabel) } private func setupConstraints() { @@ -97,98 +77,60 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { $0.edges.equalToSuperview() } - stackView.snp.makeConstraints { - $0.leading.trailing.equalToSuperview().inset(Constants.stackHorizontalInset) - $0.top.bottom.equalToSuperview().inset(Constants.stackVerticalInset) - $0.center.equalToSuperview() - } - - dashedBorderView.snp.makeConstraints { - $0.height.equalTo(dashedBorderView.snp.width) - } - - polaroidImageCardsView.snp.makeConstraints { - $0.height.equalTo(polaroidImageCardsView.snp.width) + imageView.snp.makeConstraints { + $0.edges.equalToSuperview() } - gradientBackgroundView.snp.makeConstraints { - $0.edges.equalToSuperview() + dayNumberLabel.snp.makeConstraints { + $0.center.equalToSuperview() } } // MARK: - Configuration - func configure(with day: MonthlyCalendarDay) { - isUserInteractionEnabled = day.isCurrentMonth - - applyRecordStyle(photoURLs: day.imageURLs, isCurrentMonth: day.isCurrentMonth) + func configure(with day: MonthlyCalendarDay, selectedDate: Date) { + applyRecordStyle(photoURLs: day.imageURLs) applyDayNumberStyle( dayNumber: day.dayNumber, - isCurrentMonth: day.isCurrentMonth, - isToday: day.isToday, + isCurrentMonth: day.isCurrentMonth ) - if day.isToday { - applyTodayStyle() - } + applySelectionStyle(isSelected: Calendar.current.isDate(day.date, inSameDayAs: selectedDate)) } private func resetCellState() { isUserInteractionEnabled = true - dashedBorderView.backgroundColor = .clear - containerView.backgroundColor = .clear + containerView.backgroundColor = .calendarTileBackground containerView.removeGradient() containerView.removeGlow() containerView.layer.borderColor = UIColor.clear.cgColor - stackView.backgroundColor = .clear - dashedBorderView.isHidden = false - polaroidImageCardsView.isHidden = true - polaroidImageCardsView.alpha = 1 - gradientBackgroundView.isHidden = true + containerView.layer.borderWidth = 0 + imageView.kf.cancelDownloadTask() + imageView.image = nil + imageView.isHidden = true + dayNumberLabel.layer.shadowOpacity = 0 } - private func applyTodayStyle() { - gradientBackgroundView.isHidden = false - containerView.layer.borderWidth = Constants.todayBorderWidth - containerView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor - containerView.applyGlow( - glowColor: .primary, - borderColor: UIColor.white.withAlphaComponent(0.3), - cornerRadius: Constants.cornerRadius - ) - dashedBorderView.backgroundColor = .white.withAlphaComponent(0.2) - dashedBorderView.layer.borderColor = DesignSystemAsset.sd800.color.cgColor - } - - private func applyRecordStyle(photoURLs: [URL], isCurrentMonth: Bool) { - let hasPhoto = !photoURLs.isEmpty - dashedBorderView.isHidden = hasPhoto - polaroidImageCardsView.isHidden = !hasPhoto - - guard hasPhoto else { return } - - polaroidImageCardsView.alpha = isCurrentMonth ? 1 : 0.3 - - if photoURLs.count == 1 { - polaroidImageCardsView.configure(imageURL: photoURLs[0]) - } else { - polaroidImageCardsView.configure( - backImageURL: photoURLs[0], frontImageURL: photoURLs[1]) - } + private func applyRecordStyle(photoURLs: [URL]) { + guard let imageURL = photoURLs.first else { return } + imageView.isHidden = false + imageView.kf.setImage(with: imageURL) } private func applyDayNumberStyle( dayNumber: Int, - isCurrentMonth: Bool, - isToday: Bool, + isCurrentMonth: Bool ) { let formattedDayNumber = String(format: "%d", dayNumber) - if isCurrentMonth { - dayNumberLabel.setText(formattedDayNumber, style: .p12, color: .white) - } else { - dayNumberLabel.setText( - formattedDayNumber, style: .p12, color: DesignSystemAsset.gray700.color) - } + let hasImage = !imageView.isHidden + let color: UIColor = hasImage ? .white : (isCurrentMonth ? .gray300 : .gray100) + dayNumberLabel.layer.shadowOpacity = hasImage ? 1 : 0 + dayNumberLabel.setText(formattedDayNumber, style: .p12, color: color, alignment: .center) + } + + private func applySelectionStyle(isSelected: Bool) { + containerView.layer.borderWidth = isSelected ? Constants.borderWidth : 0 + containerView.layer.borderColor = isSelected ? UIColor.primary.cgColor : UIColor.clear.cgColor } } diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarHeaderView.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarHeaderView.swift index 5d005945..60462548 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarHeaderView.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarHeaderView.swift @@ -18,6 +18,11 @@ final class MonthlyCalendarHeaderView: UIView { monthPickerTapSubject.eraseToAnyPublisher() } + private let todayTapSubject = PassthroughSubject() + var todayTapPublisher: AnyPublisher { + todayTapSubject.eraseToAnyPublisher() + } + // MARK: - UI Components private let monthYearLabel: UILabel = { @@ -29,7 +34,16 @@ final class MonthlyCalendarHeaderView: UIView { private let chevronButton: UIButton = { let button = UIButton(type: .system) button.setImage(DesignSystemAsset.iconDown.image, for: .normal) - button.tintColor = .white + button.tintColor = .grayBase + return button + }() + + private let todayButton: UIButton = { + let button = UIButton(type: .system) + button.layer.borderColor = UIColor.primary.cgColor + button.layer.borderWidth = 1 + button.layer.cornerRadius = 12 + button.setAttributedTitle(Typography.p12.styled("TODAY", color: .primary, alignment: .center), for: .normal) return button }() @@ -67,6 +81,7 @@ final class MonthlyCalendarHeaderView: UIView { private func setupUI() { addSubview(containerStackView) addSubview(tapButton) + addSubview(todayButton) } private func setupConstraints() { @@ -86,16 +101,28 @@ final class MonthlyCalendarHeaderView: UIView { tapButton.snp.makeConstraints { $0.edges.equalTo(containerStackView) } + + todayButton.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.centerY.equalToSuperview() + $0.height.equalTo(24) + $0.width.equalTo(54) + } } private func setupActions() { tapButton.addTarget(self, action: #selector(handleMonthPickerTap), for: .touchUpInside) + todayButton.addTarget(self, action: #selector(handleTodayTap), for: .touchUpInside) } // MARK: - Configuration func setMonthYearText(_ text: String) { - monthYearLabel.setText(text, style: .p18, color: .gray050) + monthYearLabel.setText(text, style: .p15, color: .grayBase) + } + + func setTodayButtonHidden(_ isHidden: Bool) { + todayButton.isHidden = isHidden } func resetChevron() { @@ -122,4 +149,8 @@ final class MonthlyCalendarHeaderView: UIView { ) monthPickerTapSubject.send() } + + @objc private func handleTodayTap() { + todayTapSubject.send() + } } diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/WeekdayHeaderView.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/WeekdayHeaderView.swift index 53c414af..acac00d7 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/WeekdayHeaderView.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/WeekdayHeaderView.swift @@ -12,9 +12,9 @@ final class WeekdayHeaderView: UIView { // MARK: - Constants - private let weekdays = ["월", "화", "수", "목", "금", "토", "일"] - - // MARK: - UI Components + private enum Constants { + static let weekdays = ["일", "월", "화", "수", "목", "금", "토"] + } private let stackView: UIStackView = { let sv = UIStackView() @@ -42,9 +42,9 @@ final class WeekdayHeaderView: UIView { private func setupUI() { addSubview(stackView) - weekdays.forEach { day in + Constants.weekdays.enumerated().forEach { index, day in let label = UILabel() - label.setText(day, style: .p12, color: .gray300) + label.setText(day, style: .p12, color: index == 0 ? .redPostBase : .gray500) label.textAlignment = .center stackView.addArrangedSubview(label) } diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index b1832561..b23b1c9e 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -7,10 +7,12 @@ import Combine import Data import DesignSystem import Domain +import Kingfisher import SnapKit +import StoreKit import UIKit -public final class MonthlyCalendarViewController: UIViewController, UICollectionViewDelegate { +public final class MonthlyCalendarViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { private enum Section: Hashable { case calendar @@ -29,18 +31,14 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection // MARK: - UI Components - private let recordPromptHeaderView = RecordPromptHeaderView() + private let scrollView = UIScrollView() + private let contentView = UIView() private let monthYearHeaderView = MonthlyCalendarHeaderView() private let weekdayHeaderView = WeekdayHeaderView() - - private lazy var containerView: UIView = { - let v = UIView() - v.layer.borderColor = DesignSystemAsset.sd800.color.cgColor - v.backgroundColor = DesignSystemAsset.sd900.color - v.layer.borderWidth = Constants.containerBorderWidth - v.layer.cornerRadius = Constants.containerCornerRadius - return v - }() + private let progressView = MonthlyRecordProgressView() + private let selectedDateLabel = UILabel() + private let detailButton = UIButton(type: .system) + private let mealSummaryView = SelectedDayMealSummaryView() private lazy var stackView: UIStackView = { let sv = UIStackView(arrangedSubviews: [weekdayHeaderView, collectionView]) @@ -66,7 +64,6 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection private var dataSource: UICollectionViewDiffableDataSource? private var cancellables = Set() private var monthPickerCancellables = Set() - private var numberOfWeeks: Int = 5 private var collectionViewHeightConstraint: Constraint? // MARK: - Init @@ -96,7 +93,7 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection viewModel.input.send(.loadInitialData) } - + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) viewModel.input.send(.refreshCurrentMonth) @@ -104,6 +101,8 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + view.layoutIfNeeded() + updateCollectionViewItemSize() updateCollectionViewHeight() } @@ -111,13 +110,18 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection // MARK: - Setup private func setupUI() { - view.backgroundColor = DesignSystemAsset.sdBase.color - view.addSubview(recordPromptHeaderView) - view.addSubview(monthYearHeaderView) - view.addSubview(containerView) - containerView.addSubview(stackView) + view.backgroundColor = .white + view.addSubview(scrollView) + scrollView.addSubview(contentView) + contentView.addSubview(monthYearHeaderView) + contentView.addSubview(stackView) + contentView.addSubview(progressView) + contentView.addSubview(selectedDateLabel) + contentView.addSubview(detailButton) + contentView.addSubview(mealSummaryView) setupSwipeGestures() + setupDetailButton() let mypageButton = UIBarButtonItem( image: DesignSystemAsset.iconMypage.image, @@ -125,59 +129,55 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection target: nil, action: nil ) - + navigationItem.rightBarButtonItem = mypageButton } private func setupConstraints() { - recordPromptHeaderView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide).offset(Constants.recordPromptTopOffset) - $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) + scrollView.snp.makeConstraints { + $0.edges.equalTo(view.safeAreaLayoutGuide) } - monthYearHeaderView.snp.makeConstraints { - $0.top.equalTo(recordPromptHeaderView.snp.bottom).offset(Constants.monthYearHeaderTopOffset) - $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) + contentView.snp.makeConstraints { + $0.edges.equalTo(scrollView.contentLayoutGuide) + $0.width.equalTo(scrollView.frameLayoutGuide) } - containerView.snp.makeConstraints { - $0.top.equalTo(monthYearHeaderView.snp.bottom).offset(Constants.containerTopOffset) - $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(Constants.containerBottomOffset) + monthYearHeaderView.snp.makeConstraints { + $0.top.equalToSuperview().offset(Constants.monthYearHeaderTopOffset) $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) } stackView.snp.makeConstraints { - $0.top.equalToSuperview().inset(Constants.stackTopInset) + $0.top.equalTo(monthYearHeaderView.snp.bottom).offset(Constants.containerTopOffset) $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) - $0.bottom.equalToSuperview().inset(Constants.stackBottomInset) } - } - private func createLayout() -> UICollectionViewLayout { - UICollectionViewCompositionalLayout { [weak self] _, _ in - guard let self else { return nil } + collectionView.snp.makeConstraints { + collectionViewHeightConstraint = $0.height.equalTo(0).constraint + } - let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0 / Constants.numberOfDaysInWeek), - heightDimension: .fractionalHeight(1.0) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.contentInsets = NSDirectionalEdgeInsets( - top: 0, - leading: Constants.cellHorizontalSpacing, - bottom: 0, - trailing: Constants.cellHorizontalSpacing - ) + progressView.snp.makeConstraints { + $0.top.equalTo(stackView.snp.bottom).offset(Constants.progressTopOffset) + $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) + $0.height.equalTo(Constants.progressHeight) + } - let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .fractionalHeight(1.0 / CGFloat(self.numberOfWeeks)) - ) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + selectedDateLabel.snp.makeConstraints { + $0.top.equalTo(progressView.snp.bottom).offset(Constants.selectedDateTopOffset) + $0.leading.equalToSuperview().inset(Constants.horizontalInset) + } - let section = NSCollectionLayoutSection(group: group) + detailButton.snp.makeConstraints { + $0.centerY.equalTo(selectedDateLabel) + $0.trailing.equalToSuperview().inset(Constants.horizontalInset) + } - return section + mealSummaryView.snp.makeConstraints { + $0.top.equalTo(selectedDateLabel.snp.bottom).offset(Constants.summaryTopOffset) + $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) + $0.height.equalTo(Constants.summaryHeight) + $0.bottom.equalToSuperview().inset(24) } } @@ -190,7 +190,7 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection return UICollectionViewCell() } - cell.configure(with: day) + cell.configure(with: day, selectedDate: self.viewModel.state.selectedDate) return cell } } @@ -203,23 +203,29 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection } .store(in: &cancellables) + monthYearHeaderView.todayTapPublisher + .sink { [weak self] in + self?.viewModel.input.send(.selectMonth(Date())) + } + .store(in: &cancellables) + // Output: ViewModel → View viewModel.statePublisher - .map(\.nickname) - .compactMap { $0 } + .map(\.monthYearText) .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [weak self] nickname in - self?.recordPromptHeaderView.configure(nickname: nickname) + .sink { [weak self] text in + self?.monthYearHeaderView.setMonthYearText(text) } .store(in: &cancellables) viewModel.statePublisher - .map(\.monthYearText) + .map(\.currentDisplayDate) .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [weak self] text in - self?.monthYearHeaderView.setMonthYearText(text) + .sink { [weak self] date in + let isCurrentMonth = Calendar.current.isDate(date, equalTo: Date(), toGranularity: .month) + self?.monthYearHeaderView.setTodayButtonHidden(isCurrentMonth) } .store(in: &cancellables) @@ -227,6 +233,14 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection .receive(on: DispatchQueue.main) .sink { [weak self] state in self?.updateCalendar(days: state.monthDays, numberOfWeeks: state.numberOfWeeks) + self?.collectionView.reloadData() + self?.updateSelectedDate(state.selectedDate) + self?.progressView.configure(recordCount: state.monthRecordCount, totalCount: state.monthDayCount) + self?.mealSummaryView.configure( + records: state.selectedRecords, + processingRecords: state.processingRecords, + hasFoodPhotos: state.hasFoodPhotos + ) } .store(in: &cancellables) @@ -239,19 +253,46 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection self?.navigateToDetail(date: date, records: records) case .showError(let error): self?.showErrorAlert(error) + case .photoAuthorizationDenied: + self?.showPhotoAuthorizationDeniedAlert() + case .uploadCompleted(let date, let mealType): + self?.navigateToDetail(date: date, records: self?.viewModel.state.selectedRecords ?? [], scrollTo: mealType, shouldPopToRoot: true) + case .saveFailed(let error): + self?.showSaveErrorAlert(error) + case .requestAppReview: + self?.requestAppReview() } } .store(in: &cancellables) + + mealSummaryView.tapPublisher + .sink { [weak self] in + self?.viewModel.input.send(.navigateToSelectedDateDetail) + } + .store(in: &cancellables) } private func setupSwipeGestures() { let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:))) swipeLeft.direction = .left - containerView.addGestureRecognizer(swipeLeft) + view.addGestureRecognizer(swipeLeft) let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:))) swipeRight.direction = .right - containerView.addGestureRecognizer(swipeRight) + view.addGestureRecognizer(swipeRight) + } + + private func setupDetailButton() { + let attributedTitle = NSMutableAttributedString( + attributedString: Typography.p12.styled("기록보기", color: .detailPrimaryText) + ) + attributedTitle.addAttribute( + .underlineStyle, + value: NSUnderlineStyle.single.rawValue, + range: NSRange(location: 0, length: attributedTitle.length) + ) + detailButton.setAttributedTitle(attributedTitle, for: .normal) + detailButton.addTarget(self, action: #selector(detailButtonTapped), for: .touchUpInside) } @objc private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { @@ -268,21 +309,44 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection // MARK: - Private Methods private func updateCalendar(days: [MonthlyCalendarDay], numberOfWeeks: Int) { - self.numberOfWeeks = numberOfWeeks updateCollectionViewHeight() collectionView.collectionViewLayout.invalidateLayout() applySnapshot(days: days) } private func updateCollectionViewHeight() { - let availableWidth = view.bounds.width - Constants.horizontalInset * 4 - let cellWidth = availableWidth / Constants.numberOfDaysInWeek - - let rowHeight = cellWidth + Constants.cellRowHeightPadding - let totalHeight = rowHeight * CGFloat(numberOfWeeks) + let itemSide = calendarItemSide() + let totalSpacing = Constants.lineSpacing * CGFloat(max(viewModel.state.numberOfWeeks - 1, 0)) + let totalHeight = itemSide * CGFloat(viewModel.state.numberOfWeeks) + totalSpacing collectionViewHeightConstraint?.update(offset: totalHeight) } + private func createLayout() -> UICollectionViewLayout { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.minimumInteritemSpacing = Constants.interItemSpacing + layout.minimumLineSpacing = Constants.lineSpacing + layout.sectionInset = .zero + layout.estimatedItemSize = .zero + return layout + } + + private func updateCollectionViewItemSize() { + guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return } + let side = calendarItemSide() + let itemSize = CGSize(width: side, height: side) + guard layout.itemSize != itemSize else { return } + layout.itemSize = itemSize + layout.invalidateLayout() + } + + private func calendarItemSide() -> CGFloat { + let width = view.bounds.width - (Constants.horizontalInset * 2) + let totalSpacing = Constants.interItemSpacing * CGFloat(Constants.numberOfDaysInWeek - 1) + let availableWidth = max(0, width - totalSpacing) + return floor(availableWidth / Constants.numberOfDaysInWeek) + } + private func applySnapshot(days: [MonthlyCalendarDay]) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.calendar]) @@ -300,12 +364,12 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection context.maximumDetentValue * Constants.monthPickerDetentRatio }] } - + picker.selectedMonthPublisher .sink { [weak self] date in self?.viewModel.input.send(.selectMonth(date)) }.store(in: &monthPickerCancellables) - + picker.dismissPublisher .sink { [weak self] in self?.monthYearHeaderView.resetChevron() @@ -315,6 +379,22 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection present(picker, animated: true) } + private func updateSelectedDate(_ date: Date) { + selectedDateLabel.setText("\(Calendar.current.component(.day, from: date))일", style: .p18, color: .detailSectionText) + } + + @objc private func detailButtonTapped() { + viewModel.input.send(.navigateToSelectedDateDetail) + } + + public func addFoodRecord() { + flowSubject.send(.pushImagePicker( + ImagePickerSceneInput(date: viewModel.state.selectedDate) { [weak self] assets in + self?.viewModel.input.send(.saveSelectedPhotos(assets)) + } + )) + } + // MARK: - UICollectionViewDelegate public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { @@ -324,10 +404,17 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection // MARK: - Navigation - private func navigateToDetail(date: Date, records: [FoodRecord]) { + private func navigateToDetail( + date: Date, + records: [FoodRecord], + scrollTo mealType: MealType? = nil, + shouldPopToRoot: Bool = false + ) { let input = DetailSceneInput( date: date, records: records, + scrollToMealType: mealType, + shouldPopToRoot: shouldPopToRoot, onDismissWithDate: { [weak self] date in self?.viewModel.input.send(.updateMonth(date)) } @@ -340,6 +427,36 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection alert.addAction(UIAlertAction(title: "확인", style: .default)) present(alert, animated: true) } + + private func showPhotoAuthorizationDeniedAlert() { + let alert = UIAlertController( + title: "사진 접근 권한 필요", + message: "음식 사진을 기록하려면 사진 라이브러리 접근 권한이 필요합니다. 설정에서 권한을 허용해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "설정으로 이동", style: .default) { _ in + if let settingsURL = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(settingsURL) + } + }) + 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 requestAppReview() { + guard let windowScene = view.window?.windowScene else { return } + AppStore.requestReview(in: windowScene) + } } @@ -347,19 +464,297 @@ public final class MonthlyCalendarViewController: UIViewController, UICollection extension MonthlyCalendarViewController { enum Constants { - static var horizontalInset: CGFloat { 20 } - static var containerCornerRadius: CGFloat { 16 } - static var containerBorderWidth: CGFloat { 1 } - static var recordPromptTopOffset: CGFloat { 28 } - static var monthYearHeaderTopOffset: CGFloat { 32 } - static var containerTopOffset: CGFloat { 18 } - static var containerBottomOffset: CGFloat { -38 } - static var stackSpacing: CGFloat { 16 } - static var stackTopInset: CGFloat { 24 } - static var stackBottomInset: CGFloat { 18 } - static var cellHorizontalSpacing: CGFloat { 1.5 } - static var cellRowHeightPadding: CGFloat { 28 } + static var horizontalInset: CGFloat { 15 } + static var monthYearHeaderTopOffset: CGFloat { 14 } + static var containerTopOffset: CGFloat { 14 } + static var stackSpacing: CGFloat { 12 } + static var interItemSpacing: CGFloat { 6 } + static var lineSpacing: CGFloat { 4 } + static var progressTopOffset: CGFloat { 24 } + static var progressHeight: CGFloat { 36 } + static var selectedDateTopOffset: CGFloat { 34 } + static var summaryTopOffset: CGFloat { 12 } + static var summaryHeight: CGFloat { 264 } static var numberOfDaysInWeek: CGFloat { 7 } static var monthPickerDetentRatio: CGFloat { 0.45 } } } + +private final class MonthlyRecordProgressView: UIView { + private let fillView = UIView() + private let fillGradientLayer = CAGradientLayer() + private let titleLabel = UILabel() + private let countLabel = UILabel() + private var fillWidthConstraint: Constraint? + private var progress: CGFloat = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + fillGradientLayer.frame = fillView.bounds + fillGradientLayer.cornerRadius = fillView.layer.cornerRadius + updateFillWidth() + } + + func configure(recordCount: Int, totalCount: Int) { + let safeTotal = max(totalCount, 1) + progress = min(1, CGFloat(recordCount) / CGFloat(safeTotal)) + titleLabel.setText("이번달 기록", style: .p12, color: .white) + countLabel.attributedText = progressCountText(recordCount: recordCount, totalCount: totalCount) + updateFillWidth() + } + + private func setupUI() { + backgroundColor = UIColor.primary.withAlphaComponent(0.2) + layer.cornerRadius = 18 + clipsToBounds = true + fillView.layer.cornerRadius = 18 + fillView.clipsToBounds = true + fillGradientLayer.colors = [ + UIColor.primaryLight.cgColor, + UIColor.primary.cgColor + ] + fillGradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + fillGradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + fillView.layer.insertSublayer(fillGradientLayer, at: 0) + addSubview(fillView) + addSubview(titleLabel) + addSubview(countLabel) + } + + private func setupConstraints() { + fillView.snp.makeConstraints { + $0.top.bottom.leading.equalToSuperview() + fillWidthConstraint = $0.width.equalTo(0).constraint + } + titleLabel.snp.makeConstraints { + $0.leading.equalToSuperview().offset(16) + $0.centerY.equalToSuperview() + } + countLabel.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(16) + $0.centerY.equalToSuperview() + } + } + + private func updateFillWidth() { + fillWidthConstraint?.update(offset: max(36, bounds.width * progress)) + } + + private func progressCountText(recordCount: Int, totalCount: Int) -> NSAttributedString { + let attributed = NSMutableAttributedString( + string: "\(recordCount)", + attributes: [ + .font: UIFont.systemFont(ofSize: 12, weight: .semibold), + .foregroundColor: UIColor.primary + ] + ) + attributed.append( + NSAttributedString( + string: "/\(totalCount)", + attributes: [ + .font: UIFont.systemFont(ofSize: 12, weight: .regular), + .foregroundColor: UIColor.primary.withAlphaComponent(0.75) + ] + ) + ) + return attributed + } +} + +private final class SelectedDayMealSummaryView: UIView { + private struct MealRowConfiguration { + let mealType: MealType + let title: String + let subtitle: String + } + + private let tapSubject = PassthroughSubject() + var tapPublisher: AnyPublisher { + tapSubject.eraseToAnyPublisher() + } + + private let stackView = UIStackView() + private let mealRowConfigurations: [MealRowConfiguration] = [ + MealRowConfiguration(mealType: .breakfast, title: "아침", subtitle: "오전 5시 ~ 오전 10시"), + MealRowConfiguration(mealType: .lunch, title: "점심", subtitle: "오전 10시 ~ 오후 5시"), + MealRowConfiguration(mealType: .dinner, title: "저녁", subtitle: "오후 5시 ~ 오후 10시"), + MealRowConfiguration(mealType: .snack, title: "야식", subtitle: "오후 10시 ~ 오전 5시") + ] + private lazy var rows: [MealRowView] = mealRowConfigurations.map { + MealRowView(title: $0.title, subtitle: $0.subtitle) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(records: [FoodRecord], processingRecords: [FoodRecord], hasFoodPhotos: Bool) { + let allRecords = records + processingRecords + for (configuration, row) in zip(mealRowConfigurations, rows) { + let imageURLs = imageURLs(in: allRecords, for: configuration.mealType) + row.configure( + imageURLs: imageURLs, + showsPlaceholder: imageURLs.isEmpty || (configuration.mealType == .dinner && !hasFoodPhotos) + ) + } + } + + private func setupUI() { + backgroundColor = .calendarTileBackground + layer.cornerRadius = 10 + addSubview(stackView) + stackView.axis = .vertical + stackView.distribution = .fillEqually + rows.enumerated().forEach { index, row in + stackView.addArrangedSubview(row) + row.showsSeparator = index < rows.count - 1 + } + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap))) + } + + private func setupConstraints() { + stackView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)) + } + } + + private func imageURLs(in records: [FoodRecord], for mealType: MealType) -> [URL] { + records + .filter { $0.mealType == mealType } + .flatMap(\.imageURLs) + } + + @objc private func didTap() { + tapSubject.send() + } +} + +private final class MealRowView: UIView { + var showsSeparator: Bool = true { + didSet { separatorView.isHidden = !showsSeparator } + } + + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let imageStackView = MealImageStackView() + private let separatorView = UIView() + private let title: String + private let subtitle: String + + init(title: String, subtitle: String) { + self.title = title + self.subtitle = subtitle + super.init(frame: .zero) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(imageURLs: [URL], showsPlaceholder: Bool) { + imageStackView.configure(imageURLs: imageURLs, showsPlaceholder: showsPlaceholder) + } + + private func setupUI() { + titleLabel.setText(title, style: .p12, color: UIColor(red: 0.157, green: 0.157, blue: 0.157, alpha: 1)) + subtitleLabel.setText(subtitle, style: .p10, color: UIColor(red: 0.235, green: 0.235, blue: 0.235, alpha: 1)) + separatorView.backgroundColor = .gray200 + addSubview(titleLabel) + addSubview(subtitleLabel) + addSubview(imageStackView) + addSubview(separatorView) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.centerY.equalToSuperview().offset(-12) + } + subtitleLabel.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.top.equalTo(titleLabel.snp.bottom).offset(8) + } + imageStackView.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.centerY.equalToSuperview() + $0.width.equalTo(60) + $0.height.equalTo(42) + } + separatorView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + $0.height.equalTo(1) + } + } +} + +private final class MealImageStackView: UIView { + private let placeholderView = UIImageView() + private var imageViews: [UIImageView] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + setupPlaceholder() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(imageURLs: [URL], showsPlaceholder: Bool) { + imageViews.forEach { $0.removeFromSuperview() } + imageViews.removeAll() + placeholderView.isHidden = !imageURLs.isEmpty || !showsPlaceholder + + for (index, url) in imageURLs.prefix(3).enumerated() { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 10 + imageView.layer.borderWidth = 1.5 + imageView.layer.borderColor = UIColor.white.cgColor + imageView.kf.setImage(with: url) + insertSubview(imageView, belowSubview: placeholderView) + imageView.snp.makeConstraints { + $0.width.height.equalTo(42) + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().offset(CGFloat(index) * -17) + } + imageViews.append(imageView) + } + } + + private func setupPlaceholder() { + placeholderView.image = DesignSystemAsset.calendarEmptyMeal.image + placeholderView.contentMode = .scaleAspectFit + placeholderView.layer.cornerRadius = 10 + placeholderView.isHidden = true + addSubview(placeholderView) + placeholderView.snp.makeConstraints { + $0.width.height.equalTo(42) + $0.trailing.centerY.equalToSuperview() + } + } +} diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index 2dca5981..7f10d558 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -42,6 +42,10 @@ public final class MonthlyCalendarViewModel { private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase private let getNicknameUseCase: GetNicknameUseCase + private let fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let pushNotificationObserver: any PushNotificationObserving + private let checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase // MARK: - Init @@ -49,12 +53,20 @@ public final class MonthlyCalendarViewModel { fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, fetchFoodRecordsUseCase: FetchFoodRecordsUseCase, - getNicknameUseCase: GetNicknameUseCase + getNicknameUseCase: GetNicknameUseCase, + fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, + pushNotificationObserver: any PushNotificationObserving, + checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase ) { self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase self.fetchFoodRecordsUseCase = fetchFoodRecordsUseCase self.getNicknameUseCase = getNicknameUseCase + self.fetchFoodImageAssetUseCase = fetchFoodImageAssetUseCase + self.saveFoodRecordUseCase = saveFoodRecordUseCase + self.pushNotificationObserver = pushNotificationObserver + self.checkAppReviewEligibilityUseCase = checkAppReviewEligibilityUseCase let today = Date() let calendar = Calendar.seoul @@ -67,6 +79,7 @@ public final class MonthlyCalendarViewModel { state.monthDays = placeholderDays state.numberOfWeeks = placeholderDays.count / 7 state.monthYearText = today.formatMonthText() + state.selectedDate = calendar.startOfDay(for: today) setupBindings() input.send(.loadNickname) @@ -83,6 +96,12 @@ public final class MonthlyCalendarViewModel { } } .store(in: &cancellables) + + pushNotificationObserver.analysisResultPublisher + .sink { [weak self] notification in + self?.input.send(.handlePushNotification(notification)) + } + .store(in: &cancellables) } @MainActor @@ -94,6 +113,7 @@ public final class MonthlyCalendarViewModel { case .loadInitialData: await requestPhotoAuthorizationIfNeeded() startLoadMonth(for: state.currentDisplayDate) + await updateSelectedDateContent() case .selectMonth(let date): let calendar = Calendar.current @@ -103,18 +123,17 @@ public final class MonthlyCalendarViewModel { let todayYearMonth = calendar.date(from: todayComponents), newYearMonth <= todayYearMonth else { return } state.currentDisplayDate = date + state.selectedDate = selectedDate(for: date) startLoadMonth(for: state.currentDisplayDate) + await updateSelectedDateContent() case .selectDay(let date): - do { - let records = try await fetchFoodRecordsUseCase.execute(for: date) - eventSubject.send(.navigateToDetail(date: date, records: records)) - } catch { - eventSubject.send(.showError(error)) - } + state.selectedDate = Calendar.current.startOfDay(for: date) + await updateSelectedDateContent() case .refreshCurrentMonth: startLoadMonth(for: state.currentDisplayDate) + await updateSelectedDateContent() case .updateMonth(let date): let calendar = Calendar.current @@ -124,6 +143,23 @@ public final class MonthlyCalendarViewModel { } else { startLoadMonth(for: state.currentDisplayDate) } + state.selectedDate = calendar.startOfDay(for: date) + await updateSelectedDateContent() + + case .requestPhotoAuthorization: + let status = await requestPhotoAuthorizationUseCase.execute() + if status == .denied || status == .restricted { + eventSubject.send(.photoAuthorizationDenied) + } + + case .saveSelectedPhotos(let assets): + await savePhotosAsRecord(assets) + + case .handlePushNotification(let notification): + await handlePushNotification(notification) + + case .navigateToSelectedDateDetail: + eventSubject.send(.navigateToDetail(date: state.selectedDate, records: state.selectedRecords)) } } @@ -144,11 +180,12 @@ public final class MonthlyCalendarViewModel { state.monthYearText = date.formatMonthText() let period = Calendar.current.monthlyCalendarPeriod(for: date) - + do { for try await monthDays in fetchMonthlyCalendarDaysUseCase.execute(for: period, currentMonth: date) { state.monthDays = monthDays state.numberOfWeeks = monthDays.count / 7 + updateMonthlyProgress() } } catch is CancellationError { print("Task Cancelled") @@ -193,6 +230,104 @@ public final class MonthlyCalendarViewModel { _ = await requestPhotoAuthorizationUseCase.execute() } } + + @MainActor + private func updateSelectedDateContent() async { + do { + let records = try await fetchFoodRecordsUseCase.execute(for: state.selectedDate) + let completedRecords = records.filter { !$0.isProcessing } + let processingRecords = records.filter { $0.isProcessing } + + var hasFoodPhotos = false + if completedRecords.isEmpty && processingRecords.isEmpty { + hasFoodPhotos = await checkFoodPhotosExist(for: state.selectedDate) + } + + state.selectedRecords = completedRecords + state.processingRecords = processingRecords + state.hasFoodPhotos = hasFoodPhotos + } catch { + eventSubject.send(.showError(error)) + } + } + + private func checkFoodPhotosExist(for date: Date) async -> Bool { + guard requestPhotoAuthorizationUseCase.isAuthorized() else { + return false + } + 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 fetchFoodImageAssetUseCase.execute( + from: startOfDay, + to: endOfDay + ) + let photos = photosByDate[startOfDay] ?? [] + return !photos.isEmpty + } catch { + return false + } + } + + @MainActor + private func savePhotosAsRecord(_ assets: [any ImageAssetable]) async { + guard !assets.isEmpty else { return } + + do { + let results = try await saveFoodRecordUseCase.execute( + from: assets, + date: state.selectedDate + ) + let mealType = results.first?.mealType ?? .breakfast + eventSubject.send(.uploadCompleted(date: state.selectedDate, mealType: mealType)) + startLoadMonth(for: state.currentDisplayDate) + await updateSelectedDateContent() + } catch { + eventSubject.send(.saveFailed(error)) + } + } + + private func handlePushNotification(_ notification: AnalysisResultNotification) async { + let calendar = Calendar.current + let notificationDate = calendar.startOfDay(for: notification.diaryDate) + let selectedDate = calendar.startOfDay(for: state.selectedDate) + + if calendar.isDate(notificationDate, equalTo: state.currentDisplayDate, toGranularity: .month) { + await startLoadMonth(for: state.currentDisplayDate) + } + + if notificationDate == selectedDate { + await updateSelectedDateContent() + } + + if checkAppReviewEligibilityUseCase.execute() { + eventSubject.send(.requestAppReview) + } + } + + @MainActor + private func updateMonthlyProgress() { + let calendar = Calendar.current + let currentComponents = calendar.dateComponents([.year, .month], from: state.currentDisplayDate) + let currentMonthDays = state.monthDays.filter { day in + let components = calendar.dateComponents([.year, .month], from: day.date) + return components.year == currentComponents.year && components.month == currentComponents.month + } + state.monthRecordCount = currentMonthDays.filter { !$0.imageURLs.isEmpty }.count + state.monthDayCount = currentMonthDays.count + } + + private func selectedDate(for monthDate: Date) -> Date { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + if calendar.isDate(monthDate, equalTo: today, toGranularity: .month) { + return today + } + return calendar.date( + from: calendar.dateComponents([.year, .month], from: monthDate) + ) ?? monthDate + } } // MARK: - State & Input @@ -200,16 +335,28 @@ public final class MonthlyCalendarViewModel { extension MonthlyCalendarViewModel { public struct State: Equatable { public internal(set) var currentDisplayDate: Date + public internal(set) var selectedDate: Date = Date() var monthDays: [MonthlyCalendarDay] = [] var numberOfWeeks: Int = 5 var monthYearText: String = "" var nickname: String? = nil + var selectedRecords: [FoodRecord] = [] + var processingRecords: [FoodRecord] = [] + var hasFoodPhotos: Bool = false + var monthRecordCount: Int = 0 + var monthDayCount: Int = 0 public static func == (lhs: Self, rhs: Self) -> Bool { lhs.monthDays == rhs.monthDays && lhs.numberOfWeeks == rhs.numberOfWeeks && lhs.monthYearText == rhs.monthYearText && lhs.nickname == rhs.nickname + && lhs.selectedDate == rhs.selectedDate + && lhs.selectedRecords == rhs.selectedRecords + && lhs.processingRecords == rhs.processingRecords + && lhs.hasFoodPhotos == rhs.hasFoodPhotos + && lhs.monthRecordCount == rhs.monthRecordCount + && lhs.monthDayCount == rhs.monthDayCount } } @@ -220,10 +367,18 @@ extension MonthlyCalendarViewModel { case selectDay(Date) case refreshCurrentMonth case updateMonth(Date) + case requestPhotoAuthorization + case saveSelectedPhotos([any ImageAssetable]) + case handlePushNotification(AnalysisResultNotification) + case navigateToSelectedDateDetail } public enum Event { case navigateToDetail(date: Date, records: [FoodRecord]) case showError(Error) + case photoAuthorizationDenied + case uploadCompleted(date: Date, mealType: MealType) + case saveFailed(Error) + case requestAppReview } } diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/BottomContentView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/BottomContentView.swift deleted file mode 100644 index 3b4dabd5..00000000 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/BottomContentView.swift +++ /dev/null @@ -1,259 +0,0 @@ -// -// BottomContentView.swift -// Presentation -// - -import Combine -import Domain -import SnapKit -import UIKit - -/// 하단 영역 (+버튼 또는 기록된 카드 스택) -final class BottomContentView: UIView { - - // MARK: - Constants - - private enum Constants { - static let containerCornerRadius: CGFloat = 24 - static let containerBorderWidth: CGFloat = 1 - static let containerHorizontalInset: CGFloat = 0 - static let containerBackgroundAlpha: CGFloat = 0.05 - static let cardHorizontalInset: CGFloat = 30 - static let pendingCardHorizontalInset: CGFloat = 15 - static let cardAspectRatio: CGFloat = 1.0 - } - - // MARK: - Publishers - - var addButtonTapPublisher: AnyPublisher { - addButtonTapSubject.eraseToAnyPublisher() - } - - var cardStackTapPublisher: AnyPublisher { - cardStackTapSubject.eraseToAnyPublisher() - } - - var processingTapPublisher: AnyPublisher { - processingTapSubject.eraseToAnyPublisher() - } - - private let addButtonTapSubject = PassthroughSubject() - private let cardStackTapSubject = PassthroughSubject() - private let processingTapSubject = PassthroughSubject() - private var cancellables = Set() - - // MARK: - UI Components - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .sd900 - view.layer.cornerRadius = Constants.containerCornerRadius - view.layer.borderWidth = Constants.containerBorderWidth - view.layer.borderColor = UIColor.sd800.cgColor - return view - }() - - // Empty State UI (when no records) - private var emptyStateView: EmptyFoodRecordView? - - // Card Stack UI (when records exist) - private let cardStackStateView: UIView = { - let view = UIView() - view.isHidden = true - view.clipsToBounds = false - return view - }() - - private var cardStackView: FoodRecordCardStackView? - - // Processing State UI - private let processingStateView: UIView = { - let view = UIView() - view.isHidden = true - view.clipsToBounds = false - return view - }() - - private var processingCardView: PendingFoodRecordCardView? - private var currentProcessingDate: Date? - private var currentState: State? - - // MARK: - Init - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - setupConstraints() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupUI() { - addSubview(containerView) - - // Card Stack State - containerView.addSubview(cardStackStateView) - - // Processing State - containerView.addSubview(processingStateView) - } - - private func setupConstraints() { - containerView.snp.makeConstraints { - $0.edges.equalToSuperview().inset( - UIEdgeInsets( - top: 0, left: Constants.containerHorizontalInset, bottom: 0, - right: Constants.containerHorizontalInset)) - } - - // Card Stack State Constraints - cardStackStateView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - // Processing State Constraints - processingStateView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - } - - // MARK: - Configuration - - func configure(state: State) { - guard currentState != state else { return } - currentState = state - - switch state { - case .empty(let hasPhotos): - showEmptyState(hasPhotos: hasPhotos) - case .processing(let records): - if let first = records.first { - showProcessingState(record: first) - } - case .recorded(let records): - if let first = records.first { - showCardStackState(record: first, totalCount: records.count) - } - } - } - - private func showProcessingState(record: FoodRecord) { - emptyStateView?.removeFromSuperview() - emptyStateView = nil - cardStackStateView.isHidden = true - processingStateView.isHidden = false - showContainerStyle(false) - cancellables.removeAll() - currentProcessingDate = record.date - - // 기존 processing 카드 제거 - processingCardView?.removeFromSuperview() - - // 새로 생성 - let imageURL = record.photos.first?.imageURL - let newProcessingCardView = PendingFoodRecordCardView(imageURL: imageURL) - processingStateView.addSubview(newProcessingCardView) - processingCardView = newProcessingCardView - - newProcessingCardView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.horizontalEdges.equalToSuperview().inset(Constants.pendingCardHorizontalInset) - $0.height.equalTo(newProcessingCardView.snp.width) - } - - // 탭 제스처 - let tapGesture = UITapGestureRecognizer( - target: self, action: #selector(processingCardTapped)) - newProcessingCardView.addGestureRecognizer(tapGesture) - } - - @objc private func processingCardTapped() { - guard let date = currentProcessingDate else { return } - processingTapSubject.send(date) - } - - private func showContainerStyle(_ show: Bool) { - containerView.backgroundColor = show ? .sd900 : .clear - containerView.layer.borderWidth = show ? Constants.containerBorderWidth : 0 - } - - private func showEmptyState(hasPhotos: Bool) { - cardStackStateView.isHidden = true - processingStateView.isHidden = true - showContainerStyle(false) - - // 기존 empty 뷰 제거 - emptyStateView?.removeFromSuperview() - cancellables.removeAll() - - // 새로 생성 - let newEmptyView = EmptyFoodRecordView( - text: hasPhotos - ? "오늘의 음식 사진을 추가해보세요" - : "기록 가능한 음식 사진이 없어요", - style: hasPhotos ? .addable : .unavailable - ) - containerView.addSubview(newEmptyView) - emptyStateView = newEmptyView - - newEmptyView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - // 사진이 있을 때만 탭 Publisher 바인딩 - if hasPhotos { - newEmptyView.addButtonTapPublisher - .sink { [weak self] in - self?.addButtonTapSubject.send() - } - .store(in: &cancellables) - } - } - - private func showCardStackState(record: FoodRecord, totalCount: Int) { - emptyStateView?.removeFromSuperview() - emptyStateView = nil - cardStackStateView.isHidden = false - processingStateView.isHidden = true - processingCardView?.removeFromSuperview() - showContainerStyle(false) - - // 기존 카드스택뷰 제거 - cardStackView?.removeFromSuperview() - cancellables.removeAll() - - // 새로 생성 - let newCardStackView = FoodRecordCardStackView(record: record, totalCount: totalCount) - cardStackStateView.addSubview(newCardStackView) - cardStackView = newCardStackView - - newCardStackView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.horizontalEdges.equalToSuperview().inset(Constants.cardHorizontalInset) - $0.height.equalTo(newCardStackView.snp.width).multipliedBy(Constants.cardAspectRatio) - } - - // Publisher 바인딩 - newCardStackView.cardTapPublisher - .sink { [weak self] record in - self?.cardStackTapSubject.send(record) - } - .store(in: &cancellables) - } - -} - -// MARK: - State - -extension BottomContentView { - enum State: Equatable { - case empty(hasPhotos: Bool) - case processing([FoodRecord]) - case recorded([FoodRecord]) - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/CoachmarkOverlayView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/CoachmarkOverlayView.swift deleted file mode 100644 index cd059e9b..00000000 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/CoachmarkOverlayView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// CoachmarkOverlayView.swift -// Presentation -// - -import UIKit -import Combine -import SnapKit -import DesignSystem - -public final class CoachmarkOverlayView: UIView { - private let didDismissSubject = PassthroughSubject() - - public var didDismissPublisher: AnyPublisher { - didDismissSubject.eraseToAnyPublisher() - } - - private let imageView: UIImageView = { - let imageView = UIImageView(image: DesignSystemAsset.coachmarkOverlay.image) - imageView.contentMode = .scaleAspectFit - return imageView - }() - - public init() { - super.init(frame: .zero) - backgroundColor = UIColor.black.withAlphaComponent(0.6) - setupImageView() - setupTapGesture() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -private extension CoachmarkOverlayView { - func setupImageView() { - addSubview(imageView) - imageView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - } - - func setupTapGesture() { - let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tap) - } - - @objc func handleTap() { - didDismissSubject.send() - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift deleted file mode 100644 index 7533aa77..00000000 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// DayCellView.swift -// Presentation -// - -import DesignSystem -import Domain -import SnapKit -import UIKit - -/// 주간 캘린더의 개별 날짜 셀 -final class DayCellView: UIView { - - private enum Constants { - static let cornerRadius: CGFloat = 8 - static let borderWidth: CGFloat = 2 - } - - var tapHandler: ((Date) -> Void)? - private var date: Date? - - // MARK: - UI Components - - private let containerView: UIView = { - let view = UIView() - view.layer.cornerRadius = 12 - 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 - return label - }() - - private let dayNumberLabel: UILabel = { - let label = UILabel() - label.textAlignment = .center - return label - }() - - private let recordIndicator: UIView = { - let view = UIView() - view.backgroundColor = DesignSystemAsset.primary.color - view.layer.cornerRadius = 3 - view.isHidden = true - return view - }() - - // MARK: - Init - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - setupGesture() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - 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() - $0.size.equalTo(4) - } - - dayOfWeekLabel.snp.makeConstraints { - $0.top.equalTo(recordIndicator.snp.bottom).offset(8) - $0.centerX.equalToSuperview() - } - - dayNumberLabel.snp.makeConstraints { - $0.top.equalTo(dayOfWeekLabel.snp.bottom).offset(4) - $0.bottom.equalToSuperview().offset(-8) - $0.centerX.equalToSuperview() - } - } - - private func setupGesture() { - let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - addGestureRecognizer(tap) - } - - // MARK: - Configuration - - func configure(with dayData: WeeklyCalendarDay, isSelected: Bool) { - self.date = dayData.date - recordIndicator.isHidden = dayData.records.isEmpty - - applyStyle( - dayOfWeek: dayData.dayOfWeek, - dayNumber: dayData.dayNumber, - isToday: dayData.isToday, - isFuture: dayData.isFuture, - isSelected: isSelected - ) - } - - private func applyStyle(dayOfWeek: String, dayNumber: String, isToday: Bool, isFuture: Bool, isSelected: Bool) { - containerView.backgroundColor = .clear - containerView.layer.cornerRadius = Constants.cornerRadius - - if isSelected { - gradientBackgroundView.isHidden = false - gradientBackgroundView.alpha = 1 - containerView.applyGlow( - glowColor: .primary, - borderColor: UIColor.white.withAlphaComponent(0.3), - cornerRadius: Constants.cornerRadius - ) - dayOfWeekLabel.setText(dayOfWeek, style: .p12, color: .white) - dayNumberLabel.setText(dayNumber, style: .p12, color: .white) - recordIndicator.backgroundColor = .white - } else if isFuture { - 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 { - 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 { - gradientBackgroundView.isHidden = true - containerView.removeGlow() - dayOfWeekLabel.setText(dayOfWeek, style: .p12, color: .gray300) - dayNumberLabel.setText(dayNumber, style: .p12, color: .white) - recordIndicator.backgroundColor = DesignSystemAsset.primary.color - } - } - - // MARK: - Actions - - @objc private func handleTap() { - guard let date = date else { return } - tapHandler?(date) - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeekGridView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeekGridView.swift deleted file mode 100644 index 19ea1fdf..00000000 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeekGridView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// WeekGridView.swift -// Presentation -// - -import Combine -import DesignSystem -import Domain -import SnapKit -import UIKit - -/// 7일 그리드 뷰 -final class WeekGridView: UIView { - - // MARK: - Publisher - - var dateTapPublisher: AnyPublisher { - dateTapSubject.eraseToAnyPublisher() - } - - private let dateTapSubject = PassthroughSubject() - - // MARK: - UI Components - - private let containerView: UIView = { - let view = UIView() - view.layer.borderColor = UIColor.gray900.cgColor - view.layer.borderWidth = 1 - view.layer.cornerRadius = 16 - return view - }() - - private let stackView: UIStackView = { - let sv = UIStackView() - sv.axis = .horizontal - sv.distribution = .fillEqually - sv.spacing = 4 - return sv - }() - - private var dayCells: [DayCellView] = [] - private var days: [WeeklyCalendarDay] = [] - - // MARK: - Init - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupUI() { - addSubview(containerView) - containerView.addSubview(stackView) - - containerView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - stackView.snp.makeConstraints { - $0.edges.equalToSuperview().inset(12) - } - - // 7개의 DayCell 생성 - (0..<7).forEach { index in - let cell = DayCellView() - cell.tapHandler = { [weak self] date in - guard let self, !self.days[index].isFuture else { return } - self.dateTapSubject.send(date) - } - dayCells.append(cell) - stackView.addArrangedSubview(cell) - } - } - - // MARK: - Public Methods - - func configure(with days: [WeeklyCalendarDay], selectedDate: Date) { - guard days.count == 7 else { return } - self.days = days - let calendar = Calendar.current - - zip(dayCells, days).forEach { cell, dayData in - let isSelected = calendar.isDate(selectedDate, inSameDayAs: dayData.date) - cell.configure(with: dayData, isSelected: isSelected) - } - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift deleted file mode 100644 index 049afd1f..00000000 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// WeeklyCalendarHeaderView.swift -// Presentation -// - -import Combine -import DesignSystem -import SnapKit -import UIKit - -/// 주간 캘린더 상단 헤더 (월 표시 + 좌우 네비게이션) -final class WeeklyCalendarHeaderView: UIView { - - // MARK: - Constants - - private enum Constants { - static let headerHeight: CGFloat = 44 - static let navigationSpacing: CGFloat = 8 - static let buttonSize: CGFloat = 44 - } - - // MARK: - Publishers - - var previousTapPublisher: AnyPublisher { - previousTapSubject.eraseToAnyPublisher() - } - - var nextTapPublisher: AnyPublisher { - nextTapSubject.eraseToAnyPublisher() - } - - private let previousTapSubject = PassthroughSubject() - private let nextTapSubject = PassthroughSubject() - - // MARK: - UI Components - - private let monthLabel: UILabel = { - let label = UILabel() - label.textColor = .white - label.textAlignment = .left - return label - }() - - private let previousButton: UIButton = { - let button = UIButton() - button.setImage(DesignSystemAsset.iconNext.image.withHorizontallyFlippedOrientation(), for: .normal) - button.tintColor = .white - return button - }() - - private let nextButton: UIButton = { - let button = UIButton() - button.setImage(DesignSystemAsset.iconNext.image, for: .normal) - button.tintColor = .white - return button - }() - - private let navigationStack: UIStackView = { - let sv = UIStackView() - sv.axis = .horizontal - sv.spacing = Constants.navigationSpacing - return sv - }() - - // MARK: - Init - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - setupConstraints() - setupActions() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Setup - - private func setupUI() { - addSubview(monthLabel) - addSubview(navigationStack) - navigationStack.addArrangedSubview(previousButton) - navigationStack.addArrangedSubview(nextButton) - } - - private func setupConstraints() { - snp.makeConstraints { - $0.height.equalTo(Constants.headerHeight) - } - - monthLabel.snp.makeConstraints { - $0.leading.equalToSuperview() - $0.centerY.equalToSuperview() - } - - navigationStack.snp.makeConstraints { - $0.trailing.equalToSuperview() - $0.centerY.equalToSuperview() - } - - previousButton.snp.makeConstraints { - $0.size.equalTo(Constants.buttonSize) - } - - nextButton.snp.makeConstraints { - $0.size.equalTo(Constants.buttonSize) - } - } - - private func setupActions() { - previousButton.addTarget(self, action: #selector(previousTapped), for: .touchUpInside) - nextButton.addTarget(self, action: #selector(nextTapped), for: .touchUpInside) - } - - // MARK: - Public Methods - - func setMonthText(_ text: String) { - monthLabel.setText(text, style: .p18, color: .gray050) - } - - func setNextButtonEnabled(_ isEnabled: Bool) { - nextButton.isEnabled = isEnabled - nextButton.alpha = isEnabled ? 1.0 : 0.3 - } - - // MARK: - Actions - - @objc private func previousTapped() { - previousTapSubject.send() - } - - @objc private func nextTapped() { - nextTapSubject.send() - } -} diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift deleted file mode 100644 index 6fc1e49b..00000000 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift +++ /dev/null @@ -1,373 +0,0 @@ -// -// WeeklyCalendarViewModel.swift -// Presentation -// - -import Combine -import Domain -import Foundation -import UIKit - -// MARK: - ViewModel - -public final class WeeklyCalendarViewModel { - // 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 calendar: Calendar - private let stateSubject: CurrentValueSubject - private let eventSubject = PassthroughSubject() - private var currentWeekBaseDate: Date - private var cancellables = Set() - - // MARK: - Dependencies - - 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 - - // MARK: - Init - - public init( - requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, - loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, - saveFoodRecordUseCase: SaveFoodRecordUseCase, - pushNotificationObserver: any PushNotificationObserving, - getNicknameUseCase: GetNicknameUseCase, - coachmarkStorage: any CoachmarkStoring, - checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase - ) { - self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase - self.loadWeeklyCalendarDataUseCase = loadWeeklyCalendarDataUseCase - self.saveFoodRecordUseCase = saveFoodRecordUseCase - self.pushNotificationObserver = pushNotificationObserver - self.getNicknameUseCase = getNicknameUseCase - self.coachmarkStorage = coachmarkStorage - self.checkAppReviewEligibilityUseCase = checkAppReviewEligibilityUseCase - - let cal = Calendar.current - self.calendar = cal - - let today = cal.startOfDay(for: Date()) - self.currentWeekBaseDate = today - - let (weekStart, _) = cal.weekRange(for: today) - let weekDates = cal.weekDates(from: weekStart) - let placeholderWeekDays = weekDates.map { dayDate in - WeeklyCalendarDay( - date: dayDate, - dayOfWeek: dayDate.formatDayOfWeek(), - dayNumber: dayDate.formatDayNumber(calendar: cal), - isToday: cal.isDateInToday(dayDate), - isFuture: cal.startOfDay(for: dayDate) > today, - records: [] - ) - } - - self.stateSubject = CurrentValueSubject(State(selectedDate: today)) - state.weekDays = placeholderWeekDays - state.monthText = today.formatMonthText() - - setupBindings() - input.send(.loadNickname) - } - - // 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) - - pushNotificationObserver.analysisResultPublisher - .sink { [weak self] notification in - self?.input.send(.handlePushNotification(notification)) - } - .store(in: &cancellables) - } - - @MainActor - private func handleInput(_ action: Input) async { - switch action { - case .loadNickname: - state.nickname = getNicknameUseCase.execute() - - case .loadInitialData: - await requestPhotoAuthorizationIfNeeded() - await loadWeekData(for: currentWeekBaseDate) - await updateDateContent(for: state.selectedDate) - - case .requestPhotoAuthorization: - let status = await requestPhotoAuthorizationUseCase.execute() - if status == .denied || status == .restricted { - eventSubject.send(.photoAuthorizationDenied) - } - - case .goToPreviousWeek: - currentWeekBaseDate = calendar.previousWeek(from: currentWeekBaseDate) - updateSelectedDateToSameWeekday(in: currentWeekBaseDate) - await loadWeekData(for: currentWeekBaseDate) - await updateDateContent(for: state.selectedDate) - - case .goToNextWeek: - let nextWeek = calendar.nextWeek(from: currentWeekBaseDate) - let today = calendar.startOfDay(for: Date()) - let (nextWeekStart, _) = calendar.weekRange(for: nextWeek) - guard nextWeekStart <= today else { return } - - currentWeekBaseDate = nextWeek - updateSelectedDateToSameWeekday(in: currentWeekBaseDate) - await loadWeekData(for: currentWeekBaseDate) - await updateDateContent(for: state.selectedDate) - - case .selectDate(let date): - if !calendar.isDate(state.selectedDate, inSameDayAs: date) { - state.selectedDate = date - await moveToWeekIfNeeded(for: date) - await updateDateContent(for: date) - } - - case .saveSelectedPhotos(let assets): - await savePhotosAsRecord(assets) - - case .handlePushNotification(let notification): - await handlePushNotification(notification) - - case .refreshData(let date): - if let date { - state.selectedDate = date - await moveToWeekIfNeeded(for: date) - } - await loadWeekData(for: currentWeekBaseDate) - await updateDateContent(for: state.selectedDate) - - case .viewDidAppear: - if !coachmarkStorage.get() { - state.shouldShowCoachmark = true - } - - case .dismissCoachmark: - coachmarkStorage.set() - state.shouldShowCoachmark = false - } - } - - // MARK: - Private Methods - - @MainActor - private func loadWeekData(for date: Date) async { - state.isLoading = true - defer { state.isLoading = false } - - do { - let weekData = try await loadWeeklyCalendarDataUseCase.loadWeekData(for: date) - state.weekDays = weekData.weekDays - state.monthText = weekData.monthText - state.canGoToNextWeek = canGoToNextWeek(from: currentWeekBaseDate) - } catch { - eventSubject.send(.loadFailed(error)) - } - } - - @MainActor - private func updateDateContent(for date: Date) async { - let startOfDay = calendar.startOfDay(for: date) - let allRecords = state.weekDays.records(for: startOfDay, calendar: calendar) - - let completedRecords = allRecords.filter { !$0.isProcessing } - let processingRecords = allRecords.filter { $0.isProcessing } - - var hasFoodPhotos = false - if completedRecords.isEmpty && processingRecords.isEmpty { - hasFoodPhotos = await checkFoodPhotosExist(for: date) - } - - state.dateContent = DateContent( - records: completedRecords, - processingRecords: processingRecords, - hasFoodPhotos: hasFoodPhotos - ) - } - - private func checkFoodPhotosExist(for date: Date) async -> Bool { - guard requestPhotoAuthorizationUseCase.isAuthorized() else { - return false - } - do { - let photos = try await loadWeeklyCalendarDataUseCase.loadPhotos(for: date) - return !photos.isEmpty - } catch { - return false - } - } - - @MainActor - private func savePhotosAsRecord(_ assets: [any ImageAssetable]) async { - guard !assets.isEmpty else { return } - - do { - let results = try await saveFoodRecordUseCase.execute( - from: assets, - date: state.selectedDate - ) - let mealType = results.first?.mealType ?? .breakfast - eventSubject.send(.uploadCompleted(date: state.selectedDate, mealType: mealType)) - await loadWeekData(for: currentWeekBaseDate) - await updateDateContent(for: state.selectedDate) - } catch { - eventSubject.send(.saveFailed(error)) - } - } - - /// 선택된 날짜가 현재 표시 중인 주 범위 밖이면 해당 주로 이동 - private func moveToWeekIfNeeded(for date: Date) async { - let (weekStart, weekEnd) = calendar.weekRange(for: currentWeekBaseDate) - let dateStart = calendar.startOfDay(for: date) - if !(weekStart...weekEnd).contains(dateStart) { - currentWeekBaseDate = date - await loadWeekData(for: currentWeekBaseDate) - } - } - - /// 주간 이동 시 같은 요일로 선택 날짜 업데이트 - private func updateSelectedDateToSameWeekday(in weekBaseDate: Date) { - let currentWeekday = calendar.component(.weekday, from: state.selectedDate) - - // 새 주의 시작일(일요일) 찾기 - let weekStart = - calendar.date( - from: calendar.dateComponents( - [.yearForWeekOfYear, .weekOfYear], from: weekBaseDate) - ) ?? weekBaseDate - - // 같은 요일로 이동 (weekday: 1=일, 2=월, ...) - if let newSelectedDate = calendar.date( - byAdding: .day, value: currentWeekday - 1, to: weekStart) - { - let today = calendar.startOfDay(for: Date()) - state.selectedDate = newSelectedDate > today ? today : newSelectedDate - } - } - - private func requestPhotoAuthorizationIfNeeded() async { - let status = requestPhotoAuthorizationUseCase.currentStatus() - if status == .notDetermined { - _ = await requestPhotoAuthorizationUseCase.execute() - } - } - - /// 사진 추가 전 권한 체크 - public func checkPhotoAuthorizationForAddingPhoto() -> Bool { - requestPhotoAuthorizationUseCase.isAuthorized() - } - - // MARK: - Push Notification - - private func handlePushNotification(_ notification: AnalysisResultNotification) async { - let notificationDate = calendar.startOfDay(for: notification.diaryDate) - let selectedDate = calendar.startOfDay(for: state.selectedDate) - let (weekStart, weekEnd) = calendar.weekRange(for: currentWeekBaseDate) - let currentWeekRange = weekStart...weekEnd - - if currentWeekRange.contains(notificationDate) { - await loadWeekData(for: currentWeekBaseDate) - } - - if notificationDate == selectedDate { - await updateDateContent(for: state.selectedDate) - } - - if checkAppReviewEligibilityUseCase.execute() { - eventSubject.send(.requestAppReview) - } - } -} - -// MARK: - State -extension WeeklyCalendarViewModel { - public struct State: Equatable { - fileprivate static var foodProbabilityThreshold: Float { 0.6 } - - public internal(set) var weekDays: [WeeklyCalendarDay] = [] - public internal(set) var selectedDate: Date = Date() - public internal(set) var monthText: String = "" - public internal(set) var isLoading: Bool = false - 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 { - case loadNickname - case loadInitialData - case requestPhotoAuthorization - case goToPreviousWeek - case goToNextWeek - case selectDate(Date) - case saveSelectedPhotos([any ImageAssetable]) - case handlePushNotification(AnalysisResultNotification) - case refreshData(Date? = nil) - case viewDidAppear - case dismissCoachmark - } - - public enum Event { - case photoAuthorizationDenied - case uploadCompleted(date: Date, mealType: MealType) - case saveFailed(Error) - case loadFailed(Error) - case requestAppReview - } -} - -// MARK: - DateContent - -extension WeeklyCalendarViewModel { - public struct DateContent: Equatable { - public let records: [FoodRecord] - public let processingRecords: [FoodRecord] - public let hasFoodPhotos: Bool - } -} - -// MARK: - Public Methods - -extension WeeklyCalendarViewModel { - /// 다음 주로 이동 가능 여부 - public func canGoToNextWeek(from date: Date) -> Bool { - let calendar = Calendar.current - let nextWeek = calendar.date(byAdding: .weekOfYear, value: 1, to: date) ?? date - let today = calendar.startOfDay(for: Date()) - let (nextWeekStart, _) = calendar.weekRange(for: nextWeek) - return nextWeekStart <= today - } - -} diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift deleted file mode 100644 index 878baf2f..00000000 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ /dev/null @@ -1,351 +0,0 @@ -// -// WeeklyCalendarViewController.swift -// Presentation -// - -import Combine -import DesignSystem -import Domain -import SnapKit -import StoreKit -import UIKit - -private enum Constants { - static let horizontalInset: CGFloat = 20 -} - -public final class WeeklyCalendarViewController: UIViewController { - - // MARK: - Dependencies - - private let viewModel: WeeklyCalendarViewModel - - // MARK: - Flow - - private let flowSubject = PassthroughSubject() - public var flowPublisher: AnyPublisher { - flowSubject.eraseToAnyPublisher() - } - - // MARK: - UI Components - - private let scrollView: UIScrollView = { - let sv = UIScrollView() - sv.showsVerticalScrollIndicator = false - return sv - }() - - private let contentView = UIView() - - private lazy var recordPromptHeaderView = RecordPromptHeaderView() - private let headerView = WeeklyCalendarHeaderView() - private let weekGridView = WeekGridView() - private let bottomContentView = BottomContentView() - - private lazy var containerStackView: UIStackView = { - let sv = UIStackView(arrangedSubviews: [weekGridView, bottomContentView]) - sv.axis = .vertical - sv.spacing = 24 - return sv - }() - - // MARK: - State - - private var cancellables = Set() - - // MARK: - Init - - public init( - viewModel: WeeklyCalendarViewModel - ) { - self.viewModel = viewModel - 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() - setupBindings() - - 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) - } - - // MARK: - Setup - - private func setupUI() { - view.backgroundColor = .sdBase - - view.addSubview(recordPromptHeaderView) - view.addSubview(headerView) - view.addSubview(containerStackView) - } - - private func setupConstraints() { - recordPromptHeaderView.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide).offset(28) - $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) - } - - headerView.snp.makeConstraints { - $0.top.equalTo(recordPromptHeaderView.snp.bottom).offset(32) - $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) - } - - containerStackView.snp.makeConstraints { - $0.top.equalTo(headerView.snp.bottom).offset(14) - $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) - $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-34) - } - } - - private func setupBindings() { - // Input: View → ViewModel - headerView.previousTapPublisher - .sink { [weak self] in - self?.viewModel.input.send(.goToPreviousWeek) - } - .store(in: &cancellables) - - headerView.nextTapPublisher - .sink { [weak self] in - self?.viewModel.input.send(.goToNextWeek) - } - .store(in: &cancellables) - - weekGridView.dateTapPublisher - .sink { [weak self] date in - self?.viewModel.input.send(.selectDate(date)) - } - .store(in: &cancellables) - - // 하단 + 버튼 탭 → 권한 체크 후 이미지 피커 표시 - bottomContentView.addButtonTapPublisher - .sink { [weak self] in - self?.handleAddButtonTap() - } - .store(in: &cancellables) - - // Foreground 복귀 시 데이터 갱신 - NotificationCenter.default.publisher(for: UIScene.willEnterForegroundNotification) - .sink { [weak self] _ in - self?.viewModel.input.send(.refreshData()) - } - .store(in: &cancellables) - - // Output: ViewModel → View (State 기반) - viewModel.statePublisher - .map(\.nickname) - .compactMap { $0 } - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] nickname in - self?.recordPromptHeaderView.configure(nickname: nickname) - } - .store(in: &cancellables) - - viewModel.statePublisher - .map(\.monthText) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] text in - self?.headerView.setMonthText(text) - } - .store(in: &cancellables) - - viewModel.statePublisher - .map(\.canGoToNextWeek) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] canGoNext in - self?.headerView.setNextButtonEnabled(canGoNext) - } - .store(in: &cancellables) - - viewModel.statePublisher - .map { (weekDays: $0.weekDays, selectedDate: $0.selectedDate) } - .removeDuplicates(by: ==) - .receive(on: DispatchQueue.main) - .sink { [weak self] days, selectedDate in - self?.weekGridView.configure(with: days, selectedDate: selectedDate) - } - .store(in: &cancellables) - - viewModel.statePublisher - .compactMap(\.dateContent) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] content in - guard let self else { return } - let state: BottomContentView.State = - if !content.records.isEmpty { - .recorded(content.records) - } else if !content.processingRecords.isEmpty { - .processing(content.processingRecords) - } else { - .empty(hasPhotos: content.hasFoodPhotos) - } - - bottomContentView.configure(state: state) - } - .store(in: &cancellables) - - // 카드 스택 탭 → 상세 화면으로 이동 - bottomContentView.cardStackTapPublisher - .sink { [weak self] record in - self?.navigateToDetail(for: record.date) - } - .store(in: &cancellables) - - // 프로세싱 카드 탭 → 상세 화면으로 이동 - bottomContentView.processingTapPublisher - .sink { [weak self] date in - self?.navigateToDetail(for: date) - } - .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) - .sink { [weak self] event in - switch event { - case .photoAuthorizationDenied: - self?.showPhotoAuthorizationDeniedAlert() - case .uploadCompleted(let date, let mealType): - self?.navigateToDetail(for: date, scrollTo: mealType, shouldPopToRoot: true) - case .saveFailed(let error): - self?.showSaveErrorAlert(error) - case .loadFailed(let error): - self?.showLoadErrorAlert(error) - case .requestAppReview: - self?.requestAppReview() - } - } - .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: - Actions - - private func handleAddButtonTap() { - let date = viewModel.state.selectedDate - flowSubject.send(.pushImagePicker( - ImagePickerSceneInput(date: date) { [weak self] assets in - self?.viewModel.input.send(.saveSelectedPhotos(assets)) - } - )) - } - - private func showPhotoAuthorizationDeniedAlert() { - let alert = UIAlertController( - title: "사진 접근 권한 필요", - message: "음식 사진을 기록하려면 사진 라이브러리 접근 권한이 필요합니다. 설정에서 권한을 허용해 주세요.", - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "취소", style: .cancel)) - alert.addAction( - UIAlertAction(title: "설정으로 이동", style: .default) { _ in - if let settingsURL = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(settingsURL) - } - }) - - present(alert, animated: true) - } - - // MARK: - Navigation - - private func navigateToDetail( - for date: Date, - scrollTo mealType: MealType? = nil, - shouldPopToRoot: Bool = false - ) { - let records = viewModel.state.weekDays.records(for: date) - let input = DetailSceneInput( - date: date, - records: records, - scrollToMealType: mealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: { [weak self] date in - self?.viewModel.input.send(.refreshData(date)) - } - ) - flowSubject.send(.pushDetail(input)) - } - - 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 requestAppReview() { - guard let windowScene = view.window?.windowScene else { return } - AppStore.requestReview(in: windowScene) - } -} - diff --git a/FoodDiary/Presentation/Sources/Components/FoodRecordCardView.swift b/FoodDiary/Presentation/Sources/Components/FoodRecordCardView.swift index 43f1e926..a4af4f32 100644 --- a/FoodDiary/Presentation/Sources/Components/FoodRecordCardView.swift +++ b/FoodDiary/Presentation/Sources/Components/FoodRecordCardView.swift @@ -16,7 +16,7 @@ public final class FoodRecordCardView: UIView { private enum Constants { static let cornerRadius: CGFloat = 20 - static let imageInset: CGFloat = 4 + static let imageInset: CGFloat = 0 static let badgeTopInset: CGFloat = 16 static let badgeLeadingInset: CGFloat = 16 static let badgeSpacing: CGFloat = 4 @@ -27,6 +27,8 @@ public final class FoodRecordCardView: UIView { // MARK: - State private let imageURL: URL? + private let showsBadges: Bool + private let timeText: String private var isConfigured = false // MARK: - UI Components @@ -63,6 +65,8 @@ public final class FoodRecordCardView: UIView { public init(time: String, district: String?, imageURL: URL?) { self.imageURL = imageURL + self.showsBadges = !time.isEmpty || district != nil + self.timeText = time self.timeBadge = PillBadgeView(text: time) self.locationBadge = district.map { PillBadgeView(text: $0) } super.init(frame: .zero) @@ -87,9 +91,13 @@ public final class FoodRecordCardView: UIView { private func setupUI() { addSubview(containerView) containerView.addSubview(foodImageView) - containerView.addSubview(badgeStackView) + if showsBadges { + containerView.addSubview(badgeStackView) + } - badgeStackView.addArrangedSubview(timeBadge) + if !timeText.isEmpty { + badgeStackView.addArrangedSubview(timeBadge) + } if let locationBadge { badgeStackView.addArrangedSubview(locationBadge) } @@ -104,9 +112,11 @@ public final class FoodRecordCardView: UIView { $0.edges.equalToSuperview().inset(Constants.imageInset) } - badgeStackView.snp.makeConstraints { - $0.top.equalTo(foodImageView).offset(Constants.badgeTopInset) - $0.leading.equalTo(foodImageView).offset(Constants.badgeLeadingInset) + if showsBadges { + badgeStackView.snp.makeConstraints { + $0.top.equalTo(foodImageView).offset(Constants.badgeTopInset) + $0.leading.equalTo(foodImageView).offset(Constants.badgeLeadingInset) + } } } diff --git a/FoodDiary/Presentation/Sources/Components/PillBadgeView.swift b/FoodDiary/Presentation/Sources/Components/PillBadgeView.swift index ad78e415..068916e0 100644 --- a/FoodDiary/Presentation/Sources/Components/PillBadgeView.swift +++ b/FoodDiary/Presentation/Sources/Components/PillBadgeView.swift @@ -13,7 +13,6 @@ final class PillBadgeView: UIView { // MARK: - Constants private enum Constants { - static let cornerRadius: CGFloat = 14 static let verticalInset: CGFloat = 6 static let horizontalInset: CGFloat = 12 } @@ -43,12 +42,16 @@ final class PillBadgeView: UIView { // MARK: - Setup private func setupUI() { - backgroundColor = UIColor.black.withAlphaComponent(0.65) - layer.cornerRadius = Constants.cornerRadius + backgroundColor = .detailBadgeBackground clipsToBounds = true addSubview(label) } + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } + private func setupConstraints() { label.snp.makeConstraints { $0.edges.equalToSuperview().inset( diff --git a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift index 0deb0932..4e624143 100644 --- a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift +++ b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift @@ -61,4 +61,12 @@ public extension UIColor { static let gray100 = DesignSystemAsset.gray100.color static let gray050 = DesignSystemAsset.gray050.color static let gray020 = DesignSystemAsset.gray020.color + static let calendarTileBackground = DesignSystemAsset.calendarTileBackground.color + static let detailBadgeBackground = DesignSystemAsset.detailBadgeBackground.color + static let detailBodyText = DesignSystemAsset.detailBodyText.color + static let detailCardBackground = DesignSystemAsset.detailCardBackground.color + static let detailMutedText = DesignSystemAsset.detailMutedText.color + static let detailPrimaryText = DesignSystemAsset.detailPrimaryText.color + static let detailSectionText = DesignSystemAsset.detailSectionText.color + static let detailStroke = DesignSystemAsset.detailStroke.color } diff --git a/FoodDiary/Presentation/Sources/Detail/Components/DetailDateNavigatorView.swift b/FoodDiary/Presentation/Sources/Detail/Components/DetailDateNavigatorView.swift index e57a0d4d..49a7cff6 100644 --- a/FoodDiary/Presentation/Sources/Detail/Components/DetailDateNavigatorView.swift +++ b/FoodDiary/Presentation/Sources/Detail/Components/DetailDateNavigatorView.swift @@ -24,14 +24,6 @@ final class DetailDateNavigatorView: UIView { private let previousTapSubject = PassthroughSubject() private let nextTapSubject = PassthroughSubject() - private let defaultTintColor: UIColor = { - if #available(iOS 26, *) { - return .label - } else { - return .white - } - }() - // MARK: - Constants private enum Constants { @@ -42,18 +34,13 @@ final class DetailDateNavigatorView: UIView { // MARK: - UI Components - private lazy var containerView: UIView = { - if #available(iOS 26, *) { - let effectView = UIVisualEffectView(effect: UIGlassEffect()) - effectView.layer.cornerRadius = Constants.cornerRadius - effectView.clipsToBounds = true - return effectView - } else { - let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark)) - effectView.layer.cornerRadius = Constants.cornerRadius - effectView.clipsToBounds = true - return effectView - } + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = Constants.cornerRadius + view.layer.borderWidth = Constants.borderWidth + view.layer.borderColor = UIColor.detailPrimaryText.cgColor + return view }() private let previousButton: UIButton = { @@ -103,26 +90,14 @@ final class DetailDateNavigatorView: UIView { private func setupUI() { addSubview(containerView) - let contentView: UIView - if let effectView = containerView as? UIVisualEffectView { - contentView = effectView.contentView - } else { - contentView = containerView - } - contentView.addSubview(previousButton) - contentView.addSubview(dateLabel) - contentView.addSubview(shimmerView) - contentView.addSubview(nextButton) - - if #available(iOS 26, *) { - previousButton.tintColor = .label - dateLabel.textColor = .label - nextButton.tintColor = .label - } else { - previousButton.tintColor = .white - dateLabel.textColor = .white - nextButton.tintColor = .white - } + containerView.addSubview(previousButton) + containerView.addSubview(dateLabel) + containerView.addSubview(shimmerView) + containerView.addSubview(nextButton) + + previousButton.tintColor = .detailPrimaryText + dateLabel.textColor = .detailPrimaryText + nextButton.tintColor = .detailPrimaryText } private func setupConstraints() { @@ -162,16 +137,12 @@ final class DetailDateNavigatorView: UIView { // MARK: - Public Methods func setDateText(_ text: String) { - if #available(iOS 26, *) { - dateLabel.setText(text, style: .p15, color: .label) - } else { - dateLabel.setText(text, style: .p15) - } + dateLabel.setText(text, style: .p15, color: .detailPrimaryText) } func setNextButtonEnabled(_ enabled: Bool) { nextButton.isEnabled = enabled - nextButton.tintColor = enabled ? defaultTintColor : .gray400 + nextButton.tintColor = enabled ? .detailPrimaryText : .gray400 } func setPending(_ isPending: Bool) { diff --git a/FoodDiary/Presentation/Sources/Detail/Components/MealRecordedContentView.swift b/FoodDiary/Presentation/Sources/Detail/Components/MealRecordedContentView.swift index 4469d11e..d346f8dd 100644 --- a/FoodDiary/Presentation/Sources/Detail/Components/MealRecordedContentView.swift +++ b/FoodDiary/Presentation/Sources/Detail/Components/MealRecordedContentView.swift @@ -8,105 +8,91 @@ import Domain import SnapKit import UIKit -/// 음식 기록이 있을 때 표시되는 카드 캐러셀 + 정보 영역 +/// 음식 기록이 있을 때 표시되는 카드형 콘텐츠 final class MealRecordedContentView: UIView { - // MARK: - Constants - private enum Constants { - static let cardSpacing: CGFloat = 12 + static let horizontalInset: CGFloat = 16 + static let verticalInset: CGFloat = 20 + static let topRowBottomSpacing: CGFloat = 14 static let pageControlTopSpacing: CGFloat = 8 static let pageControlHeight: CGFloat = 20 - static let infoTopSpacing: CGFloat = 16 - static let buttonSpacing: CGFloat = 10 - static let hashtagTopSpacing: CGFloat = 16 + static let textTopSpacing: CGFloat = 16 + static let hashtagTopSpacing: CGFloat = 6 + static let actionSpacing: CGFloat = 10 + static let badgeSpacing: CGFloat = 12 static let buttonImagePadding: CGFloat = 4 - static let textHorizontalInset: CGFloat = 10 - static let noteTopSpacing: CGFloat = 16 } - // MARK: - Types - private struct CardItem { let record: FoodRecord let imageURL: URL } - // MARK: - Handlers - private let onCopyTapped: ((FoodRecord) -> Void)? private let onShareTapped: ((FoodRecord) -> Void)? - // MARK: - State - private var cardItems: [CardItem] = [] private var currentRecord: FoodRecord? - // MARK: - Constraints + private var contentTopWithPageControl: Constraint? + private var contentTopWithoutPageControl: Constraint? - private var labelTrailingWithButton: Constraint? - private var labelTrailingWithoutButton: Constraint? - private var infoTopWithPageControl: Constraint? - private var infoTopWithoutPageControl: Constraint? - private var infoBottomConstraint: Constraint? + private let badgeStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = Constants.badgeSpacing + return stackView + }() - // MARK: - UI Components + private let actionStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = Constants.actionSpacing + return stackView + }() private lazy var collectionView: UICollectionView = { - let cv = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) - cv.backgroundColor = .clear - cv.showsHorizontalScrollIndicator = false - cv.isScrollEnabled = false - cv.delegate = self - cv.dataSource = self - cv.register( - DetailFoodCardCell.self, forCellWithReuseIdentifier: DetailFoodCardCell.reuseIdentifier) - return cv + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + collectionView.backgroundColor = .clear + collectionView.showsHorizontalScrollIndicator = false + collectionView.isScrollEnabled = false + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register( + DetailFoodCardCell.self, + forCellWithReuseIdentifier: DetailFoodCardCell.reuseIdentifier + ) + return collectionView }() private lazy var pageControl: UIPageControl = { - let pc = UIPageControl() - pc.currentPageIndicatorTintColor = .primary - pc.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.3) - pc.addTarget(self, action: #selector(pageControlChanged), for: .valueChanged) - return pc + let pageControl = UIPageControl() + pageControl.currentPageIndicatorTintColor = .primary + pageControl.pageIndicatorTintColor = .detailStroke + pageControl.addTarget(self, action: #selector(pageControlChanged), for: .valueChanged) + return pageControl }() - private let infoContainerView = UIView() - + private let textContainerView = UIView() private let restaurantNameLabel = UILabel() - - private let copyButton: UIButton = { - var config = UIButton.Configuration.plain() - config.image = DesignSystemAsset.iconCopy.image - .withRenderingMode(.alwaysTemplate) - config.imagePadding = Constants.buttonImagePadding - config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) - let button = UIButton(configuration: config) - button.tintColor = .white - return button - }() - - private let shareButton: UIButton = { - var config = UIButton.Configuration.plain() - config.image = UIImage(systemName: "link")? - .withConfiguration(UIImage.SymbolConfiguration(pointSize: 12, weight: .regular)) - config.imagePadding = Constants.buttonImagePadding - config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) - let button = UIButton(configuration: config) - button.tintColor = .white - return button - }() - private let hashtagLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 return label }() - private var noteView: NoteContentView? + private lazy var copyButton = ActionButton( + title: "복사", + image: DesignSystemAsset.iconCopy.image + ) - // MARK: - Init + private lazy var shareButton = ActionButton( + title: "공유", + image: DesignSystemAsset.iconShare.image + ) init( records: [FoodRecord], @@ -132,22 +118,39 @@ final class MealRecordedContentView: UIView { fatalError("init(coder:) has not been implemented") } - // MARK: - Setup - private func setupUI() { + backgroundColor = .detailCardBackground + layer.cornerRadius = 10 + clipsToBounds = true + + addSubview(badgeStackView) + addSubview(actionStackView) addSubview(collectionView) addSubview(pageControl) - addSubview(infoContainerView) + addSubview(textContainerView) + + textContainerView.addSubview(restaurantNameLabel) + textContainerView.addSubview(hashtagLabel) - infoContainerView.addSubview(restaurantNameLabel) - infoContainerView.addSubview(copyButton) - infoContainerView.addSubview(shareButton) - infoContainerView.addSubview(hashtagLabel) + actionStackView.addArrangedSubview(copyButton) + actionStackView.addArrangedSubview(shareButton) } private func setupConstraints() { + badgeStackView.snp.makeConstraints { + $0.top.equalToSuperview().inset(Constants.verticalInset) + $0.leading.equalToSuperview().inset(Constants.horizontalInset) + $0.centerY.equalTo(actionStackView) + $0.trailing.lessThanOrEqualTo(actionStackView.snp.leading).offset(-12) + } + + actionStackView.snp.makeConstraints { + $0.top.equalToSuperview().inset(Constants.verticalInset) + $0.trailing.equalToSuperview().inset(Constants.horizontalInset) + } + collectionView.snp.makeConstraints { - $0.top.equalToSuperview() + $0.top.equalTo(actionStackView.snp.bottom).offset(Constants.topRowBottomSpacing) $0.leading.trailing.equalToSuperview() $0.height.equalTo(collectionView.snp.width) } @@ -158,40 +161,23 @@ final class MealRecordedContentView: UIView { $0.height.equalTo(Constants.pageControlHeight) } - infoContainerView.snp.makeConstraints { - infoTopWithPageControl = $0.top - .equalTo(pageControl.snp.bottom).offset(Constants.infoTopSpacing).constraint - infoTopWithoutPageControl = $0.top - .equalTo(collectionView.snp.bottom).offset(Constants.infoTopSpacing).constraint - $0.leading.trailing.equalToSuperview().inset(Constants.textHorizontalInset) - $0.bottom.equalToSuperview() + textContainerView.snp.makeConstraints { + contentTopWithPageControl = $0.top.equalTo(pageControl.snp.bottom).offset(Constants.textTopSpacing).constraint + contentTopWithoutPageControl = $0.top.equalTo(collectionView.snp.bottom).offset(Constants.textTopSpacing).constraint + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview().inset(Constants.verticalInset) } - infoTopWithoutPageControl?.deactivate() + contentTopWithoutPageControl?.deactivate() restaurantNameLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview() - labelTrailingWithButton = $0.trailing - .lessThanOrEqualTo(copyButton.snp.leading).offset(-8).constraint - labelTrailingWithoutButton = $0.trailing - .lessThanOrEqualToSuperview().constraint - } - labelTrailingWithoutButton?.deactivate() - - shareButton.snp.makeConstraints { - $0.centerY.equalTo(restaurantNameLabel) - $0.trailing.equalToSuperview() - $0.height.greaterThanOrEqualTo(44) - } - - copyButton.snp.makeConstraints { - $0.centerY.equalTo(restaurantNameLabel) - $0.trailing.equalTo(shareButton.snp.leading) - $0.height.greaterThanOrEqualTo(44) + $0.top.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) } hashtagLabel.snp.makeConstraints { $0.top.equalTo(restaurantNameLabel.snp.bottom).offset(Constants.hashtagTopSpacing) - $0.leading.trailing.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) + $0.bottom.equalToSuperview() } } @@ -201,12 +187,7 @@ final class MealRecordedContentView: UIView { heightDimension: .fractionalHeight(1.0) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.contentInsets = NSDirectionalEdgeInsets( - top: 0, - leading: Constants.cardSpacing / 2, - bottom: 0, - trailing: Constants.cardSpacing / 2 - ) + item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), @@ -218,6 +199,7 @@ final class MealRecordedContentView: UIView { section.orthogonalScrollingBehavior = .groupPaging section.visibleItemsInvalidationHandler = { [weak self] _, offset, environment in let pageWidth = environment.container.contentSize.width + guard pageWidth > 0 else { return } let currentPage = Int((offset.x + pageWidth / 2) / pageWidth) self?.pageControl.currentPage = currentPage self?.updateCurrentRecord(for: currentPage) @@ -238,70 +220,59 @@ final class MealRecordedContentView: UIView { let showPageControl = cardItems.count > 1 pageControl.isHidden = !showPageControl if showPageControl { - infoTopWithPageControl?.activate() - infoTopWithoutPageControl?.deactivate() + contentTopWithPageControl?.activate() + contentTopWithoutPageControl?.deactivate() } else { - infoTopWithPageControl?.deactivate() - infoTopWithoutPageControl?.activate() + contentTopWithPageControl?.deactivate() + contentTopWithoutPageControl?.activate() } if let record = cardItems.first?.record { currentRecord = record - configureInfoSection(with: record) + configureBadges(with: record) + configureText(with: record) } } - private func configureInfoSection(with record: FoodRecord) { - let hasName = !(record.restaurantName ?? "").isEmpty - - if hasName { - restaurantNameLabel.setText(record.restaurantName!, style: .hd16, color: .white) - copyButton.isHidden = false - shareButton.isHidden = false - labelTrailingWithButton?.activate() - labelTrailingWithoutButton?.deactivate() - } else { - restaurantNameLabel.setText("수정버튼을 눌러 내용을 기록해 보세요", style: .p12, color: .gray400) - copyButton.isHidden = true - shareButton.isHidden = true - labelTrailingWithButton?.deactivate() - labelTrailingWithoutButton?.activate() + private func configureBadges(with record: FoodRecord) { + badgeStackView.arrangedSubviews.forEach { + badgeStackView.removeArrangedSubview($0) + $0.removeFromSuperview() } - let copyTitle = Typography.p12.styled("복사", color: .white) - copyButton.setAttributedTitle(copyTitle, for: .normal) - - let shareTitle = Typography.p12.styled("공유", color: .white) - shareButton.setAttributedTitle(shareTitle, for: .normal) - - let hashtagText = record.hashtags.map { "#\($0)" }.joined(separator: " ") - hashtagLabel.setText(hashtagText, style: .p12, color: .white) - hashtagLabel.isHidden = record.hashtags.isEmpty + let timeBadge = PillBadgeView(text: record.formattedShortTime) + badgeStackView.addArrangedSubview(timeBadge) - configureNoteSection(with: record) + if let district = record.district, !district.isEmpty { + let districtBadge = PillBadgeView(text: district) + badgeStackView.addArrangedSubview(districtBadge) + } } - private func configureNoteSection(with record: FoodRecord) { - noteView?.removeFromSuperview() - noteView = nil - infoBottomConstraint?.deactivate() - - let hasNote = !(record.note ?? "").isEmpty - - if hasNote { - let newNoteView = NoteContentView(note: record.note!) - infoContainerView.addSubview(newNoteView) - newNoteView.snp.makeConstraints { - $0.top.equalTo(hashtagLabel.snp.bottom).offset(Constants.noteTopSpacing) - $0.leading.trailing.equalToSuperview() - infoBottomConstraint = $0.bottom.equalToSuperview().constraint - } - noteView = newNoteView + private func configureText(with record: FoodRecord) { + if let restaurantName = record.restaurantName, !restaurantName.isEmpty { + restaurantNameLabel.attributedText = NSAttributedString( + string: restaurantName, + attributes: [ + .font: DesignSystemFontFamily.Pretendard.semiBold.font(size: 14), + .foregroundColor: UIColor.detailPrimaryText, + .kern: -0.21 + ] + ) } else { - hashtagLabel.snp.makeConstraints { - infoBottomConstraint = $0.bottom.equalToSuperview().constraint - } + restaurantNameLabel.setText("수정버튼을 눌러 내용을 기록해 보세요", style: .p12, color: .detailMutedText) } + + let hashtagText = record.hashtags.map { "#\($0)" }.joined(separator: " ") + hashtagLabel.attributedText = NSAttributedString( + string: hashtagText, + attributes: [ + .font: DesignSystemFontFamily.Pretendard.regular.font(size: 10), + .foregroundColor: UIColor.detailMutedText, + .kern: -0.15 + ] + ) + hashtagLabel.isHidden = hashtagText.isEmpty } private func updateCurrentRecord(for page: Int) { @@ -309,33 +280,28 @@ final class MealRecordedContentView: UIView { let record = cardItems[page].record guard record != currentRecord else { return } currentRecord = record - configureInfoSection(with: record) + configureBadges(with: record) + configureText(with: record) } - // MARK: - Actions - @objc private func pageControlChanged() { let indexPath = IndexPath(item: pageControl.currentPage, section: 0) collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) } @objc private func copyTapped() { - guard let record = currentRecord else { return } - onCopyTapped?(record) + guard let currentRecord else { return } + onCopyTapped?(currentRecord) } @objc private func shareTapped() { - guard let record = currentRecord else { return } - onShareTapped?(record) + guard let currentRecord else { return } + onShareTapped?(currentRecord) } } -// MARK: - UICollectionViewDataSource - extension MealRecordedContentView: UICollectionViewDataSource { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) - -> Int - { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { cardItems.count } @@ -354,15 +320,45 @@ extension MealRecordedContentView: UICollectionViewDataSource { let cardItem = cardItems[indexPath.item] cell.configure( - time: cardItem.record.formattedShortTime, - district: cardItem.record.district, + time: "", + district: nil, imageURL: cardItem.imageURL ) - return cell } } -// MARK: - UICollectionViewDelegate - extension MealRecordedContentView: UICollectionViewDelegate {} + +private final class ActionButton: UIButton { + init(title: String, image: UIImage?) { + super.init(frame: .zero) + + var configuration = UIButton.Configuration.plain() + configuration.image = image + configuration.imagePadding = 4 + configuration.contentInsets = .zero + self.configuration = configuration + tintColor = .detailPrimaryText + imageView?.contentMode = .scaleAspectFit + imageView?.snp.makeConstraints { + $0.width.height.equalTo(18) + } + setAttributedTitle( + NSAttributedString( + string: title, + attributes: [ + .font: DesignSystemFontFamily.Pretendard.regular.font(size: 12), + .foregroundColor: UIColor.detailPrimaryText, + .kern: -0.18 + ] + ), + for: .normal + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/FoodDiary/Presentation/Sources/Detail/Components/MealSectionView.swift b/FoodDiary/Presentation/Sources/Detail/Components/MealSectionView.swift index ead74f82..6aa66010 100644 --- a/FoodDiary/Presentation/Sources/Detail/Components/MealSectionView.swift +++ b/FoodDiary/Presentation/Sources/Detail/Components/MealSectionView.swift @@ -15,11 +15,11 @@ final class MealSectionView: UIView { // MARK: - Constants private enum Constants { - static let titleTopInset: CGFloat = 42 + static let titleTopInset: CGFloat = 24 static let contentTopInset: CGFloat = 16 - static let horizontalInset: CGFloat = 20 - static let textHorizontalInset: CGFloat = Self.horizontalInset + 10 - static let pendingCardHorizontalInset: CGFloat = horizontalInset + 6 + static let horizontalInset: CGFloat = 16 + static let textHorizontalInset: CGFloat = Self.horizontalInset + static let pendingCardHorizontalInset: CGFloat = horizontalInset static let emptyImageSize: CGFloat = 160 static let emptyTextTopSpacing: CGFloat = 10 } @@ -57,7 +57,7 @@ final class MealSectionView: UIView { private let titleLabel: UILabel = { let label = UILabel() - label.textColor = .white + label.textColor = .detailSectionText return label }() @@ -128,12 +128,25 @@ final class MealSectionView: UIView { case .snack: title = "야식" } - titleLabel.setText(title, style: .hd20, color: .white) + titleLabel.attributedText = NSAttributedString( + string: title, + attributes: [ + .font: DesignSystemFontFamily.Pretendard.semiBold.font(size: 16), + .foregroundColor: UIColor.detailSectionText, + .kern: -0.24 + ] + ) } private func configureEditButton() { let attributed = NSMutableAttributedString( - attributedString: Typography.p14.styled("수정", color: .white)) + string: "수정", + attributes: [ + .font: DesignSystemFontFamily.Pretendard.regular.font(size: 13), + .foregroundColor: UIColor.detailSectionText, + .kern: -0.195 + ] + ) attributed.addAttribute( .underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: attributed.length)) @@ -169,12 +182,12 @@ final class MealSectionView: UIView { imageView.contentMode = .scaleAspectFit let label = UILabel() - label.setText("오늘의 음식 사진을 촬영해보세요", style: .p12, color: .gray100) + label.setText("오늘의 음식 사진을 촬영해보세요", style: .p12, color: .detailMutedText) label.textAlignment = .center - let container = DashedBorderView() + let container = DashedBorderView(strokeColor: .detailStroke) container.cornerRadius = 16 - container.backgroundColor = .sd900 + container.backgroundColor = .detailCardBackground container.layer.cornerRadius = 16 container.clipsToBounds = true container.addSubview(imageView) diff --git a/FoodDiary/Presentation/Sources/Detail/Components/NoteContentView.swift b/FoodDiary/Presentation/Sources/Detail/Components/NoteContentView.swift index 697b5e7f..d81a2c97 100644 --- a/FoodDiary/Presentation/Sources/Detail/Components/NoteContentView.swift +++ b/FoodDiary/Presentation/Sources/Detail/Components/NoteContentView.swift @@ -54,7 +54,7 @@ final class NoteContentView: UIView { // MARK: - Setup private func setupUI() { - backgroundColor = .sd900 + backgroundColor = .detailCardBackground layer.cornerRadius = Constants.cornerRadius clipsToBounds = true @@ -83,7 +83,7 @@ final class NoteContentView: UIView { } private func configure(note: String) { - headerLabel.setText("AI가 요약했어요", style: .p12, color: .gray050) - contentLabel.setText(note, style: .p12, color: .gray100, lineSpacing: 4) + headerLabel.setText("AI가 요약했어요", style: .p12, color: .primary) + contentLabel.setText(note, style: .p12, color: .detailBodyText, lineSpacing: 4) } } diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index de1e5955..ccd51189 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -74,19 +74,19 @@ public final class DetailViewController: UIViewController { config.cornerStyle = .capsule button = UIButton(configuration: config) } - button.tintColor = .white + button.tintColor = .primary return button }() private let emptyDayTitleLabel: UILabel = { let label = UILabel() - label.setText("기록된 다이어리가 없어요", style: .p18, color: .gray050) + label.setText("기록된 다이어리가 없어요", style: .p18, color: .detailPrimaryText) return label }() private let emptyDaySubtitleLabel: UILabel = { let label = UILabel() - label.setText("음식 사진을 추가해보세요", style: .p15, color: .gray100) + label.setText("음식 사진을 추가해보세요", style: .p15, color: .detailMutedText) return label }() @@ -152,6 +152,7 @@ public final class DetailViewController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + applyNavigationBarAppearance() navigationController?.setNavigationBarHidden(false, animated: animated) navigationController?.hidesBarsOnSwipe = true viewModel.input.send(.loadRecords) @@ -159,6 +160,7 @@ public final class DetailViewController: UIViewController { public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + resetNavigationBarAppearance() navigationController?.hidesBarsOnSwipe = false if isMovingFromParent { onDismissWithDate?(viewModel.state.currentDate) @@ -205,6 +207,28 @@ public final class DetailViewController: UIViewController { dismissDetail() } + private func applyNavigationBarAppearance() { + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .white + appearance.titleTextAttributes = [.foregroundColor: UIColor.detailPrimaryText] + appearance.shadowColor = .detailStroke + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + navigationController?.navigationBar.tintColor = .detailPrimaryText + } + + private func resetNavigationBarAppearance() { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.titleTextAttributes = [.foregroundColor: UIColor.white] + appearance.shadowColor = .clear + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + navigationController?.navigationBar.tintColor = .white + } + private func dismissDetail() { if shouldPopToRoot { navigationController?.popToRootViewController(animated: true) @@ -214,7 +238,7 @@ public final class DetailViewController: UIViewController { } private func setupUI() { - view.backgroundColor = .sdBase + view.backgroundColor = .white view.addSubview(scrollView) scrollView.addSubview(contentView) @@ -478,7 +502,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]) { @@ -603,4 +627,3 @@ public final class DetailViewController: UIViewController { scrollView.setContentOffset(targetOffset, animated: false) } } - diff --git a/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift b/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift index 584fcfb4..2894569c 100644 --- a/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift +++ b/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift @@ -5,22 +5,21 @@ // Created by 강대훈 on 2/13/26. // -import UIKit -import DesignSystem import Combine +import DesignSystem +import UIKit public final class RootTabBarController: UITabBarController { - let calendarVC: CalendarViewController + let calendarVC: MonthlyCalendarViewController let insightVC: UIViewController private let mypageButtonTapSubject = PassthroughSubject() - private var cancellables = Set() public var mypageButtonTapPublisher: AnyPublisher { mypageButtonTapSubject.eraseToAnyPublisher() } public init( - calendarVC: CalendarViewController, + calendarVC: MonthlyCalendarViewController, insightVC: UIViewController ) { self.calendarVC = calendarVC @@ -45,23 +44,44 @@ public final class RootTabBarController: UITabBarController { private func setup() { self.delegate = self + tabBar.isHidden = false tabBar.tintColor = DesignSystemAsset.primary.color - - calendarVC.currentModePublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] mode in - self?.updateToggleIcon(for: mode) - } - .store(in: &cancellables) + tabBar.isTranslucent = true + tabBar.backgroundColor = .clear + tabBar.barTintColor = .clear + tabBar.backgroundImage = UIImage() + tabBar.shadowImage = UIImage() + tabBar.layer.backgroundColor = UIColor.clear.cgColor + + let appearance = UITabBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundEffect = nil + appearance.backgroundColor = .clear + appearance.shadowColor = .clear + appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.gray600] + appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: DesignSystemAsset.primary.color] + appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.gray600] + appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: DesignSystemAsset.primary.color] + appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.gray600] + appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: DesignSystemAsset.primary.color] + tabBar.standardAppearance = appearance + tabBar.scrollEdgeAppearance = appearance calendarVC.tabBarItem = UITabBarItem(title: "홈", image: DesignSystemAsset.iconHome.image, tag: 0) insightVC.tabBarItem = UITabBarItem(title: "인사이트", image: DesignSystemAsset.iconInsight.image, tag: 1) - let toggleVC = UIViewController() - toggleVC.tabBarItem = UITabBarItem(tabBarSystemItem: .search, tag: 2) - toggleVC.tabBarItem.image = DesignSystemAsset.iconWeekly.image - - viewControllers = [calendarVC, insightVC, toggleVC] + let addVC = UIViewController() + let addItem = UITabBarItem(tabBarSystemItem: .search, tag: 2) + let addImage = UIImage(systemName: "plus")? + .withTintColor(DesignSystemAsset.primary.color, renderingMode: .alwaysOriginal) + addItem.image = addImage + addItem.selectedImage = addImage + addItem.title = nil + addItem.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0) + addItem.accessibilityLabel = "음식 기록 추가" + addVC.tabBarItem = addItem + + viewControllers = [calendarVC, insightVC, addVC] } private func setupNavigationBar() { @@ -81,17 +101,6 @@ public final class RootTabBarController: UITabBarController { @objc private func mypageButtonTapped() { 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 - } } extension RootTabBarController: UITabBarControllerDelegate { @@ -99,7 +108,7 @@ extension RootTabBarController: UITabBarControllerDelegate { guard let index = viewControllers?.firstIndex(of: viewController) else { return true } if index == 2 { - toggleViewMode() + calendarVC.addFoodRecord() return false } @@ -107,10 +116,5 @@ extension RootTabBarController: UITabBarControllerDelegate { } 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 { - subview.isHidden = selectedIndex == 1 - } - } } }