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
- }
- }
}
}