diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6ea8301..0139367 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: run: | mkdir -p Projects/App/Resources if [ ! -f Projects/App/Resources/Common.xcconfig ]; then - printf "BASE_URL = https://hellodoriworld.com\nKAKAO_NATIVE_APP_KEY =\n" > Projects/App/Resources/Common.xcconfig + printf 'BASE_URL = https:/$()/hellodoriworld.com\nKAKAO_NATIVE_APP_KEY =\n' > Projects/App/Resources/Common.xcconfig fi echo "✅ xcconfig file is ready" diff --git a/.github/workflows/deploy_release.yml b/.github/workflows/deploy_release.yml index 1670343..e6ecdf9 100644 --- a/.github/workflows/deploy_release.yml +++ b/.github/workflows/deploy_release.yml @@ -55,7 +55,7 @@ jobs: - name: Prepare xcconfig run: | mkdir -p Projects/App/Resources - printf "BASE_URL = ${{ vars.BASE_URL }}\nKAKAO_NATIVE_APP_KEY = ${{ secrets.KAKAO_NATIVE_APP_KEY }}\n" \ + printf 'BASE_URL = ${{ vars.BASE_URL }}\nKAKAO_NATIVE_APP_KEY = ${{ secrets.KAKAO_NATIVE_APP_KEY }}\n' \ > Projects/App/Resources/Common.xcconfig - name: Install Tuist dependencies diff --git a/.github/workflows/deploy_testflight.yml b/.github/workflows/deploy_testflight.yml index 2c6c9ac..41a4aae 100644 --- a/.github/workflows/deploy_testflight.yml +++ b/.github/workflows/deploy_testflight.yml @@ -96,8 +96,10 @@ jobs: - name: Prepare xcconfig run: | mkdir -p Projects/App/Resources - printf "BASE_URL = ${{ vars.BASE_URL }}\nKAKAO_NATIVE_APP_KEY = ${{ secrets.KAKAO_NATIVE_APP_KEY }}\nPROFILE_NAME = ${PROVISIONING_PROFILE_SPECIFIER}\n" \ + printf 'BASE_URL = ${{ vars.BASE_URL }}\nKAKAO_NATIVE_APP_KEY = ${{ secrets.KAKAO_NATIVE_APP_KEY }}\n' \ > Projects/App/Resources/Common.xcconfig + printf 'PROFILE_NAME = %s\n' "${PROVISIONING_PROFILE_SPECIFIER}" \ + >> Projects/App/Resources/Common.xcconfig - name: Install Tuist dependencies run: tuist install diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 5333e6c..6f0a2a7 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -20,6 +20,7 @@ let project = Project.dori( DoriModules.calendar.module.projectDependency, DoriModules.history.module.projectDependency, DoriModules.myPage.module.projectDependency, + DoriModules.notification.module.projectDependency, DoriModules.network.module.projectDependency, DoriModules.networkImpl.module.projectDependency, DoriModules.kakaoAuth.module.projectDependency, diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index 1368034..27006cd 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -15,6 +15,7 @@ import FeatureOnboarding import FeatureAddDori import FeatureHistory import FeatureCalendar +import FeatureNotification import PlatformKakaoAuth import PlatformKeychain import PlatformFCM @@ -79,6 +80,7 @@ struct DoriApp: App { ) $0.fcmPushTestAPIClient = .live(networkService: networkService) $0.notificationSettingsAPIClient = .live(networkService: networkService) + $0.notificationListAPIClient = .live(networkService: networkService) } storeBox.store = store diff --git a/Projects/App/Sources/MainTabView.swift b/Projects/App/Sources/MainTabView.swift index ec8bd06..a81ec6d 100644 --- a/Projects/App/Sources/MainTabView.swift +++ b/Projects/App/Sources/MainTabView.swift @@ -28,6 +28,7 @@ struct MainTabFeature { var isTabBarVisible: Bool { history.path.isEmpty && calendar.addDori == nil && + calendar.notificationList == nil && myPage.navigationPath.isEmpty } } diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notificaiton_off.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notificaiton_off.imageset/Contents.json new file mode 100644 index 0000000..188625f --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notificaiton_off.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "notificaiton_off.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notificaiton_off.imageset/notificaiton_off.png b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notificaiton_off.imageset/notificaiton_off.png new file mode 100644 index 0000000..03eeeb8 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notificaiton_off.imageset/notificaiton_off.png differ diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notification_setting.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notification_setting.imageset/Contents.json new file mode 100644 index 0000000..5dd6902 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notification_setting.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "notification_setting.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notification_setting.imageset/notification_setting.png b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notification_setting.imageset/notification_setting.png new file mode 100644 index 0000000..24d8bff Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Notification/notification_setting.imageset/notification_setting.png differ diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriEmptyView.swift b/Projects/Core/DoriDesignSystem/Sources/DoriEmptyView.swift index 4783421..bbd0296 100644 --- a/Projects/Core/DoriDesignSystem/Sources/DoriEmptyView.swift +++ b/Projects/Core/DoriDesignSystem/Sources/DoriEmptyView.swift @@ -67,6 +67,13 @@ public extension DoriEmptyView.Content { title: "검색된 도리가 없어요.", description: "해당 도리의 기록을 찾을 수 없어요.\n다른 이름으로 검색해보세요." ) + + /// 알림함이 비어 있을 때 + static let notificationList = DoriEmptyView.Content( + image: UIAsset.Images.placeholderEmpty.image, + title: "아직 도착한 알림이 없어요", + description: "새로운 소식이 오면 여기에서 알려드릴게요!" + ) } // MARK: - Preview diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift index f22a64e..563dbc8 100644 --- a/Projects/Feature/Calendar/Sources/CalendarFeature.swift +++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift @@ -8,6 +8,7 @@ import ComposableArchitecture import Foundation import FeatureAddDori +import FeatureNotification import DoriCore @Reducer @@ -17,6 +18,7 @@ public struct CalendarFeature { @ObservableState public struct State: Equatable, Sendable { @Presents public var addDori: AddDoriFeature.State? + @Presents public var notificationList: NotificationListFeature.State? public var currentMonth: Date public var selectedType: TransactionType = .judori @@ -44,7 +46,9 @@ public struct CalendarFeature { public enum Action: Equatable, Sendable { case onAppear case fabTapped + case notificationBellTapped case addDori(PresentationAction) + case notificationList(PresentationAction) case goToPreviousMonth case goToNextMonth case selectedTypeChanged(TransactionType) @@ -67,6 +71,13 @@ public struct CalendarFeature { state.addDori = AddDoriFeature.State() return .none + case .notificationBellTapped: + state.notificationList = NotificationListFeature.State() + return .none + + case .notificationList: + return .none + case .addDori(.presented(.delegate(.doriCreated))): state.addDori = nil return .none @@ -137,6 +148,9 @@ public struct CalendarFeature { .ifLet(\.$addDori, action: \.addDori) { AddDoriFeature() } + .ifLet(\.$notificationList, action: \.notificationList) { + NotificationListFeature() + } } private func fetchMonthlyData(month: Date, type: TransactionType) -> Effect { diff --git a/Projects/Feature/Calendar/Sources/CalendarView.swift b/Projects/Feature/Calendar/Sources/CalendarView.swift index 3364158..113017c 100644 --- a/Projects/Feature/Calendar/Sources/CalendarView.swift +++ b/Projects/Feature/Calendar/Sources/CalendarView.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import DoriDesignSystem import FeatureAddDori +import FeatureNotification public struct CalendarView: View { @Bindable var store: StoreOf @@ -64,7 +65,17 @@ public struct CalendarView: View { } .scrollDisabled(true) .background(.bgPrimary) - .doriNavigationBar(DoriNavigationBarConfig.titleWithActions("캘린더")) + .doriNavigationBar( + DoriNavigationBarConfig.titleWithActions( + "캘린더", + trailing: [ + .iconButton( + image: UIAsset.Icons.notificaitonOff.image.renderingMode(.template), + action: { store.send(.notificationBellTapped) } + ) + ] + ) + ) .onAppear { store.send(.onAppear) } .overlay(alignment: .bottomTrailing) { FloatingActionButton { @@ -77,6 +88,11 @@ public struct CalendarView: View { ) { addDoriStore in AddDoriView(store: addDoriStore) } + .navigationDestination( + item: $store.scope(state: \.notificationList, action: \.notificationList) + ) { notificationStore in + NotificationListView(store: notificationStore) + } .sheet( isPresented: Binding( get: { store.selectedDay != nil }, diff --git a/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/test_calendarGrid_emptyMonth_dark.1.png b/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/test_calendarGrid_emptyMonth_dark.1.png index 04c7dfd..91a516e 100644 Binary files a/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/test_calendarGrid_emptyMonth_dark.1.png and b/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/test_calendarGrid_emptyMonth_dark.1.png differ diff --git a/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/test_calendarGrid_emptyMonth_light.1.png b/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/test_calendarGrid_emptyMonth_light.1.png index 5ebc619..18e5393 100644 Binary files a/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/test_calendarGrid_emptyMonth_light.1.png and b/Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/test_calendarGrid_emptyMonth_light.1.png differ diff --git a/Projects/Feature/MyPage/Sources/MyPageFeature.swift b/Projects/Feature/MyPage/Sources/MyPageFeature.swift index b7043c5..72f45e3 100644 --- a/Projects/Feature/MyPage/Sources/MyPageFeature.swift +++ b/Projects/Feature/MyPage/Sources/MyPageFeature.swift @@ -8,6 +8,7 @@ import ComposableArchitecture import DoriDesignSystem import DoriNetwork +import FeatureNotification import Foundation import PlatformKeychain diff --git a/Projects/Feature/MyPage/Sources/MyPageView.swift b/Projects/Feature/MyPage/Sources/MyPageView.swift index 2f2b695..9173e07 100644 --- a/Projects/Feature/MyPage/Sources/MyPageView.swift +++ b/Projects/Feature/MyPage/Sources/MyPageView.swift @@ -8,6 +8,7 @@ import ComposableArchitecture import DoriCore import DoriDesignSystem +import FeatureNotification import SwiftUI public struct MyPageView: View { diff --git a/Projects/Feature/MyPage/Tests/Snapshot/DoriToggleSwitchSnapshotTests.swift b/Projects/Feature/MyPage/Tests/Snapshot/DoriToggleSwitchSnapshotTests.swift index abd1ebd..4aaa9e9 100644 --- a/Projects/Feature/MyPage/Tests/Snapshot/DoriToggleSwitchSnapshotTests.swift +++ b/Projects/Feature/MyPage/Tests/Snapshot/DoriToggleSwitchSnapshotTests.swift @@ -1,3 +1,4 @@ +import FeatureNotification import SnapshotTesting import SwiftUI import XCTest diff --git a/Projects/Feature/MyPage/Tests/Snapshot/NotificationSettingsSnapshotTests.swift b/Projects/Feature/MyPage/Tests/Snapshot/NotificationSettingsSnapshotTests.swift index 0e20403..1505a5c 100644 --- a/Projects/Feature/MyPage/Tests/Snapshot/NotificationSettingsSnapshotTests.swift +++ b/Projects/Feature/MyPage/Tests/Snapshot/NotificationSettingsSnapshotTests.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import FeatureNotification import SnapshotTesting import SwiftUI import XCTest diff --git a/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift b/Projects/Feature/Notification/Sources/DoriToggleSwitch.swift similarity index 89% rename from Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift rename to Projects/Feature/Notification/Sources/DoriToggleSwitch.swift index 0989962..626cef7 100644 --- a/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift +++ b/Projects/Feature/Notification/Sources/DoriToggleSwitch.swift @@ -8,14 +8,18 @@ import DoriDesignSystem import SwiftUI -struct DoriToggleSwitch: View { +public struct DoriToggleSwitch: View { @Binding var isOn: Bool private let width: CGFloat = 51 private let height: CGFloat = 31 private let thumbSize: CGFloat = 23 - var body: some View { + public init(isOn: Binding) { + self._isOn = isOn + } + + public var body: some View { ZStack { Capsule() .fill(isOn ? UIAsset.Colors.brandMain.color : UIAsset.Colors.borderInput.color) diff --git a/Projects/Feature/Notification/Sources/NotificationListAPIClient.swift b/Projects/Feature/Notification/Sources/NotificationListAPIClient.swift new file mode 100644 index 0000000..92db0ae --- /dev/null +++ b/Projects/Feature/Notification/Sources/NotificationListAPIClient.swift @@ -0,0 +1,99 @@ +// +// NotificationListAPIClient.swift +// Dori-iOS +// +// Created by 강동영 on 6/11/26. +// + +import ComposableArchitecture +import DoriNetwork +import Foundation + +// MARK: - API Client + +@DependencyClient +public struct NotificationListAPIClient: Sendable { + /// 알림함 목록 조회. cursor 가 nil 이면 첫 페이지. + public var fetchNotifications: @Sendable (_ cursor: String?, _ size: Int) async throws -> NotificationPageResponse +} + +enum NotificationListAPIClientError: LocalizedError { + case unconfigured + case invalidResponse + case backendError(String) + + var errorDescription: String? { + switch self { + case .unconfigured: + return "NotificationListAPIClient가 구성되지 않았습니다." + case .invalidResponse: + return "서버 응답이 올바르지 않습니다." + case .backendError(let message): + return message + } + } +} + +extension NotificationListAPIClient: DependencyKey { + public static let liveValue = Self( + fetchNotifications: { _, _ in throw NotificationListAPIClientError.unconfigured } + ) +} + +extension NotificationListAPIClient: TestDependencyKey { + public static let previewValue = Self( + fetchNotifications: { _, _ in + NotificationPageResponse( + items: [ + NotificationItemResponse( + id: 10, + typeCode: "DORI_ALERT", + title: "결혼식 알림", + body: "내일 홍길동님의 결혼식 일정이 있어요!", + targetType: "DORI", + targetId: 10, + partnerId: 2, + readAt: nil, + pushedAt: "2026-05-26T14:00:26.794863464", + createdAt: "2026-05-26T14:00:26.794884463" + ) + ], + size: 20, + nextCursor: nil, + hasNext: false + ) + } + ) + + public static let testValue = Self() +} + +public extension NotificationListAPIClient { + static func live(networkService: any NetworkService) -> Self { + Self( + fetchNotifications: { cursor, size in + let endpoint = FetchNotificationsEndpoint(cursor: cursor, size: size) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + if let apiError = response.error { + throw NotificationListAPIClientError.backendError( + apiError.message ?? "알림 목록 조회에 실패했습니다." + ) + } + guard response.success, let data = response.data else { + throw NotificationListAPIClientError.invalidResponse + } + return data + } + ) + } +} + +public extension DependencyValues { + var notificationListAPIClient: NotificationListAPIClient { + get { self[NotificationListAPIClient.self] } + set { self[NotificationListAPIClient.self] = newValue } + } +} diff --git a/Projects/Feature/Notification/Sources/NotificationListFeature.swift b/Projects/Feature/Notification/Sources/NotificationListFeature.swift new file mode 100644 index 0000000..84ff447 --- /dev/null +++ b/Projects/Feature/Notification/Sources/NotificationListFeature.swift @@ -0,0 +1,112 @@ +// +// NotificationListFeature.swift +// Dori-iOS +// +// Created by 강동영 on 6/11/26. +// + +import ComposableArchitecture +import DoriNetwork +import Foundation + +@Reducer +public struct NotificationListFeature { + public init() {} + + private static let pageSize = 20 + + // MARK: - State + + @ObservableState + public struct State: Equatable, Sendable { + @Presents public var notificationSettings: NotificationSettingsFeature.State? + + public var items: [NotificationItemResponse] = [] + public var nextCursor: String? + public var hasNext: Bool = true + public var isLoading: Bool = false + public var errorMessage: String? + + public init() {} + } + + // MARK: - Action + + public enum Action: Equatable, Sendable { + case onAppear + case loadNextPageIfNeeded(NotificationItemResponse) + case notificationsResponse(Result) + case settingsButtonTapped + case notificationSettings(PresentationAction) + } + + public struct APIError: Error, Equatable, Sendable { + public let message: String + public init(message: String) { self.message = message } + } + + // MARK: - Dependencies + + @Dependency(\.notificationListAPIClient) var apiClient + + // MARK: - Reducer + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + guard state.items.isEmpty, !state.isLoading else { return .none } + return fetch(state: &state) + + case let .loadNextPageIfNeeded(item): + guard state.hasNext, !state.isLoading else { return .none } + guard item.id == state.items.last?.id else { return .none } + return fetch(state: &state) + + case let .notificationsResponse(.success(page)): + state.isLoading = false + state.errorMessage = nil + state.items.append(contentsOf: page.items) + state.nextCursor = page.nextCursor + state.hasNext = page.hasNext + return .none + + case let .notificationsResponse(.failure(error)): + state.isLoading = false + state.errorMessage = error.message + return .none + + case .settingsButtonTapped: + state.notificationSettings = NotificationSettingsFeature.State() + return .none + + case .notificationSettings(.presented(.delegate(.didTapBack))): + state.notificationSettings = nil + return .none + + case .notificationSettings: + return .none + } + } + .ifLet(\.$notificationSettings, action: \.notificationSettings) { + NotificationSettingsFeature() + } + } + + // MARK: - Private + + private func fetch(state: inout State) -> Effect { + state.isLoading = true + let cursor = state.nextCursor + let size = Self.pageSize + let fetch = apiClient.fetchNotifications + return .run { send in + do { + let page = try await fetch(cursor, size) + await send(.notificationsResponse(.success(page))) + } catch { + await send(.notificationsResponse(.failure(APIError(message: error.localizedDescription)))) + } + } + } +} diff --git a/Projects/Feature/Notification/Sources/NotificationListView.swift b/Projects/Feature/Notification/Sources/NotificationListView.swift new file mode 100644 index 0000000..4635b26 --- /dev/null +++ b/Projects/Feature/Notification/Sources/NotificationListView.swift @@ -0,0 +1,86 @@ +// +// NotificationListView.swift +// Dori-iOS +// +// Created by 강동영 on 6/11/26. +// + +import SwiftUI +import ComposableArchitecture +import DoriDesignSystem +import DoriNetwork + +public struct NotificationListView: View { + @Bindable var store: StoreOf + @Environment(\.dismiss) private var dismiss + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(store.items) { item in + NotificationRowView(item: item) + .onAppear { + store.send(.loadNextPageIfNeeded(item)) + } + + Rectangle() + .fill(.borderDefault) + .frame(height: 0.5) + } + + if store.isLoading && !store.items.isEmpty { + ProgressView() + .padding(.vertical, 16) + } + } + } + .overlay { + if store.items.isEmpty { + if store.isLoading { + ProgressView() + } else { + DoriEmptyView(.notificationList) + } + } + } + .background(.bgPrimary) + .doriNavigationBar( + .backWithTitleAndActions( + "알림", + onBack: { dismiss() }, + trailing: [ + .iconButton( + image: UIAsset.Icons.notificationSetting.image.renderingMode(.template), + action: { store.send(.settingsButtonTapped) } + ) + ] + ) + ) + .navigationDestination( + item: $store.scope(state: \.notificationSettings, action: \.notificationSettings) + ) { settingsStore in + NotificationSettingsView(store: settingsStore) + } + .onAppear { + store.send(.onAppear) + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + NotificationListView( + store: Store(initialState: NotificationListFeature.State()) { + NotificationListFeature() + } withDependencies: { + $0.notificationListAPIClient = .previewValue + } + ) + } +} diff --git a/Projects/Feature/Notification/Sources/NotificationRowView.swift b/Projects/Feature/Notification/Sources/NotificationRowView.swift new file mode 100644 index 0000000..961337a --- /dev/null +++ b/Projects/Feature/Notification/Sources/NotificationRowView.swift @@ -0,0 +1,91 @@ +// +// NotificationRowView.swift +// Dori-iOS +// +// Created by 강동영 on 6/11/26. +// + +import SwiftUI +import DoriDesignSystem +import DoriNetwork + +struct NotificationRowView: View { + let item: NotificationItemResponse + + private var isUnread: Bool { item.readAt == nil } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + // 읽지 않은 알림 표시 점 + Circle() + .fill(isUnread ? UIAsset.Colors.brandMain.color : Color.clear) + .frame(width: 6, height: 6) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .pretendard(.body(.sb3)) + .foregroundStyle(.textPrimary) + + Text(item.body) + .pretendard(.body(.r4)) + .foregroundStyle(.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + Text(NotificationDateFormatter.displayString(from: item.pushedAt)) + .pretendard(.caption(.m2)) + .foregroundStyle(.textDisabled) + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.bgPrimary) + } +} + +// MARK: - Date Formatting + +/// 서버 타임스탬프("2026-05-26T14:00:26.794863464")를 표시용 문자열로 변환. +/// 소수초 자리수가 표준(밀리초)을 넘어 `ISO8601DateFormatter` 가 실패할 수 있으므로 +/// 소수초/타임존을 잘라낸 뒤 고정 포맷으로 파싱한다. +enum NotificationDateFormatter { + private static let parser: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return formatter + }() + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "a h:mm" + return formatter + }() + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일" + return formatter + }() + + static func displayString(from raw: String, now: Date = Date()) -> String { + guard let date = parse(raw) else { return "" } + if Calendar.current.isDate(date, inSameDayAs: now) { + return timeFormatter.string(from: date) + } + return dateFormatter.string(from: date) + } + + private static func parse(_ raw: String) -> Date? { + // 소수초("." 이후)와 타임존 표기를 잘라낸 "yyyy-MM-dd'T'HH:mm:ss" 부분만 사용 + let trimmed = String(raw.prefix(19)) + return parser.date(from: trimmed) + } +} diff --git a/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift b/Projects/Feature/Notification/Sources/NotificationSettingsFeature.swift similarity index 100% rename from Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift rename to Projects/Feature/Notification/Sources/NotificationSettingsFeature.swift diff --git a/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift b/Projects/Feature/Notification/Sources/NotificationSettingsView.swift similarity index 97% rename from Projects/Feature/MyPage/Sources/NotificationSettingsView.swift rename to Projects/Feature/Notification/Sources/NotificationSettingsView.swift index 1d7942f..db771a9 100644 --- a/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift +++ b/Projects/Feature/Notification/Sources/NotificationSettingsView.swift @@ -9,15 +9,19 @@ import ComposableArchitecture import DoriDesignSystem import SwiftUI -struct NotificationSettingsView: View { +public struct NotificationSettingsView: View { @Bindable var store: StoreOf @Environment(\.scenePhase) private var scenePhase + public init(store: StoreOf) { + self.store = store + } + private var allPushDescrition: String { store.isAllPushEnabled ? "앱 알림 받기" : "알림이 꺼져 있어요\n알림을 켜고 소식을 받아보세요" } - - var body: some View { + + public var body: some View { ZStack { UIAsset.Colors.bgPrimary.color .ignoresSafeArea() diff --git a/Projects/Feature/Notification/Tests/NotificationListFeatureTests.swift b/Projects/Feature/Notification/Tests/NotificationListFeatureTests.swift new file mode 100644 index 0000000..357a92e --- /dev/null +++ b/Projects/Feature/Notification/Tests/NotificationListFeatureTests.swift @@ -0,0 +1,184 @@ +// +// NotificationListFeatureTests.swift +// Dori-iOS +// +// Created by 강동영 on 6/11/26. +// + +import ComposableArchitecture +import DoriNetwork +import Testing + +@testable import FeatureNotification + +@MainActor +struct NotificationListFeatureTests { + + // MARK: - Helpers + + private func makeItem(id: Int, readAt: String? = nil) -> NotificationItemResponse { + NotificationItemResponse( + id: id, + typeCode: "DORI_ALERT", + title: "결혼식 알림 \(id)", + body: "내일 홍길동님의 결혼식 일정이 있어요!", + targetType: "DORI", + targetId: id, + partnerId: 2, + readAt: readAt, + pushedAt: "2026-05-26T14:00:26.794863464", + createdAt: "2026-05-26T14:00:26.794884463" + ) + } + + // MARK: - 초기 로딩 + + @Test + func onAppear_첫페이지를_로드한다() async { + let page = NotificationPageResponse( + items: [makeItem(id: 10), makeItem(id: 9)], + size: 20, + nextCursor: "cursor-2", + hasNext: true + ) + let receivedCursor = LockIsolated(nil) + + let store = TestStore(initialState: NotificationListFeature.State()) { + NotificationListFeature() + } withDependencies: { + $0.notificationListAPIClient.fetchNotifications = { cursor, _ in + receivedCursor.setValue(cursor) + return page + } + } + + await store.send(.onAppear) { + $0.isLoading = true + } + + await store.receive(\.notificationsResponse.success) { + $0.isLoading = false + $0.items = [self.makeItem(id: 10), self.makeItem(id: 9)] + $0.nextCursor = "cursor-2" + $0.hasNext = true + } + + #expect(receivedCursor.value == .some(nil)) // 첫 페이지는 cursor 없음 + } + + @Test + func onAppear_이미_로드된_경우_재요청하지_않는다() async { + let store = TestStore( + initialState: { + var state = NotificationListFeature.State() + state.items = [makeItem(id: 1)] + return state + }() + ) { + NotificationListFeature() + } + + await store.send(.onAppear) // items 비어있지 않음 → no effect + } + + // MARK: - 무한 스크롤 + + @Test + func 마지막_항목_도달시_다음_페이지를_로드한다() async { + let nextPage = NotificationPageResponse( + items: [makeItem(id: 8)], + size: 20, + nextCursor: nil, + hasNext: false + ) + let receivedCursor = LockIsolated(nil) + + let lastItem = makeItem(id: 9) + let store = TestStore( + initialState: { + var state = NotificationListFeature.State() + state.items = [makeItem(id: 10), lastItem] + state.nextCursor = "cursor-2" + state.hasNext = true + return state + }() + ) { + NotificationListFeature() + } withDependencies: { + $0.notificationListAPIClient.fetchNotifications = { cursor, _ in + receivedCursor.setValue(cursor) + return nextPage + } + } + + await store.send(.loadNextPageIfNeeded(lastItem)) { + $0.isLoading = true + } + + await store.receive(\.notificationsResponse.success) { + $0.isLoading = false + $0.items = [self.makeItem(id: 10), self.makeItem(id: 9), self.makeItem(id: 8)] + $0.nextCursor = nil + $0.hasNext = false + } + + #expect(receivedCursor.value == "cursor-2") // 다음 페이지 cursor 전달 + } + + @Test + func 마지막_항목이_아니면_로드하지_않는다() async { + let firstItem = makeItem(id: 10) + let store = TestStore( + initialState: { + var state = NotificationListFeature.State() + state.items = [firstItem, makeItem(id: 9)] + state.nextCursor = "cursor-2" + state.hasNext = true + return state + }() + ) { + NotificationListFeature() + } + + await store.send(.loadNextPageIfNeeded(firstItem)) // 마지막 아님 → no effect + } + + @Test + func hasNext가_false면_추가_로드하지_않는다() async { + let lastItem = makeItem(id: 9) + let store = TestStore( + initialState: { + var state = NotificationListFeature.State() + state.items = [makeItem(id: 10), lastItem] + state.hasNext = false + return state + }() + ) { + NotificationListFeature() + } + + await store.send(.loadNextPageIfNeeded(lastItem)) // hasNext=false → no effect + } + + // MARK: - 에러 처리 + + @Test + func 로드_실패시_에러메시지를_세팅한다() async { + struct SomeError: Error {} + + let store = TestStore(initialState: NotificationListFeature.State()) { + NotificationListFeature() + } withDependencies: { + $0.notificationListAPIClient.fetchNotifications = { _, _ in throw SomeError() } + } + + await store.send(.onAppear) { + $0.isLoading = true + } + + await store.receive(\.notificationsResponse.failure) { + $0.isLoading = false + $0.errorMessage = SomeError().localizedDescription + } + } +} diff --git a/Projects/Feature/Notification/Tests/Snapshot/NotificationListSnapshotTests.swift b/Projects/Feature/Notification/Tests/Snapshot/NotificationListSnapshotTests.swift new file mode 100644 index 0000000..b3a6583 --- /dev/null +++ b/Projects/Feature/Notification/Tests/Snapshot/NotificationListSnapshotTests.swift @@ -0,0 +1,118 @@ +import ComposableArchitecture +import DoriNetwork +import Foundation +import SnapshotTesting +import SwiftUI +import XCTest + +@testable import FeatureNotification + +@MainActor +final class NotificationListSnapshotTests: XCTestCase { + private static let mockItems: [NotificationItemResponse] = [ + NotificationItemResponse( + id: 1, + typeCode: "DORI_ALERT", + title: "결혼식 알림", + body: "내일 홍길동님의 결혼식 일정이 있어요!", + targetType: "DORI", + targetId: 10, + partnerId: 2, + readAt: nil, + pushedAt: "2026-05-26T14:00:26.794863464", + createdAt: "2026-05-26T14:00:26.794884463" + ), + NotificationItemResponse( + id: 2, + typeCode: "DORI_ALERT", + title: "생일 알림", + body: "오늘은 이순신님의 생일이에요. 도리를 보내보는 건 어떨까요?", + targetType: "DORI", + targetId: 11, + partnerId: 3, + readAt: "2026-05-25T10:00:00", + pushedAt: "2026-05-25T09:00:00.000000000", + createdAt: "2026-05-25T09:00:00.000000000" + ), + NotificationItemResponse( + id: 3, + typeCode: "DORI_ALERT", + title: "돌잔치 알림", + body: "강감찬님 자녀의 돌잔치가 3일 후에 있어요!", + targetType: "DORI", + targetId: 12, + partnerId: 4, + readAt: nil, + pushedAt: "2026-05-24T08:30:00.000000000", + createdAt: "2026-05-24T08:30:00.000000000" + ) + ] + + private func makeEmptyView() -> some View { + NavigationStack { + NotificationListView( + store: Store(initialState: NotificationListFeature.State()) { + NotificationListFeature() + } withDependencies: { + $0.notificationListAPIClient = .previewValue + } + ) + } + } + + private func makePopulatedView() -> some View { + var state = NotificationListFeature.State() + state.items = Self.mockItems + state.hasNext = false + + return NavigationStack { + NotificationListView( + store: Store(initialState: state) { + NotificationListFeature() + } withDependencies: { + $0.notificationListAPIClient = .testValue + } + ) + } + } + + func test_notificationList_empty_light() { + assertSnapshot( + of: makeEmptyView(), + as: .image( + layout: .fixed(width: 393, height: 852), + traits: UITraitCollection(userInterfaceStyle: .light) + ) + ) + } + + func test_notificationList_empty_dark() { + assertSnapshot( + of: makeEmptyView(), + as: .image( + layout: .fixed(width: 393, height: 852), + traits: UITraitCollection(userInterfaceStyle: .dark) + ) + ) + } + + func test_notificationList_populated_light() { + assertSnapshot( + of: makePopulatedView(), + as: .image( + layout: .fixed(width: 393, height: 852), + traits: UITraitCollection(userInterfaceStyle: .light) + ) + ) + } + + func test_notificationList_populated_dark() { + assertSnapshot( + of: makePopulatedView(), + as: .image( + layout: .fixed(width: 393, height: 852), + traits: UITraitCollection(userInterfaceStyle: .dark) + ) + ) + } +} diff --git a/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_empty_dark.1.png b/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_empty_dark.1.png new file mode 100644 index 0000000..2aafad9 Binary files /dev/null and b/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_empty_dark.1.png differ diff --git a/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_empty_light.1.png b/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_empty_light.1.png new file mode 100644 index 0000000..12e21fa Binary files /dev/null and b/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_empty_light.1.png differ diff --git a/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_populated_dark.1.png b/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_populated_dark.1.png new file mode 100644 index 0000000..e601590 Binary files /dev/null and b/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_populated_dark.1.png differ diff --git a/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_populated_light.1.png b/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_populated_light.1.png new file mode 100644 index 0000000..5a6a416 Binary files /dev/null and b/Projects/Feature/Notification/Tests/Snapshot/__Snapshots__/NotificationListSnapshotTests/test_notificationList_populated_light.1.png differ diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift index c7ea2d7..0cb9f3d 100644 --- a/Projects/Feature/Project.swift +++ b/Projects/Feature/Project.swift @@ -50,6 +50,7 @@ let project = Project.dori( DoriModules.calendar.module, dependencies: [ DoriModules.addDori.module.targetDependency, + DoriModules.notification.module.targetDependency, DoriModules.designSystem.module.projectDependency, DoriModules.core.module.projectDependency, DoriModules.network.module.projectDependency, @@ -85,6 +86,7 @@ let project = Project.dori( .doriFramework( DoriModules.myPage.module, dependencies: [ + DoriModules.notification.module.targetDependency, DoriModules.designSystem.module.projectDependency, DoriModules.network.module.projectDependency, DoriModules.keychain.module.projectDependency, @@ -94,6 +96,24 @@ let project = Project.dori( ), .doriUnitTests( DoriModules.myPage.module, + dependencies: [ + DoriModules.notification.module.targetDependency, + DoriModules.testSupport.module.projectDependency, + .external(.composableArchitecture), + .external(.snapshotTesting), + ] + ), + .doriFramework( + DoriModules.notification.module, + dependencies: [ + DoriModules.designSystem.module.projectDependency, + DoriModules.core.module.projectDependency, + DoriModules.network.module.projectDependency, + .external(.composableArchitecture) + ] + ), + .doriUnitTests( + DoriModules.notification.module, dependencies: [ DoriModules.testSupport.module.projectDependency, .external(.composableArchitecture), diff --git a/Projects/Infra/DoriNetwork/Sources/Endpoints/NotificationListEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Endpoints/NotificationListEndpoints.swift new file mode 100644 index 0000000..7ba06cb --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Endpoints/NotificationListEndpoints.swift @@ -0,0 +1,35 @@ +// +// NotificationListEndpoints.swift +// Dori-iOS +// +// Created by 강동영 on 6/11/26. +// + +import Foundation + +/// 알림함 목록 조회 (GET /notifications) +/// SENT 알림만 노출, 최신순, cursor 페이징. +public struct FetchNotificationsEndpoint: Endpoint { + public let baseURL: String + public let path: String = "/notifications" + public let method: HTTPMethod = .GET + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + /// - Parameters: + /// - cursor: 이전 응답의 nextCursor. 첫 페이지는 nil (쿼리에서 생략). + /// - size: 페이지 크기 (기본 20). + public init( + cursor: String?, + size: Int, + baseURL: String = NetworkConfig.baseURL + ) { + self.baseURL = baseURL + var query = ["size": String(size)] + if let cursor { + query["cursor"] = cursor + } + self.queryParameters = query + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/NotificationListResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/NotificationListResponses.swift new file mode 100644 index 0000000..1e3e9a5 --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Responses/NotificationListResponses.swift @@ -0,0 +1,69 @@ +// +// NotificationListResponses.swift +// Dori-iOS +// +// Created by 강동영 on 6/11/26. +// + +import Foundation + +/// 알림함 단일 항목 (GET /notifications 의 data.items[]) +/// +/// 응답의 중첩 `data` 객체(type/doriId/daysLeft 등)는 현재 화면 범위(항목 탭 동작 없음)에서 +/// 사용하지 않으므로 디코딩하지 않는다. Decodable 은 정의되지 않은 키를 무시한다. +public struct NotificationItemResponse: Decodable, Equatable, Sendable, Identifiable { + public let id: Int + public let typeCode: String + public let title: String + public let body: String + public let targetType: String? + public let targetId: Int? + public let partnerId: Int? + public let readAt: String? + public let pushedAt: String + public let createdAt: String + + public init( + id: Int, + typeCode: String, + title: String, + body: String, + targetType: String? = nil, + targetId: Int? = nil, + partnerId: Int? = nil, + readAt: String? = nil, + pushedAt: String, + createdAt: String + ) { + self.id = id + self.typeCode = typeCode + self.title = title + self.body = body + self.targetType = targetType + self.targetId = targetId + self.partnerId = partnerId + self.readAt = readAt + self.pushedAt = pushedAt + self.createdAt = createdAt + } +} + +/// 알림함 cursor 페이지 (GET /notifications 의 data) +public struct NotificationPageResponse: Decodable, Equatable, Sendable { + public let items: [NotificationItemResponse] + public let size: Int + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [NotificationItemResponse], + size: Int, + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.size = size + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Tuist/ProjectDescriptionHelpers/DoriTargets.swift b/Tuist/ProjectDescriptionHelpers/DoriTargets.swift index 6b7480d..b10af4f 100644 --- a/Tuist/ProjectDescriptionHelpers/DoriTargets.swift +++ b/Tuist/ProjectDescriptionHelpers/DoriTargets.swift @@ -70,6 +70,7 @@ public enum DoriModules: CaseIterable, Sendable { case history case myPage case addDori + case notification public var module: DoriModule { switch self { @@ -99,6 +100,8 @@ public enum DoriModules: CaseIterable, Sendable { DoriModule(name: "FeatureMyPage", layer: .feature, directoryName: "MyPage") case .addDori: DoriModule(name: "FeatureAddDori", layer: .feature, directoryName: "AddDori") + case .notification: + DoriModule(name: "FeatureNotification", layer: .feature, directoryName: "Notification") } } } diff --git a/docs/decision/notification/notificationList/Implementation.md b/docs/decision/notification/notificationList/Implementation.md new file mode 100644 index 0000000..f98ce8c --- /dev/null +++ b/docs/decision/notification/notificationList/Implementation.md @@ -0,0 +1,47 @@ +# Implementation — 알림 리스트 화면 추가 (#33) + +> 짝 문서: 같은 경로의 [`PLAN.md`](./PLAN.md) +> 브랜치: `feat/33-notification-list` (base `develop`) +> 관련 이슈: [do-ri/iOS#33](https://github.com/do-ri/iOS/issues/33) + +## 1. What + +캘린더 nav 우상단 종 아이콘 → 탭 → 알림 리스트 화면(push) → `GET /notifications` cursor 무한 스크롤. + +### 확정 결정 (사용자) +- 종 아이콘: **단일 고정** (배지 on/off 로직은 후속) +- 항목 탭: **동작 없음** +- 페이징: **cursor 무한 스크롤** +- 모듈: **새 `Projects/Feature/Notification`** + +### 신규 파일 +- `Projects/Infra/DoriNetwork/Sources/Endpoints/NotificationListEndpoints.swift` — `FetchNotificationsEndpoint(cursor:size:)`, 첫 페이지는 cursor 생략 +- `Projects/Infra/DoriNetwork/Sources/Responses/NotificationListResponses.swift` — `NotificationItemResponse`, `NotificationPageResponse`. 응답의 중첩 `data` 객체는 미디코딩(범위 외) +- `Projects/Feature/Notification/Sources/NotificationListAPIClient.swift` — `NotificationSettingsAPIClient` 패턴 복제. `live(networkService:)` + `SuccessResponse` +- `Projects/Feature/Notification/Sources/NotificationListFeature.swift` — cursor 무한 스크롤 reducer +- `Projects/Feature/Notification/Sources/NotificationListView.swift` — 목록 + 빈 상태 + 초기/추가 로딩 인디케이터 +- `Projects/Feature/Notification/Sources/NotificationRowView.swift` — 제목/본문/시간 + 안읽음 점. 고정밀 타임스탬프는 소수초 절단 후 파싱 +- `Projects/Feature/Notification/Tests/NotificationListFeatureTests.swift` — 6 케이스 + +### 수정 파일 +- `Tuist/ProjectDescriptionHelpers/DoriTargets.swift` — `DoriModules.notification` 추가 +- `Projects/Feature/Project.swift` — Notification framework/test 타깃 + Calendar 의존(`notification.targetDependency`) +- `Projects/App/Project.swift` — App → notification 의존 +- `Projects/App/Sources/DoriApp.swift` — `notificationListAPIClient = .live(networkService:)` +- `Projects/Feature/Calendar/Sources/CalendarFeature.swift` — `@Presents notificationList` + `notificationBellTapped` + `.ifLet` +- `Projects/Feature/Calendar/Sources/CalendarView.swift` — 종 아이콘 trailing(`UIAsset.Icons.notificaitonOff`) + `navigationDestination` +- `Projects/Core/DoriDesignSystem/Sources/DoriEmptyView.swift` — `notificationList` empty content +- `Projects/Feature/Calendar/Tests/Snapshot/__Snapshots__/CalendarGridSnapshotTests/*.png` — 종 아이콘 추가에 따른 의도된 baseline 재기록(light/dark) + +## 2. 검증 기록 +- 빌드: `xcodebuild build -scheme DoriApp` → **BUILD SUCCEEDED** +- 유닛: `FeatureNotificationTests` 6/6 PASS (첫 로드 / 재진입 가드 / 무한스크롤 다음페이지 / 마지막항목 아님 가드 / hasNext=false 가드 / 에러 errorMessage) +- 회귀: `FeatureCalendarTests` 8/8 PASS (CalendarGrid baseline 2장 재기록 → replay 통과) +- 시각: 종 아이콘 nav 렌더 확인. 알림 리스트 목록(제목/본문/날짜/안읽음 점/구분선) + 무한스크롤 스피너 + 빈상태 로딩→empty 전환 확인 (throwaway 스냅샷, 커밋 제외) + +## 3. 보류 / 후속 +- **종 배지(on/off)**: unread 개수/존재 API 부재로 단일 아이콘 고정. unread API 확정 후 별건. +- **항목 탭 네비게이션**: targetType/targetId 보유. 도리 상세 연결은 별건. +- **타임존**: `pushedAt`에 오프셋 없음 → `timeZone = .current`로 파싱. 서버 기준 확정 시 재검토. +- **아이콘 렌더링**: imageset이 template 아님 → `foregroundStyle` 재색 미적용. 라이트 강제 상태라 무방. 다크 활성화 시 dark variant 검토. +- **스냅샷 baseline**: 알림 리스트 light/dark 정식 baseline은 피그마 spec 확정 후 별건(`test-strategy.md`). diff --git a/docs/decision/notification/notificationList/PLAN.md b/docs/decision/notification/notificationList/PLAN.md new file mode 100644 index 0000000..450dc46 --- /dev/null +++ b/docs/decision/notification/notificationList/PLAN.md @@ -0,0 +1,49 @@ +# 알림 리스트 화면 추가 (#33) + +> 짝 문서: 같은 경로의 [`Implementation.md`](./Implementation.md) +> 브랜치: `feat/33-notification-list` (base `develop`) +> 관련 이슈: [do-ri/iOS#33](https://github.com/do-ri/iOS/issues/33) + +## Context + +캘린더 nav 우상단에 알림 진입점이 없었다. 본 작업은 (1) 캘린더 nav 우상단 종 아이콘, (2) 탭 시 알림 리스트 화면 push, (3) `GET /notifications`(cursor 페이징, 최신순) 조회로 목록/빈 상태 렌더를 추가한다. + +알림 *설정*은 MyPage에 있으나 알림 *리스트*는 캘린더에서 진입하는 독립 흐름이라 새 `Feature/Notification` 모듈로 분리한다. + +## 확정 결정 (사용자) + +| 항목 | 결정 | +|---|---| +| 종 아이콘 on/off 배지 | 단일 아이콘 고정 (unread API 부재, 배지 로직 후속) | +| 항목 탭 | 동작 없음 (목록 표시까지) | +| 페이징 | cursor 무한 스크롤 (`nextCursor`/`hasNext`) | +| 모듈 위치 | 새 `Projects/Feature/Notification` | + +## Scope + +### In +- DoriNetwork: `FetchNotificationsEndpoint`(cursor+size), `NotificationItemResponse`/`NotificationPageResponse` +- 새 Feature/Notification 모듈: Client(`NotificationSettingsAPIClient` 패턴) + Feature(무한스크롤) + View(목록/빈상태/로딩) + Row +- Calendar 통합: `@Presents notificationList` + `navigationDestination` + 종 아이콘 trailing (addDori push 패턴 복제) +- App 조합 client 주입, Tuist 모듈/의존 등록 + +### Out +- 종 배지 on/off 로직, 항목 탭 네비게이션, 읽음 처리 API +- 알림 리스트 정식 스냅샷 baseline (피그마 spec 확정 후) +- `.preferredColorScheme(.light)` 해제 + +## Verification +1. `xcodebuild build -scheme DoriApp` 성공 +2. `FeatureNotificationTests` 로드/페이징/에러 PASS +3. `FeatureCalendarTests` 회귀 PASS (종 아이콘 추가로 CalendarGrid baseline 재기록) +4. 시뮬레이터: 캘린더 → 종 탭 → 리스트(목록/빈상태) 확인 + +## Risk +- `pushedAt` 9자리 소수초 → 표준 ISO8601 파서 실패 가능 → 소수초 절단 후 고정 포맷 파싱 +- 아이콘 imageset이 template 아님 → nav `foregroundStyle` 재색 미적용 (라이트 강제라 무방) +- Calendar→Notification 단방향 의존 신설 + +## Related +- `docs/decision/calendarRedesign/redesignCalendarScreen/` — 캘린더 nav/스냅샷 재기록 패턴 +- `Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift` — Client 패턴 출처 +- `Projects/Feature/History/Sources/DoriList/` — 리스트/페이징 패턴 출처