Skip to content

Commit d593de6

Browse files
authored
Merge pull request #76 from Clokey-dev/fix/#75
[#75] 옷장 리포트 리팩터링 · 통합 로깅 · 현지화 · 버그픽스 (v1.0.0 준비)
2 parents 5249bbf + d837f8d commit d593de6

141 files changed

Lines changed: 4556 additions & 719 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
//
2+
// AppDelegate.swift
3+
// Codive
4+
//
5+
// Created by Claude on 4/26/26.
6+
//
7+
8+
import UIKit
9+
import FirebaseMessaging
10+
import UserNotifications
11+
12+
final class AppDelegate: NSObject, UIApplicationDelegate {
13+
14+
/// 앱이 죽어있을 때 푸시 탭으로 실행된 경우 저장해두는 pending 데이터
15+
static var pendingPushUserInfo: [AnyHashable: Any]?
16+
17+
func application(
18+
_ application: UIApplication,
19+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
20+
) -> Bool {
21+
UNUserNotificationCenter.current().delegate = self
22+
Messaging.messaging().delegate = self
23+
24+
requestNotificationPermission(application)
25+
26+
return true
27+
}
28+
29+
// MARK: - APNs Token
30+
31+
func application(
32+
_ application: UIApplication,
33+
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
34+
) {
35+
Messaging.messaging().apnsToken = deviceToken
36+
}
37+
38+
func application(
39+
_ application: UIApplication,
40+
didFailToRegisterForRemoteNotificationsWithError error: Error
41+
) {
42+
#if DEBUG
43+
print("[Push] APNs 등록 실패: \(error.localizedDescription)")
44+
#endif
45+
}
46+
47+
// MARK: - Permission
48+
49+
private func requestNotificationPermission(_ application: UIApplication) {
50+
let options: UNAuthorizationOptions = [.alert, .badge, .sound]
51+
UNUserNotificationCenter.current().requestAuthorization(options: options) { granted, error in
52+
#if DEBUG
53+
if let error {
54+
print("[Push] 권한 요청 실패: \(error.localizedDescription)")
55+
} else {
56+
print("[Push] 알림 권한 허용: \(granted)")
57+
}
58+
#endif
59+
60+
guard granted else { return }
61+
62+
DispatchQueue.main.async {
63+
application.registerForRemoteNotifications()
64+
}
65+
}
66+
}
67+
}
68+
69+
// MARK: - UNUserNotificationCenterDelegate
70+
71+
extension AppDelegate: UNUserNotificationCenterDelegate {
72+
73+
// 포그라운드에서 알림 수신 시 배너 표시
74+
func userNotificationCenter(
75+
_ center: UNUserNotificationCenter,
76+
willPresent notification: UNNotification,
77+
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
78+
) {
79+
completionHandler([.banner, .badge, .sound])
80+
}
81+
82+
// 알림 탭 시 처리
83+
func userNotificationCenter(
84+
_ center: UNUserNotificationCenter,
85+
didReceive response: UNNotificationResponse,
86+
withCompletionHandler completionHandler: @escaping () -> Void
87+
) {
88+
let userInfo = response.notification.request.content.userInfo
89+
90+
#if DEBUG
91+
print("[Push] 알림 탭 userInfo: \(userInfo)")
92+
#endif
93+
94+
// MainTabView가 아직 없으면 pending으로 저장, 있으면 바로 전달
95+
AppDelegate.pendingPushUserInfo = userInfo
96+
97+
NotificationCenter.default.post(
98+
name: .pushNotificationTapped,
99+
object: nil,
100+
userInfo: userInfo
101+
)
102+
103+
completionHandler()
104+
}
105+
}
106+
107+
// MARK: - MessagingDelegate
108+
109+
extension AppDelegate: MessagingDelegate {
110+
111+
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
112+
guard let fcmToken else { return }
113+
114+
#if DEBUG
115+
AppLog.push.debug("FCM 토큰: \(fcmToken.masked(), privacy: .public)")
116+
#endif
117+
118+
// UserDefaults에 저장 (로그인 후 서버 전송용)
119+
UserDefaults.standard.set(fcmToken, forKey: "fcmToken")
120+
121+
// 로그인 상태면 서버에 즉시 전송
122+
Task {
123+
await sendFCMTokenToServerIfNeeded(fcmToken)
124+
}
125+
}
126+
127+
@MainActor
128+
private func sendFCMTokenToServerIfNeeded(_ fcmToken: String) async {
129+
let tokenService = TokenService()
130+
131+
guard tokenService.hasValidTokens(),
132+
!tokenService.isAccessTokenExpired() else {
133+
return
134+
}
135+
136+
do {
137+
let authAPIService = AuthAPIService()
138+
try await authAPIService.renewDeviceToken(deviceToken: fcmToken)
139+
#if DEBUG
140+
print("[Push] 서버에 FCM 토큰 전송 완료")
141+
#endif
142+
} catch {
143+
#if DEBUG
144+
print("[Push] 서버 FCM 토큰 전송 실패: \(error.localizedDescription)")
145+
#endif
146+
}
147+
}
148+
}
149+
150+
// MARK: - Notification Name
151+
152+
extension Notification.Name {
153+
static let pushNotificationTapped = Notification.Name("pushNotificationTapped")
154+
}

Codive/Application/AppRootView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ struct AppRootView: View {
5858
.scaleEffect(1.5)
5959
}
6060
}
61+
.onTapGesture {
62+
hideKeyboard()
63+
}
6164
.onOpenURL { url in
6265
handleDeepLink(url: url)
6366
}

Codive/Application/CodiveApp.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import KakaoSDKAuth
1111
@main
1212
struct CodiveApp: App {
1313

14+
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
1415
let appDIContainer = AppDIContainer()
1516

1617
init() {

Codive/Codive.Release.entitlements

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.developer.applesignin</key>
6+
<array>
7+
<string>Default</string>
8+
</array>
9+
<key>com.apple.developer.weatherkit</key>
10+
<true/>
11+
<key>aps-environment</key>
12+
<string>production</string>
13+
</dict>
14+
</plist>

Codive/Codive.entitlements

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@
88
</array>
99
<key>com.apple.developer.weatherkit</key>
1010
<true/>
11+
<key>aps-environment</key>
12+
<string>development</string>
1113
</dict>
1214
</plist>

Codive/Core/Utils/Logger.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// Logger.swift
3+
// Codive
4+
//
5+
// os.Logger 기반 통합 로깅 래퍼.
6+
// Release 빌드에서는 .debug 레벨이 자동 무시되며,
7+
// 민감 정보(토큰 등)는 `masked()` 헬퍼로 마스킹하여 기록한다.
8+
//
9+
10+
import Foundation
11+
import OSLog
12+
13+
enum AppLog {
14+
private static let subsystem = "com.codive.app"
15+
16+
static let app = Logger(subsystem: subsystem, category: "app")
17+
static let auth = Logger(subsystem: subsystem, category: "auth")
18+
static let network = Logger(subsystem: subsystem, category: "network")
19+
static let api = Logger(subsystem: subsystem, category: "api")
20+
static let push = Logger(subsystem: subsystem, category: "push")
21+
static let deeplink = Logger(subsystem: subsystem, category: "deeplink")
22+
static let ui = Logger(subsystem: subsystem, category: "ui")
23+
}
24+
25+
extension String {
26+
/// 민감 문자열의 앞 일부만 노출하고 나머지는 마스킹.
27+
/// - Parameter visible: 앞에서 그대로 보여줄 문자 수 (기본 8)
28+
func masked(visible: Int = 8) -> String {
29+
guard count > visible else { return "***" }
30+
return prefix(visible) + "...(\(count))"
31+
}
32+
}

Codive/DIContainer/AddDIContainer.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ final class AddDIContainer {
1919
let navigationRouter: NavigationRouter
2020
lazy var addViewFactory = AddViewFactory(addDIContainer: self)
2121

22+
// 캘린더에서 선택한 날짜 (기록 생성 시 사용)
23+
var selectedDate: Date?
24+
2225
// 지우개 편집 시 공유 참조
2326
weak var activeClothAddViewModel: ClothAddViewModel?
2427
var pendingErasedImage: UIImage?
@@ -46,10 +49,11 @@ final class AddDIContainer {
4649
)
4750
}
4851

49-
func makeRecordDetailViewModel(selectedPhotos: [SelectedPhoto]) -> RecordDetailViewModel {
52+
func makeRecordDetailViewModel(selectedPhotos: [SelectedPhoto], selectedDate: Date? = nil) -> RecordDetailViewModel {
5053
return RecordDetailViewModel(
5154
selectedPhotos: selectedPhotos,
52-
navigationRouter: navigationRouter
55+
navigationRouter: navigationRouter,
56+
selectedDate: selectedDate
5357
)
5458
}
5559

@@ -93,9 +97,9 @@ final class AddDIContainer {
9397
return RecordAddView(viewModel: makeRecordAddViewModel(flowType: flowType))
9498
}
9599

96-
func makeRecordDetailView(selectedPhotos: [SelectedPhoto]) -> RecordDetailView {
100+
func makeRecordDetailView(selectedPhotos: [SelectedPhoto], selectedDate: Date? = nil) -> RecordDetailView {
97101
return RecordDetailView(
98-
viewModel: makeRecordDetailViewModel(selectedPhotos: selectedPhotos)
102+
viewModel: makeRecordDetailViewModel(selectedPhotos: selectedPhotos, selectedDate: selectedDate)
99103
)
100104
}
101105

Codive/DIContainer/AuthDIContainer.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ final class AuthDIContainer {
3737
return OnboardingViewModel(
3838
appRouter: appRouter,
3939
navigationRouter: navigationRouter,
40-
authRepository: authRepository
40+
authRepository: authRepository,
41+
authAPIService: authAPIService
4142
)
4243
}
4344

Codive/DIContainer/ClosetDIContainer.swift

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ final class ClosetDIContainer {
8080
return CheckStatisticsConditionUseCase(repository: statisticsRepository)
8181
}
8282

83+
func makeFetchFavoriteItemsUseCase() -> FetchFavoriteItemsUseCase {
84+
return FetchFavoriteItemsUseCase(repository: statisticsRepository)
85+
}
86+
87+
func makeFetchFavoriteCategoryItemsUseCase() -> FetchFavoriteCategoryItemsUseCase {
88+
return FetchFavoriteCategoryItemsUseCase(repository: statisticsRepository)
89+
}
90+
91+
func makeFetchClosetUtilizationUseCase() -> FetchClosetUtilizationUseCase {
92+
return FetchClosetUtilizationUseCase(repository: statisticsRepository)
93+
}
94+
95+
func makeFetchClothListByCategoryUseCase() -> FetchClothListByCategoryUseCase {
96+
return FetchClothListByCategoryUseCase(repository: clothRepository)
97+
}
98+
99+
func makeFetchClothDetailUseCase() -> FetchClothDetailUseCase {
100+
return FetchClothDetailUseCase(repository: clothRepository)
101+
}
102+
83103
// MARK: - ViewModels
84104
func makeMyClosetViewModel() -> MyClosetViewModel {
85105
return MyClosetViewModel(
@@ -108,7 +128,7 @@ final class ClosetDIContainer {
108128
cloth: cloth,
109129
navigationRouter: navigationRouter,
110130
deleteClothItemsUseCase: makeDeleteClothItemsUseCase(),
111-
clothRepository: clothRepository
131+
fetchClothDetailUseCase: makeFetchClothDetailUseCase()
112132
)
113133
}
114134

@@ -123,7 +143,34 @@ final class ClosetDIContainer {
123143
func makeWardrobeReportDetailViewModel() -> WardrobeReportDetailViewModel {
124144
return WardrobeReportDetailViewModel(
125145
navigationRouter: navigationRouter,
126-
checkStatisticsConditionUseCase: makeCheckStatisticsConditionUseCase()
146+
checkStatisticsConditionUseCase: makeCheckStatisticsConditionUseCase(),
147+
fetchFavoriteItemsUseCase: makeFetchFavoriteItemsUseCase(),
148+
fetchFavoriteCategoryItemsUseCase: makeFetchFavoriteCategoryItemsUseCase(),
149+
fetchClosetUtilizationUseCase: makeFetchClosetUtilizationUseCase()
150+
)
151+
}
152+
153+
func makeFavoriteByCategoryViewModel(parentCategoryId: Int64) -> FavoriteByCategoryViewModel {
154+
return FavoriteByCategoryViewModel(
155+
navigationRouter: navigationRouter,
156+
fetchFavoriteCategoryItemsUseCase: makeFetchFavoriteCategoryItemsUseCase(),
157+
fetchClothListByCategoryUseCase: makeFetchClothListByCategoryUseCase(),
158+
parentCategoryId: parentCategoryId
159+
)
160+
}
161+
162+
func makeItemDataViewModel() -> ItemDataViewModel {
163+
return ItemDataViewModel(
164+
navigationRouter: navigationRouter,
165+
fetchFavoriteItemsUseCase: makeFetchFavoriteItemsUseCase(),
166+
fetchClothListByCategoryUseCase: makeFetchClothListByCategoryUseCase()
167+
)
168+
}
169+
170+
func makeWearingDataViewModel() -> WearingDataViewModel {
171+
return WearingDataViewModel(
172+
navigationRouter: navigationRouter,
173+
fetchClosetUtilizationUseCase: makeFetchClosetUtilizationUseCase()
127174
)
128175
}
129176

@@ -143,4 +190,16 @@ final class ClosetDIContainer {
143190
func makeWardrobeReportDetailView() -> some View {
144191
return WardrobeReportDetailView(viewModel: makeWardrobeReportDetailViewModel())
145192
}
193+
194+
func makeFavoriteByCategoryView(parentCategoryId: Int64) -> some View {
195+
return FavoriteByCategoryView(viewModel: makeFavoriteByCategoryViewModel(parentCategoryId: parentCategoryId))
196+
}
197+
198+
func makeItemDataView() -> some View {
199+
return ItemDataView(viewModel: makeItemDataViewModel())
200+
}
201+
202+
func makeWearingDataView() -> some View {
203+
return WearingDataView(viewModel: makeWearingDataViewModel())
204+
}
146205
}

Codive/Features/Auth/Presentation/View/TermsAgreementView.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,15 @@ struct TermsAgreementView: View {
8888

8989
// 개별 항목들
9090
ForEach(termsList, id: \.termId) { term in
91+
let wikiURL = termsWikiURL(for: term.title)
9192
AgreementRow(
9293
title: term.title,
9394
isAgreed: binding(for: term.termId),
94-
isRequired: !term.isOptional
95+
isRequired: !term.isOptional,
96+
showChevron: wikiURL != nil
9597
) {
96-
if let url = termsWikiURL(for: term.title) {
97-
selectedTermsURL = TermsURL(url: url)
98+
if let wikiURL {
99+
selectedTermsURL = TermsURL(url: wikiURL)
98100
}
99101
}
100102
}
@@ -124,7 +126,7 @@ struct TermsAgreementView: View {
124126
.padding(.horizontal, 20)
125127
.padding(.bottom, 10)
126128
}
127-
.navigationBarHidden(true)
129+
.toolbar(.hidden, for: .navigationBar)
128130
.task {
129131
await loadTerms()
130132
}

0 commit comments

Comments
 (0)