From 4989c9effad11b91717faad74e34e433eb9d83bc Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Jul 2026 13:41:04 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=9D=98=EB=AF=B8=20=EA=B8=B0=EB=B0=98=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DTO/PushNotificationResponse.swift | 9 +- .../Mapper/PushNotificationMapping.swift | 12 ++- .../PushNotificationRepositoryImpl.swift | 3 +- .../Mapper/PushNotificationMappingTests.swift | 52 ++++++++++++ .../PushNotificationRepositoryImplTests.swift | 85 +++++++++++++++++++ .../Sources/Entity/PushNotification.swift | 5 +- .../Entity/PushNotificationContent.swift | 12 +++ 7 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 Application/Data/Tests/Mapper/PushNotificationMappingTests.swift create mode 100644 Application/Data/Tests/Repository/PushNotificationRepositoryImplTests.swift create mode 100644 Application/Domain/Sources/Entity/PushNotificationContent.swift diff --git a/Application/Data/Sources/DTO/PushNotificationResponse.swift b/Application/Data/Sources/DTO/PushNotificationResponse.swift index e6960e20..a4b16902 100644 --- a/Application/Data/Sources/DTO/PushNotificationResponse.swift +++ b/Application/Data/Sources/DTO/PushNotificationResponse.swift @@ -9,6 +9,10 @@ import Foundation import Domain public struct PushNotificationResponse { + public enum Content: Equatable { + case todoDueTomorrow(todoTitle: String?) + } + public let id: String public let title: String public let body: String @@ -16,6 +20,7 @@ public struct PushNotificationResponse { public let isRead: Bool public let todoId: String public let todoCategory: TodoCategoryResponse + public let content: Content? public init( id: String, @@ -24,7 +29,8 @@ public struct PushNotificationResponse { receivedAt: Date, isRead: Bool, todoId: String, - todoCategory: TodoCategoryResponse + todoCategory: TodoCategoryResponse, + content: Content? = nil ) { self.id = id self.title = title @@ -33,5 +39,6 @@ public struct PushNotificationResponse { self.isRead = isRead self.todoId = todoId self.todoCategory = todoCategory + self.content = content } } diff --git a/Application/Data/Sources/Mapper/PushNotificationMapping.swift b/Application/Data/Sources/Mapper/PushNotificationMapping.swift index 8b1848e1..859387ed 100644 --- a/Application/Data/Sources/Mapper/PushNotificationMapping.swift +++ b/Application/Data/Sources/Mapper/PushNotificationMapping.swift @@ -27,11 +27,21 @@ public extension PushNotificationResponse { receivedAt: self.receivedAt, isRead: self.isRead, todoId: self.todoId, - todoCategory: todoCategory + todoCategory: todoCategory, + content: content?.toDomain() ) } } +public extension PushNotificationResponse.Content { + func toDomain() -> PushNotificationContent { + switch self { + case .todoDueTomorrow(let todoTitle): + return .todoDueTomorrow(todoTitle: todoTitle) + } + } +} + public extension PushNotificationCursorDTO { func toDomain() -> PushNotificationCursor { PushNotificationCursor( diff --git a/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift b/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift index 7f9734de..9e25ee8b 100644 --- a/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift @@ -218,7 +218,8 @@ private extension PushNotificationRepositoryImpl { receivedAt: response.receivedAt, isRead: response.isRead, todoId: response.todoId, - todoCategory: .decoded(todoCategory) + todoCategory: .decoded(todoCategory), + content: response.content ) } } diff --git a/Application/Data/Tests/Mapper/PushNotificationMappingTests.swift b/Application/Data/Tests/Mapper/PushNotificationMappingTests.swift new file mode 100644 index 00000000..4be6e4b1 --- /dev/null +++ b/Application/Data/Tests/Mapper/PushNotificationMappingTests.swift @@ -0,0 +1,52 @@ +// +// PushNotificationMappingTests.swift +// DataTests +// +// Created by opfic on 7/19/26. +// + +import Foundation +import Testing +import Domain +@testable import Data + +struct PushNotificationMappingTests { + @Test("Todo 마감 알림 응답은 의미 기반 내용을 변환한다") + func Todo_마감_알림_응답은_의미_기반_내용을_변환한다() throws { + let response = makeResponse( + content: .todoDueTomorrow(todoTitle: "테스트 작성") + ) + + let notification = try response.toDomain() + + #expect(notification.content == .todoDueTomorrow(todoTitle: "테스트 작성")) + #expect(notification.title == "fallback-title") + #expect(notification.body == "fallback-body") + } + + @Test("기존 푸시 알림 응답은 의미 기반 내용 없이 변환한다") + func 기존_푸시_알림_응답은_의미_기반_내용_없이_변환한다() throws { + let response = makeResponse() + + let notification = try response.toDomain() + + #expect(notification.content == nil) + #expect(notification.title == "fallback-title") + #expect(notification.body == "fallback-body") + } + + private func makeResponse( + content: PushNotificationResponse.Content? = nil + ) -> PushNotificationResponse { + PushNotificationResponse( + id: "notification-id", + title: "fallback-title", + body: "fallback-body", + receivedAt: Date(timeIntervalSince1970: 1), + isRead: false, + todoId: "todo-id", + todoCategory: .decoded(.system(.feature)), + content: content + ) + } +} diff --git a/Application/Data/Tests/Repository/PushNotificationRepositoryImplTests.swift b/Application/Data/Tests/Repository/PushNotificationRepositoryImplTests.swift new file mode 100644 index 00000000..a7df0425 --- /dev/null +++ b/Application/Data/Tests/Repository/PushNotificationRepositoryImplTests.swift @@ -0,0 +1,85 @@ +// +// PushNotificationRepositoryImplTests.swift +// DataTests +// +// Created by opfic on 7/19/26. +// + +import Combine +import Foundation +import Testing +import Core +import Domain +@testable import Data + +struct PushNotificationRepositoryImplTests { + @Test("푸시 알림 카테고리 해석은 의미 기반 내용을 보존한다") + func 푸시_알림_카테고리_해석은_의미_기반_내용을_보존한다() async throws { + let response = PushNotificationResponse( + id: "notification-id", + title: "fallback-title", + body: "fallback-body", + receivedAt: Date(timeIntervalSince1970: 1), + isRead: false, + todoId: "todo-id", + todoCategory: .raw(SystemTodoCategory.feature.rawValue), + content: .todoDueTomorrow(todoTitle: "테스트 작성") + ) + let service = PushNotificationServiceSpy(response: response) + let repository = PushNotificationRepositoryImpl( + pushNotificationService: service, + todoCategoryService: TodoCategoryServiceSpy(), + store: MemoryCacheStoreSpy() + ) + + let page = try await repository.requestNotifications(.default, cursor: nil) + let notification = try #require(page.items.first) + + #expect(notification.content == .todoDueTomorrow(todoTitle: "테스트 작성")) + #expect(notification.todoCategory == .system(.feature)) + } +} + +private final class PushNotificationServiceSpy: PushNotificationService { + private let response: PushNotificationResponse + + init(response: PushNotificationResponse) { + self.response = response + } + + func fetchPushNotificationEnabled() async throws -> Bool { false } + func fetchPushNotificationTime() async throws -> DateComponents { DateComponents() } + func updatePushNotificationSettings(isEnabled: Bool, components: DateComponents) async throws { } + + func requestNotifications( + _ notificationQuery: PushNotificationQuery, + cursor: PushNotificationCursorDTO? + ) async throws -> PushNotificationPageResponse { + PushNotificationPageResponse(items: [response], nextCursor: nil) + } + + func observeNotifications( + _ query: PushNotificationQuery, + limit: Int + ) throws -> AnyPublisher { + Empty().eraseToAnyPublisher() + } + + func observeUnreadPushCount() throws -> AnyPublisher { + Empty().eraseToAnyPublisher() + } + + func deleteNotification(_ notificationID: String) async throws { } + func undoDeleteNotification(_ notificationID: String) async throws { } + func toggleNotificationRead(_ todoId: String) async throws { } +} + +private final class TodoCategoryServiceSpy: TodoCategoryService { + func fetchCategoryPreferences() async throws -> [TodoCategoryPreferenceResponse] { [] } + func updateCategoryPreferences(_ preferences: [TodoCategoryPreferenceResponse]) async throws { } +} + +private final class MemoryCacheStoreSpy: MemoryCacheStore { + func value(forKey key: String) -> Value? { nil } + func setValue(_ value: Value?, forKey key: String) { } +} diff --git a/Application/Domain/Sources/Entity/PushNotification.swift b/Application/Domain/Sources/Entity/PushNotification.swift index 5a9bd48c..ff8d1e6f 100644 --- a/Application/Domain/Sources/Entity/PushNotification.swift +++ b/Application/Domain/Sources/Entity/PushNotification.swift @@ -15,6 +15,7 @@ public struct PushNotification: Hashable { public var isRead: Bool public let todoId: String public let todoCategory: TodoCategory + public let content: PushNotificationContent? public init( id: String, @@ -23,7 +24,8 @@ public struct PushNotification: Hashable { receivedAt: Date, isRead: Bool, todoId: String, - todoCategory: TodoCategory + todoCategory: TodoCategory, + content: PushNotificationContent? = nil ) { self.id = id self.title = title @@ -32,5 +34,6 @@ public struct PushNotification: Hashable { self.isRead = isRead self.todoId = todoId self.todoCategory = todoCategory + self.content = content } } diff --git a/Application/Domain/Sources/Entity/PushNotificationContent.swift b/Application/Domain/Sources/Entity/PushNotificationContent.swift new file mode 100644 index 00000000..027aa41f --- /dev/null +++ b/Application/Domain/Sources/Entity/PushNotificationContent.swift @@ -0,0 +1,12 @@ +// +// PushNotificationContent.swift +// Domain +// +// Created by opfic on 7/19/26. +// + +import Foundation + +public enum PushNotificationContent: Hashable { + case todoDueTomorrow(todoTitle: String?) +} From dd8cadeac2f708b76a40e507be603c0a2308dcb5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Jul 2026 20:17:28 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=9D=98=EB=AF=B8=20=EA=B8=B0=EB=B0=98=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EB=94=94=EC=BD=94=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/PushNotificationDecoder.swift | 38 ++++++++++++++ .../Service/PushNotificationServiceImpl.swift | 5 +- .../PushNotificationDecoderTests.swift | 49 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 Application/Infra/Sources/Service/PushNotificationDecoder.swift create mode 100644 Application/Infra/Tests/Service/PushNotificationDecoderTests.swift diff --git a/Application/Infra/Sources/Service/PushNotificationDecoder.swift b/Application/Infra/Sources/Service/PushNotificationDecoder.swift new file mode 100644 index 00000000..518a8711 --- /dev/null +++ b/Application/Infra/Sources/Service/PushNotificationDecoder.swift @@ -0,0 +1,38 @@ +// +// PushNotificationDecoder.swift +// Infra +// +// Created by opfic on 7/19/26. +// + +import Data + +enum PushNotificationDecoder { + private enum FieldKey: String { + case contentType + case contentArguments + } + + private enum ContentType: String { + case todoDueTomorrow + } + + private enum ArgumentKey: String { + case todoTitle + } + + static func decode(_ data: [String: Any]) -> PushNotificationResponse.Content? { + guard let contentType = data[FieldKey.contentType.rawValue] as? String, + let type = ContentType(rawValue: contentType) else { + return nil + } + + switch type { + case .todoDueTomorrow: + let contentArguments = data[FieldKey.contentArguments.rawValue] as? [String: Any] + return .todoDueTomorrow( + todoTitle: contentArguments?[ArgumentKey.todoTitle.rawValue] as? String + ) + } + } +} diff --git a/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift b/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift index e4744d1c..027a800c 100644 --- a/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift +++ b/Application/Infra/Sources/Service/PushNotificationServiceImpl.swift @@ -362,6 +362,8 @@ private extension PushNotificationServiceImpl { return nil } + let content = PushNotificationDecoder.decode(data) + return PushNotificationResponse( id: snapshot.documentID, title: title, @@ -369,7 +371,8 @@ private extension PushNotificationServiceImpl { receivedAt: receivedAt.dateValue(), isRead: isRead, todoId: todoId, - todoCategory: .raw(todoCategory) + todoCategory: .raw(todoCategory), + content: content ) } diff --git a/Application/Infra/Tests/Service/PushNotificationDecoderTests.swift b/Application/Infra/Tests/Service/PushNotificationDecoderTests.swift new file mode 100644 index 00000000..1cb11f4e --- /dev/null +++ b/Application/Infra/Tests/Service/PushNotificationDecoderTests.swift @@ -0,0 +1,49 @@ +// +// PushNotificationDecoderTests.swift +// InfraTests +// +// Created by opfic on 7/19/26. +// + +import Testing +import Data +@testable import Infra + +struct PushNotificationDecoderTests { + @Test("todoDueTomorrow 내용은 Todo 제목을 해석한다") + func todoDueTomorrow_내용은_Todo_제목을_해석한다() { + let content = PushNotificationDecoder.decode([ + "contentType": "todoDueTomorrow", + "contentArguments": ["todoTitle": "테스트 작성"] + ]) + + #expect(content == .todoDueTomorrow(todoTitle: "테스트 작성")) + } + + @Test("제목 없는 todoDueTomorrow 내용은 제목을 nil로 해석한다") + func todoDueTomorrow_제목이_없으면_제목을_nil로_해석한다() { + let content = PushNotificationDecoder.decode([ + "contentType": "todoDueTomorrow", + "contentArguments": [:] + ]) + + #expect(content == .todoDueTomorrow(todoTitle: nil)) + } + + @Test("기존 알림 필드는 의미 기반 내용 없이 해석한다") + func legacy_알림_필드는_의미_기반_내용_없이_해석한다() { + let content = PushNotificationDecoder.decode([:]) + + #expect(content == nil) + } + + @Test("알 수 없는 내용 타입은 의미 기반 내용 없이 해석한다") + func unknown_내용_타입은_의미_기반_내용_없이_해석한다() { + let content = PushNotificationDecoder.decode([ + "contentType": "unknown", + "contentArguments": ["todoTitle": "테스트 작성"] + ]) + + #expect(content == nil) + } +} From 3a0066a970edfbb996d36d5e69605872776f0a97 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Jul 2026 22:32:13 +0900 Subject: [PATCH 3/8] =?UTF-8?q?chore:=20superpowers=20=EB=AC=B4=EC=8B=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3afe14fb..9c68bd26 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,9 @@ fastlane/logs/ .tmp/ .spm/ Tuist/.build/ - # drawio *.drawio *.drawio.bkp + +# AI +.superpowers/ From 7af4369696ec4b2d1d7152d65f9c3f087aa3bbd5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Jul 2026 23:13:56 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20NotificationTab=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EB=AC=B8=EA=B5=AC=20=ED=98=84?= =?UTF-8?q?=EC=A7=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Resource/Localizable.xcstrings | 51 +++++++++++++++ .../PushNotificationItem.swift | 35 ++++++++-- .../PushNotificationItemTests.swift | 64 +++++++++++++++++++ .../PushNotificationListFixtures.swift | 6 +- 4 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift diff --git a/Application/App/Sources/Resource/Localizable.xcstrings b/Application/App/Sources/Resource/Localizable.xcstrings index 4b2792d2..48ad3bf9 100644 --- a/Application/App/Sources/Resource/Localizable.xcstrings +++ b/Application/App/Sources/Resource/Localizable.xcstrings @@ -1119,6 +1119,57 @@ } } }, + "push_notification_todo_due_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo Reminder" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo 알림" + } + } + } + }, + "push_notification_todo_due_tomorrow_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ is due tomorrow." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%1$@'의 마감일이 내일입니다." + } + } + } + }, + "push_notification_todo_due_tomorrow_without_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "An untitled Todo is due tomorrow." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목 없는 Todo의 마감일이 내일입니다." + } + } + } + }, "push_notifications_empty" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift index d5001e45..8d426ea2 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift @@ -11,20 +11,47 @@ import Domain public struct PushNotificationItem: Identifiable, Hashable { public let id: String public var isHidden = false - public let title: String - public let body: String public let receivedAt: Date public var isRead: Bool public let todoId: String public let todoCategory: TodoCategory + private let legacyTitle: String + private let legacyBody: String + private let content: PushNotificationContent? + + public var title: String { + switch content { + case .todoDueTomorrow: + return String(localized: "push_notification_todo_due_title") + case nil: + return legacyTitle + } + } + + public var body: String { + switch content { + case .todoDueTomorrow(let todoTitle): + guard let todoTitle, !todoTitle.isEmpty else { + return String(localized: "push_notification_todo_due_tomorrow_without_title") + } + return String.localizedStringWithFormat( + String(localized: "push_notification_todo_due_tomorrow_format"), + todoTitle + ) + case nil: + return legacyBody + } + } + public init(from notification: PushNotification) { self.id = notification.id - self.title = notification.title - self.body = notification.body self.receivedAt = notification.receivedAt self.isRead = notification.isRead self.todoId = notification.todoId self.todoCategory = notification.todoCategory + self.legacyTitle = notification.title + self.legacyBody = notification.body + self.content = notification.content } } diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift new file mode 100644 index 00000000..63a366b4 --- /dev/null +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift @@ -0,0 +1,64 @@ +// +// PushNotificationItemTests.swift +// NotificationTabTests +// +// Created by opfic on 7/19/26. +// + +import Foundation +import Testing +@testable import NotificationTab + +struct PushNotificationItemTests { + @Test("Todo 제목이 있는 의미 기반 알림을 지연 일정 문구로 변환한다") + func todoDueTomorrowWithTitleUsesLocalizedContent() { + let notification = makePushNotification( + id: "notification-1", + number: 1, + content: .todoDueTomorrow(todoTitle: "테스트 작성") + ) + let item = PushNotificationItem(from: notification) + + #expect(item.title == String(localized: "push_notification_todo_due_title")) + #expect( + item.body == String.localizedStringWithFormat( + String(localized: "push_notification_todo_due_tomorrow_format"), + "테스트 작성" + ) + ) + } + + @Test("제목 없는 Todo 알림을 전용 문구로 변환한다") + func todoDueTomorrowWithoutTitleUsesDedicatedLocalizedContent() { + let notification = makePushNotification( + id: "notification-1", + number: 1, + content: .todoDueTomorrow(todoTitle: nil) + ) + let item = PushNotificationItem(from: notification) + + #expect(item.title == String(localized: "push_notification_todo_due_title")) + #expect(item.body == String(localized: "push_notification_todo_due_tomorrow_without_title")) + } + + @Test("빈 제목을 제목 없는 Todo 전용 문구로 변환한다") + func todoDueTomorrowWithEmptyTitleUsesDedicatedLocalizedContent() { + let notification = makePushNotification( + id: "notification-1", + number: 1, + content: .todoDueTomorrow(todoTitle: "") + ) + let item = PushNotificationItem(from: notification) + + #expect(item.body == String(localized: "push_notification_todo_due_tomorrow_without_title")) + } + + @Test("기존 알림은 저장된 제목과 본문을 그대로 사용한다") + func legacyNotificationUsesStoredTitleAndBody() { + let notification = makePushNotification(id: "notification-1", number: 1) + let item = PushNotificationItem(from: notification) + + #expect(item.title == "title-1") + #expect(item.body == "body-1") + } +} diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFixtures.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFixtures.swift index 7e1008e0..0a5dbafe 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFixtures.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationListFixtures.swift @@ -11,7 +11,8 @@ import Foundation func makePushNotification( id: String, number: Int, - isRead: Bool = false + isRead: Bool = false, + content: PushNotificationContent? = nil ) -> PushNotification { PushNotification( id: id, @@ -20,7 +21,8 @@ func makePushNotification( receivedAt: Date(timeIntervalSince1970: Double(number)), isRead: isRead, todoId: "todo-\(number)", - todoCategory: .system(.feature) + todoCategory: .system(.feature), + content: content ) } From 4afabbb31c0f215988bfd37e15ae835e8c22b4f3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Jul 2026 23:41:13 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20=EB=A7=88=EC=BC=80=ED=8C=85=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=201.4=20->=201.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Shared/Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/Shared/Version.xcconfig b/Application/Shared/Version.xcconfig index 5a114760..ffddda8d 100644 --- a/Application/Shared/Version.xcconfig +++ b/Application/Shared/Version.xcconfig @@ -1,2 +1,2 @@ -MARKETING_VERSION = 1.4 +MARKETING_VERSION = 1.5 IPHONEOS_DEPLOYMENT_TARGET = 17.0 From b5ab506aa7dff607486255219b0ffcfab903d43d Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Jul 2026 23:41:40 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20FCM=20token=EC=97=90=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=ED=98=84=EC=A7=80=ED=99=94=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Service/UserServiceImpl.swift | 19 ++++++++----- .../Tests/Service/UserServiceImplTests.swift | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 Application/Infra/Tests/Service/UserServiceImplTests.swift diff --git a/Application/Infra/Sources/Service/UserServiceImpl.swift b/Application/Infra/Sources/Service/UserServiceImpl.swift index 55b6c2d5..131c8cae 100644 --- a/Application/Infra/Sources/Service/UserServiceImpl.swift +++ b/Application/Infra/Sources/Service/UserServiceImpl.swift @@ -56,11 +56,7 @@ final class UserServiceImpl: UserService { userField["appleName"] = user.displayName } - var tokenField: [String: Any] = [:] - - if let fcmToken = response.fcmToken { - tokenField["fcmToken"] = fcmToken - } + let tokenField = Self.makeTokenField(fcmToken: response.fcmToken) try await upsertUserDocuments( uid: user.uid, @@ -144,7 +140,7 @@ final class UserServiceImpl: UserService { do { let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) - try await tokensRef.setData(["fcmToken": fcmToken], merge: true) + try await tokensRef.setData(Self.makeTokenField(fcmToken: fcmToken), merge: true) logger.info("Successfully updated FCM token") } catch { logger.error("Failed to update FCM token", error: error) @@ -176,6 +172,17 @@ final class UserServiceImpl: UserService { } } +extension UserServiceImpl { + static func makeTokenField(fcmToken: String?) -> [String: Any] { + guard let fcmToken else { return [:] } + + return [ + "fcmToken": fcmToken, + "pushLocalizationVersion": 1 + ] + } +} + private extension UserServiceImpl { private static func record(_ error: Error, code: CrashlyticsError.Code) { FirebaseCrashlyticsHelper.record( diff --git a/Application/Infra/Tests/Service/UserServiceImplTests.swift b/Application/Infra/Tests/Service/UserServiceImplTests.swift new file mode 100644 index 00000000..7cf06088 --- /dev/null +++ b/Application/Infra/Tests/Service/UserServiceImplTests.swift @@ -0,0 +1,27 @@ +// +// UserServiceImplTests.swift +// InfraTests +// +// Created by opfic on 7/19/26. +// + +import Testing +@testable import Infra + +struct UserServiceImplTests { + @Test("FCM token 필드는 현지화 지원 버전을 함께 구성한다") + func tokenField_FCM_token과_현지화_지원_버전을_구성한다() { + let field = UserServiceImpl.makeTokenField(fcmToken: "fcm-token") + + #expect(field["fcmToken"] as? String == "fcm-token") + #expect(field["pushLocalizationVersion"] as? Int == 1) + #expect(field["languageCode"] == nil) + } + + @Test("FCM token이 없으면 token 필드를 구성하지 않는다") + func tokenField_FCM_token이_없으면_비어_있다() { + let field = UserServiceImpl.makeTokenField(fcmToken: nil) + + #expect(field.isEmpty) + } +} From e7475806582fe064a966e9c352cb47b8ecce8b6a Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 20 Jul 2026 00:07:54 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EA=B3=B5=EB=B0=B1=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=EB=A7=8C=20=EA=B5=AC=EC=84=B1=EB=90=9C=20Todo=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A0=9C=EB=AA=A9=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotification/PushNotificationItem.swift | 3 ++- .../PushNotification/PushNotificationItemTests.swift | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift index 8d426ea2..22dcd342 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotification/PushNotificationItem.swift @@ -32,7 +32,8 @@ public struct PushNotificationItem: Identifiable, Hashable { public var body: String { switch content { case .todoDueTomorrow(let todoTitle): - guard let todoTitle, !todoTitle.isEmpty else { + guard let todoTitle, + !todoTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return String(localized: "push_notification_todo_due_tomorrow_without_title") } return String.localizedStringWithFormat( diff --git a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift index 63a366b4..d4ac2bb5 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotification/PushNotificationItemTests.swift @@ -53,6 +53,18 @@ struct PushNotificationItemTests { #expect(item.body == String(localized: "push_notification_todo_due_tomorrow_without_title")) } + @Test("공백만 있는 Todo 제목을 제목 없는 전용 문구로 변환한다") + func todoDueTomorrowWithWhitespaceOnlyTitleUsesDedicatedLocalizedContent() { + let notification = makePushNotification( + id: "notification-1", + number: 1, + content: .todoDueTomorrow(todoTitle: " \n\t ") + ) + let item = PushNotificationItem(from: notification) + + #expect(item.body == String(localized: "push_notification_todo_due_tomorrow_without_title")) + } + @Test("기존 알림은 저장된 제목과 본문을 그대로 사용한다") func legacyNotificationUsesStoredTitleAndBody() { let notification = makePushNotification(id: "notification-1", number: 1) From 24e2adf984c2e648a89096b926a41b73f9042801 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 20 Jul 2026 01:02:51 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20stale=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Resource/Localizable.xcstrings | 62 ++++--------------- 1 file changed, 13 insertions(+), 49 deletions(-) diff --git a/Application/App/Sources/Resource/Localizable.xcstrings b/Application/App/Sources/Resource/Localizable.xcstrings index 48ad3bf9..92dd6dd2 100644 --- a/Application/App/Sources/Resource/Localizable.xcstrings +++ b/Application/App/Sources/Resource/Localizable.xcstrings @@ -1,42 +1,6 @@ { "sourceLanguage" : "ko", "strings" : { - "" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - } - }, - "shouldTranslate" : false - }, - "#%lld" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "#%lld" - } - } - }, - "shouldTranslate" : false - }, - "%lld" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" - } - } - }, - "shouldTranslate" : false - }, "account_alert_already_linked_message" : { "extractionState" : "manual", "localizations" : { @@ -565,7 +529,7 @@ } }, "https://" : { - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2531,7 +2495,7 @@ } }, "TODO" : { - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2577,7 +2541,7 @@ }, "todo_category_doc" : { "comment" : "Todo category: Documentation", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2595,7 +2559,7 @@ }, "todo_category_etc" : { "comment" : "Todo category: Etc", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2613,7 +2577,7 @@ }, "todo_category_feature" : { "comment" : "Todo category: Feature", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2631,7 +2595,7 @@ }, "todo_category_improvement" : { "comment" : "Todo category: Improvement", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2649,7 +2613,7 @@ }, "todo_category_issue" : { "comment" : "Todo category: Issue", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2667,7 +2631,7 @@ }, "todo_category_research" : { "comment" : "Todo category: Research", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2685,7 +2649,7 @@ }, "todo_category_review" : { "comment" : "Todo category: Review", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -2703,7 +2667,7 @@ }, "todo_category_test" : { "comment" : "Todo category: Test", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -3536,7 +3500,7 @@ } }, "Todos" : { - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -3547,7 +3511,7 @@ } }, "Web Page" : { - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -3558,7 +3522,7 @@ } }, "Web Pages" : { - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : {