From af8813685eb929c3aafb9c21e4859fbe2e767a88 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 12 Mar 2026 18:35:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=ED=95=A8=20=EC=A7=84=EC=9E=85=20=EB=B0=8F=20=EB=94=A5=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EB=9D=BC=EC=9A=B0=ED=8C=85=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ic_bell.imageset/Contents.json | 21 ++ .../ic_bell.imageset/ic_bell.png | Bin 0 -> 1052 bytes .../ic_bell_settings.imageset/Contents.json | 21 ++ .../ic_bell_settings.png | Bin 0 -> 1576 bytes SodaLive/Resources/Localizable.xcstrings | 70 ++++--- SodaLive/Sources/App/AppDeepLinkHandler.swift | 190 ++++++++++++++++++ SodaLive/Sources/App/AppState.swift | 7 + SodaLive/Sources/App/AppStep.swift | 2 + SodaLive/Sources/App/SodaLiveApp.swift | 4 + SodaLive/Sources/ContentView.swift | 3 + SodaLive/Sources/Home/HomeTabView.swift | 6 + .../GetPushNotificationCategoryResponse.swift | 5 + .../GetPushNotificationListResponse.swift | 42 ++++ .../List/PushNotificationApi.swift | 50 +++++ .../List/PushNotificationListItemView.swift | 50 +++++ .../List/PushNotificationListView.swift | 97 +++++++++ .../List/PushNotificationListViewModel.swift | 173 ++++++++++++++++ .../List/PushNotificationRepository.swift | 17 ++ SodaLive/Sources/Splash/SplashView.swift | 8 + docs/20260312_알림리스트구현.md | 54 +++++ 20 files changed, 788 insertions(+), 32 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/ic_bell.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_bell.imageset/ic_bell.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_bell_settings.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_bell_settings.imageset/ic_bell_settings.png create mode 100644 SodaLive/Sources/App/AppDeepLinkHandler.swift create mode 100644 SodaLive/Sources/Notification/List/GetPushNotificationCategoryResponse.swift create mode 100644 SodaLive/Sources/Notification/List/GetPushNotificationListResponse.swift create mode 100644 SodaLive/Sources/Notification/List/PushNotificationApi.swift create mode 100644 SodaLive/Sources/Notification/List/PushNotificationListItemView.swift create mode 100644 SodaLive/Sources/Notification/List/PushNotificationListView.swift create mode 100644 SodaLive/Sources/Notification/List/PushNotificationListViewModel.swift create mode 100644 SodaLive/Sources/Notification/List/PushNotificationRepository.swift create mode 100644 docs/20260312_알림리스트구현.md diff --git a/SodaLive/Resources/Assets.xcassets/ic_bell.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_bell.imageset/Contents.json new file mode 100644 index 0000000..94b91ab --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_bell.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_bell.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_bell.imageset/ic_bell.png b/SodaLive/Resources/Assets.xcassets/ic_bell.imageset/ic_bell.png new file mode 100644 index 0000000000000000000000000000000000000000..d3675137b575b03ab070f8f774d1f23416e99a08 GIT binary patch literal 1052 zcmV+%1mpXOP)A+kR;ibTq7RE8FgD+fzU8Z8Mk)Zm_mJ zkUaNO2svs9+W~&DF9rPk)n;C;D(5Rw9(F}s8ww#;rz zi*-g)K*MZJsdg@)*c>R9P<1o&e>=875kvJ)(gSBn7O*E-z@E%jK&g{y5dkAt92N9Y zFDy?RLnLIKuW-m%zlDC zDrOr9Fq8+rKp#Aza0U$wo$-}osD4U#AkVFBHCBy=K2_}P-g&_x1k`u{so|Mv6$0|~ zcnQ7mdGIr)CUW0ZEL|=Q`D%qj_Lvg#)uM;&pq$7bzCsWBhVYu%CG_)y+0#{R(QzHj z3d)H@%{28g6sdzjB1FRY)d!P4A$-N`66x@r+2hvmM8d#fkO+~Z@wn4ZWbV61V$g9J z@Hkal6t90aNQ@IvbcG?Icm-;5-d7Xx!eNid6n|XHAW?!t>_-V@iWiR;NE|zc>&Uon zx_i^xZn{cMf2sncBM;5}Csj4n@owpJ*Sf67O1VfL`NM`yn_ZO}zP!i;Gv zgX?l7QewVr+cxOpfd)Q=KVV+C8)J7G3gt7IOeT}bWHOnIG$AMV)HRJ1(rSBp3o8>z z5J}QrHEhdoC;Y_3Xnf3JLy@Hd$gljSZUG$)D_FW>q*zIC6`D(Ut`IK@|CBHGaWbvm zMuE&ff{0y~19jW1kvWF^5qU$Wm&|kbDpb{9t6K)SCd`fX(w}bmlz#^o<-pffw>CS zSAm@hb~_+ffs+a#6>L($Kj>kI$uN3><;@%Yo`(;BW)Ookl15__MIw9Y6+v(!Q7;G@fQ7P9LxWNnz+g6!W=*V05Hu3?jd-qBmit0GfnyD)IC=8++ zkYCVN&hW$rK8x!w;(1cXp6lSke57^xOVq95*-_~N+)ddq1%|N|&&@H6Ct3?0gXCbJ z)heGU%G05H`lZ&#MM^~fo-lA{kklAsWjiP%;OJb>% z!F=(=Atz;$);(jyjCFv^9^qMS;OZ}PJ~}e!`qLdP32H|#F_qkS-mGce%o0fRjb;fN)HUP81y*k6eK{g0iT)uOtbOdl>QpmggDF zM|_6^yatWg0WcfGnGNUSR0_EyxHMIF%X|`Bdr&uDftKdW6{FH-2M0+k8@+$e%Fh zQVv~TR?r(kcb=Cz%4*w`jp3XTYvlt3Pjd`oj>4nCFB}|%;1T~XmTL}kt@MoKhEklH ziF8uQl{6P{Y;X_MSB%}F9naE&3BmOrYa%^b_#5?RP9iRUpo!oX`k%O*B5)gFECVLu zyrkjC<lWn}{%uVo(}cL1WF{GW z_}^2H=|DGaWAK)sqU))3?*yF!_GyYM7v_n}XnWZ6h4#^7$`PPTn;aYczVwa)Vy+1P zQW30I)1A;9Kp({hH-FeO8bZi~t}}+wYcBXE(4E3u=#9Xi-d3PqK^$GrhO$Qke-wP) z^fq+6+zX8W^!z_#)U0_ycve8LY>nc&Y%JQcSYksRU8g^)^F`gIO)I}g*T-q@W6$?G zgNH-^a;a>}SDrQB7~x5j^ZB(<(Di?lExCHs!A3%B16?1r$XA9tAo?Sba&@3Pnx(W5 zxDGn6EyH<}FBe1k>a%y>w}5)^I4ovlbe;G5oU{W`|7kajl!vD`;iMfX^-pOo6yJAK ztTBX3(}KE6kKzCDuC2565gOmGtwrL{`0**(>?iCw%8CfI95GT;}?+94jOK<0=qh zz}{&Z?}^HQP8)^n%6+6?7jKSZNDEA;?!tf2uf;d2yKqU7NF)-8L?V$$3~sl^-JR0}u&0000 Bool { + guard isSupportedScheme(url) else { + return false + } + + guard let action = parseAction(url: url) else { + return false + } + + DispatchQueue.main.async { + if case .splash = AppState.shared.rootStep { + AppState.shared.pendingDeepLinkAction = action + return + } + + apply(action: action) + } + + return true + } + + static func handle(urlString: String) -> Bool { + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed) else { + return false + } + + return handle(url: url) + } + + static func apply(action: AppDeepLinkAction) { + switch action { + case .live(let roomId): + guard roomId > 0 else { return } + AppState.shared.pushRoomId = 0 + AppState.shared.pushRoomId = roomId + + case .content(let contentId): + guard contentId > 0 else { return } + AppState.shared.setAppStep(step: .contentDetail(contentId: contentId)) + + case .series(let seriesId): + guard seriesId > 0 else { return } + AppState.shared.setAppStep(step: .seriesDetail(seriesId: seriesId)) + + case .community(let creatorId): + guard creatorId > 0 else { return } + AppState.shared.setAppStep(step: .creatorCommunityAll(creatorId: creatorId)) + + case .message: + AppState.shared.setAppStep(step: .message) + + case .audition: + AppState.shared.setAppStep(step: .audition) + } + } + + private static func isSupportedScheme(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { + return false + } + + let appScheme = APPSCHEME.lowercased() + return scheme == appScheme || scheme == "voiceon" || scheme == "voiceon-test" + } + + private static func parseAction(url: URL) -> AppDeepLinkAction? { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + + if let routeAction = parsePathStyle(url: url, components: components) { + return routeAction + } + + return parseQueryStyle(components: components) + } + + private static func parsePathStyle(url: URL, components: URLComponents?) -> AppDeepLinkAction? { + let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + let host = components?.host?.lowercased() ?? "" + + if !host.isEmpty { + let identifier = pathComponents.first + return makeAction(route: host, identifier: identifier) + } + + guard !pathComponents.isEmpty else { + return nil + } + + let route = pathComponents[0].lowercased() + let identifier = pathComponents.count > 1 ? pathComponents[1] : nil + return makeAction(route: route, identifier: identifier) + } + + private static func parseQueryStyle(components: URLComponents?) -> AppDeepLinkAction? { + guard let queryItems = components?.queryItems else { + return nil + } + + var queryMap: [String: String] = [:] + for item in queryItems { + queryMap[item.name.lowercased()] = item.value + } + + if let roomId = queryMap["room_id"], let value = Int(roomId), value > 0 { + return .live(roomId: value) + } + + if let contentId = queryMap["content_id"], let value = Int(contentId), value > 0 { + return .content(contentId: value) + } + + if let seriesId = queryMap["series_id"], let value = Int(seriesId), value > 0 { + return .series(seriesId: value) + } + + if let communityId = queryMap["community_id"], let value = Int(communityId), value > 0 { + return .community(creatorId: value) + } + + if queryMap["message_id"] != nil { + return .message + } + + if queryMap["audition_id"] != nil { + return .audition + } + + return nil + } + + private static func makeAction(route: String, identifier: String?) -> AppDeepLinkAction? { + switch route { + case "live": + guard let identifier = identifier, let roomId = Int(identifier), roomId > 0 else { + return nil + } + return .live(roomId: roomId) + + case "content": + guard let identifier = identifier, let contentId = Int(identifier), contentId > 0 else { + return nil + } + return .content(contentId: contentId) + + case "series": + guard let identifier = identifier, let seriesId = Int(identifier), seriesId > 0 else { + return nil + } + return .series(seriesId: seriesId) + + case "community": + if let identifier = identifier, let creatorId = Int(identifier), creatorId > 0 { + return .community(creatorId: creatorId) + } + + guard let creatorId = fallbackCommunityCreatorId() else { + return nil + } + + return .community(creatorId: creatorId) + + case "message": + return .message + + case "audition": + return .audition + + default: + return nil + } + } + + private static func fallbackCommunityCreatorId() -> Int? { + let userId = UserDefaults.int(forKey: .userId) + return userId > 0 ? userId : nil + } +} diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index 35ee724..a1671ba 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -49,6 +49,7 @@ class AppState: ObservableObject { @Published var pushMessageId = 0 @Published var pushAudioContentId = 0 @Published var pushSeriesId = 0 + @Published var pendingDeepLinkAction: AppDeepLinkAction? = nil @Published var roomId = 0 { didSet { if roomId <= 0 { @@ -141,6 +142,12 @@ class AppState: ObservableObject { self.syncStepWithNavigationPath() } } + + func consumePendingDeepLinkAction() -> AppDeepLinkAction? { + let action = pendingDeepLinkAction + pendingDeepLinkAction = nil + return action + } // 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침 func softRestart() { diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index cfa2fd1..ce9ed6f 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -161,6 +161,8 @@ enum AppStep { case introduceCreatorAll case message + + case notificationList case pointStatus(refresh: () -> Void) diff --git a/SodaLive/Sources/App/SodaLiveApp.swift b/SodaLive/Sources/App/SodaLiveApp.swift index 295fc06..44c9625 100644 --- a/SodaLive/Sources/App/SodaLiveApp.swift +++ b/SodaLive/Sources/App/SodaLiveApp.swift @@ -84,6 +84,10 @@ struct SodaLiveApp: App { comps.path.lowercased() == "/result" { canPgPaymentViewModel.handleVerifyOpenURL(url) } else { + if AppDeepLinkHandler.handle(url: url) { + return + } + _ = LoginManager.shared.application(UIApplication.shared, open: url, options: [:]) ApplicationDelegate.shared.application(UIApplication.shared, open: url, options: [:]) AppsFlyerLib.shared().handleOpen(url) diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index a952cab..d7d6f38 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -324,6 +324,9 @@ struct AppStepLayerView: View { case .message: MessageView() + case .notificationList: + PushNotificationListView() + case .pointStatus(let refresh): PointStatusView(refresh: refresh) diff --git a/SodaLive/Sources/Home/HomeTabView.swift b/SodaLive/Sources/Home/HomeTabView.swift index e54cdd5..51369cb 100644 --- a/SodaLive/Sources/Home/HomeTabView.swift +++ b/SodaLive/Sources/Home/HomeTabView.swift @@ -89,6 +89,12 @@ struct HomeTabView: View { .shared .setAppStep(step: .myBox(currentTab: .orderlist)) } + + Image("ic_bell") + .onTapGesture { + AppState.shared + .setAppStep(step: .notificationList) + } } } .padding(.horizontal, 24) diff --git a/SodaLive/Sources/Notification/List/GetPushNotificationCategoryResponse.swift b/SodaLive/Sources/Notification/List/GetPushNotificationCategoryResponse.swift new file mode 100644 index 0000000..5fb8ac8 --- /dev/null +++ b/SodaLive/Sources/Notification/List/GetPushNotificationCategoryResponse.swift @@ -0,0 +1,5 @@ +import Foundation + +struct GetPushNotificationCategoryResponse: Decodable { + let categories: [String] +} diff --git a/SodaLive/Sources/Notification/List/GetPushNotificationListResponse.swift b/SodaLive/Sources/Notification/List/GetPushNotificationListResponse.swift new file mode 100644 index 0000000..40cb6d1 --- /dev/null +++ b/SodaLive/Sources/Notification/List/GetPushNotificationListResponse.swift @@ -0,0 +1,42 @@ +import Foundation + +struct GetPushNotificationListResponse: Decodable { + let totalCount: Int + let items: [PushNotificationListItem] +} + +struct PushNotificationListItem: Decodable { + let id: Int + let senderNickname: String + let senderProfileImage: String? + let message: String + let category: String + let deepLink: String? + let sentAt: String +} + +extension PushNotificationListItem { + func relativeSentAtText(now: Date = Date()) -> String { + guard let sentDate = DateParser.parse(sentAt) else { + return sentAt + } + + let interval = max(0, now.timeIntervalSince(sentDate)) + if interval < 60 { + return I18n.Time.justNow + } + + if interval < 3600 { + let minutes = max(1, Int(interval / 60)) + return I18n.Time.minutesAgo(minutes) + } + + if interval < 86_400 { + let hours = max(1, Int(interval / 3600)) + return I18n.Time.hoursAgo(hours) + } + + let days = max(1, Int(interval / 86_400)) + return I18n.Time.daysAgo(days) + } +} diff --git a/SodaLive/Sources/Notification/List/PushNotificationApi.swift b/SodaLive/Sources/Notification/List/PushNotificationApi.swift new file mode 100644 index 0000000..b159cb9 --- /dev/null +++ b/SodaLive/Sources/Notification/List/PushNotificationApi.swift @@ -0,0 +1,50 @@ +import Foundation + +import Moya + +enum PushNotificationApi { + case getPushNotificationCategories + case getPushNotificationList(page: Int, size: Int, category: String?) +} + +extension PushNotificationApi: TargetType { + var baseURL: URL { + URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getPushNotificationCategories: + return "/push/notification/categories" + case .getPushNotificationList: + return "/push/notification/list" + } + } + + var method: Moya.Method { + .get + } + + var task: Task { + switch self { + case .getPushNotificationCategories: + return .requestPlain + + case .getPushNotificationList(let page, let size, let category): + var parameters: [String: Any] = [ + "page": max(0, page - 1), + "size": size + ] + + if let category = category?.trimmingCharacters(in: .whitespacesAndNewlines), !category.isEmpty { + parameters["category"] = category + } + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + } + } + + var headers: [String: String]? { + ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/Notification/List/PushNotificationListItemView.swift b/SodaLive/Sources/Notification/List/PushNotificationListItemView.swift new file mode 100644 index 0000000..ba782fc --- /dev/null +++ b/SodaLive/Sources/Notification/List/PushNotificationListItemView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +import Kingfisher + +struct PushNotificationListItemView: View { + let item: PushNotificationListItem + + var body: some View { + HStack(alignment: .center, spacing: 13.3) { + KFImage(URL(string: item.senderProfileImage ?? "")) + .cancelOnDisappear(true) + .resizable() + .scaledToFill() + .frame(width: 60, height: 60) + .background( + Circle() + .foregroundColor(Color(hex: "555555")) + ) + .cornerRadius(30) + .clipped() + + VStack(alignment: .leading, spacing: 5.3) { + HStack(spacing: 0) { + Text(item.senderNickname) + .appFont(size: 13.3, weight: .medium) + .foregroundColor(Color(hex: "bbbbbb")) + + Text(" · \(item.relativeSentAtText())") + .appFont(size: 10, weight: .medium) + .foregroundColor(Color(hex: "909090")) + } + + Text(item.message) + .appFont(size: 13.3, weight: .medium) + .foregroundColor(Color(hex: "eeeeee")) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 13.3) + .overlay(alignment: .bottom) { + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "555555")) + } + } +} diff --git a/SodaLive/Sources/Notification/List/PushNotificationListView.swift b/SodaLive/Sources/Notification/List/PushNotificationListView.swift new file mode 100644 index 0000000..b31621f --- /dev/null +++ b/SodaLive/Sources/Notification/List/PushNotificationListView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct PushNotificationListView: View { + @StateObject var viewModel = PushNotificationListViewModel() + @State private var isInitialized = false + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(alignment: .leading, spacing: 13.3) { + titleBar + + ContentMainContentThemeView( + themeList: viewModel.categories, + selectTheme: { + viewModel.selectedCategory = $0 + }, + selectedTheme: $viewModel.selectedCategory + ) + + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(0..() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var categories: [String] = [] + @Published var selectedCategory: String = "" { + didSet { + if oldValue != selectedCategory { + let trimmed = selectedCategory.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + getPushNotificationList(reset: true) + } + } + } + } + + @Published var items: [PushNotificationListItem] = [] + @Published var totalCount: Int = 0 + + private var page: Int = 1 + private let pageSize: Int = 20 + private var isLast: Bool = false + + func initialize() { + getPushNotificationCategories { [weak self] in + guard let self = self else { return } + let trimmed = self.selectedCategory.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + self.getPushNotificationList(reset: true) + } + } + } + + func getPushNotificationList(reset: Bool = false) { + if reset { + page = 1 + isLast = false + totalCount = 0 + items.removeAll() + } + + if isLoading || isLast { + return + } + + isLoading = true + + repository.getPushNotificationList( + page: page, + size: pageSize, + category: requestCategory + ) + .sink { [weak self] result in + guard let self = self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + + case .failure(let error): + self.isLoading = false + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + + self.isLoading = false + + do { + let decoded = try JSONDecoder().decode( + ApiResponse.self, + from: response.data + ) + + if let data = decoded.data, decoded.success { + self.totalCount = data.totalCount + + if data.items.isEmpty { + self.isLast = true + return + } + + self.items.append(contentsOf: data.items) + + let totalPages = Int(ceil(Double(max(data.totalCount, 0)) / Double(self.pageSize))) + let isReachedByPage = totalPages > 0 ? self.page >= totalPages : true + let isReachedByCount = data.totalCount > 0 && self.items.count >= data.totalCount + + if isReachedByPage || isReachedByCount { + self.isLast = true + } else { + self.page += 1 + } + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func onTapItem(_ item: PushNotificationListItem) { + guard let deepLink = item.deepLink?.trimmingCharacters(in: .whitespacesAndNewlines), !deepLink.isEmpty else { + return + } + + _ = AppDeepLinkHandler.handle(urlString: deepLink) + } + + private var requestCategory: String? { + let trimmed = selectedCategory.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func getPushNotificationCategories(completion: (() -> Void)? = nil) { + repository.getPushNotificationCategories() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + + completion?() + } receiveValue: { [weak self] response in + guard let self = self else { return } + + do { + let decoded = try JSONDecoder().decode( + ApiResponse.self, + from: response.data + ) + + if let data = decoded.data, decoded.success { + var filtered: [String] = [] + for category in data.categories { + let trimmed = category.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || filtered.contains(trimmed) { + continue + } + filtered.append(trimmed) + } + + self.categories = filtered + self.selectedCategory = filtered.first ?? "" + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/Notification/List/PushNotificationRepository.swift b/SodaLive/Sources/Notification/List/PushNotificationRepository.swift new file mode 100644 index 0000000..43c4b81 --- /dev/null +++ b/SodaLive/Sources/Notification/List/PushNotificationRepository.swift @@ -0,0 +1,17 @@ +import Foundation + +import Combine +import CombineMoya +import Moya + +final class PushNotificationRepository { + private let api = MoyaProvider() + + func getPushNotificationCategories() -> AnyPublisher { + api.requestPublisher(.getPushNotificationCategories) + } + + func getPushNotificationList(page: Int, size: Int, category: String?) -> AnyPublisher { + api.requestPublisher(.getPushNotificationList(page: page, size: size, category: category)) + } +} diff --git a/SodaLive/Sources/Splash/SplashView.swift b/SodaLive/Sources/Splash/SplashView.swift index 6200272..df03499 100644 --- a/SodaLive/Sources/Splash/SplashView.swift +++ b/SodaLive/Sources/Splash/SplashView.swift @@ -110,6 +110,14 @@ struct SplashView: View { private func nextAppStep() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { withAnimation { + if let pendingDeepLinkAction = AppState.shared.consumePendingDeepLinkAction() { + AppState.shared.setAppStep(step: .main) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + AppDeepLinkHandler.apply(action: pendingDeepLinkAction) + } + return + } + if !UserDefaults.string(forKey: UserDefaultsKey.token).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if AppState.shared.pushRoomId > 0 { liveViewModel.enterLiveRoom(roomId: AppState.shared.pushRoomId) diff --git a/docs/20260312_알림리스트구현.md b/docs/20260312_알림리스트구현.md new file mode 100644 index 0000000..8acc44f --- /dev/null +++ b/docs/20260312_알림리스트구현.md @@ -0,0 +1,54 @@ +# 20260312 알림 리스트 구현 + +## 작업 목표 +- 홈 탭 벨 아이콘 진입으로 알림 리스트 화면을 구현한다. +- 알림 카테고리/리스트 API를 연동하고 무한 스크롤을 구현한다. +- 알림 아이템 deepLink 실행과 경로형 deepLink 파싱을 보완한다. + +## 구현 체크리스트 +- [x] 기존 UI/네비게이션/딥링크 처리 패턴 탐색 +- [x] 알림 리스트 라우트(AppStep) 및 진입 경로(HomeTabView) 연결 +- [x] 알림 카테고리/리스트 API 모델, TargetType, Repository 추가 +- [x] 알림 리스트 ViewModel(카테고리/페이지네이션/상대시간/딥링크 처리) 구현 +- [x] 알림 리스트 View(UI: Title bar, Category List, Item List) 구현 +- [x] 경로형(`voiceon://live/333`) + 기존 쿼리형 deepLink 파싱/실행 보완 +- [x] `voiceon://community`처럼 id 없는 community 경로의 fallback(로그인 userId) 처리 보완 +- [x] 알림 카테고리 리스트에서 앱 주입 `전체` 제거, 서버 조회 카테고리만 사용하도록 수정 +- [x] LSP 진단 및 빌드/테스트 검증 + +## 검증 기록 +- 무엇/왜/어떻게: `lsp_diagnostics`를 변경 파일 전부(`AppStep`, `ContentView`, `HomeTabView`, `AppState`, `SodaLiveApp`, `SplashView`, `Notification/List/*`)에 수행해 정적 진단을 확인했다. +- 실행 명령: `lsp_diagnostics` (복수 파일) +- 결과: SourceKit 컨텍스트에서 외부 모듈(`Bootpay`, `Kingfisher`, `Moya`, `CombineMoya`, `FirebaseRemoteConfig`) 및 참조 타입 인식 오류가 다수 발생해 단독 진단으로는 신뢰도가 낮았고, 실제 컴파일 유효성은 빌드로 검증했다. +- 무엇/왜/어떻게: 새 알림 페이지/딥링크/라우팅 변경의 컴파일 무결성을 `SodaLive` 스킴에서 검증했다. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 무엇/왜/어떻게: 동일 변경 사항의 dev 스킴 회귀 여부를 확인했다. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 무엇/왜/어떻게: 저장소의 테스트 액션 가용성 확인을 위해 두 스킴 테스트를 실행했다. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` +- 결과: `Scheme SodaLive is not currently configured for the test action.` +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: `Scheme SodaLive-dev is not currently configured for the test action.` +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: 병렬 빌드 시 `build.db` lock으로 1회 실패 후 단독 재실행에서 `** BUILD SUCCEEDED **`. +- 무엇/왜/어떻게: 알림 카테고리 리스트가 앱에서 `전체`를 주입하던 로직을 제거하고, 서버 응답 카테고리만 노출/선택/요청에 사용하도록 `PushNotificationListViewModel`을 수정했다. +- 실행 명령: `lsp_diagnostics` (`SodaLive/Sources/Notification/List/PushNotificationListViewModel.swift`) +- 결과: SourceKit 컨텍스트 한계로 모듈/타입 인식 오류가 보고되어 단독 신뢰는 낮고, 컴파일 유효성은 빌드 결과로 확인했다. +- 무엇/왜/어떻게: `community` 경로의 id 누락 케이스 보완(`voiceon://community` 입력 시 로그인 사용자 `userId`를 fallback으로 사용) 이후 정적 진단/빌드/테스트 상태를 재검증했다. +- 실행 명령: `lsp_diagnostics` (`SodaLive/Sources/App/AppDeepLinkHandler.swift`) +- 결과: SourceKit 컨텍스트 한계로 `AppState`, `APPSCHEME`, `UserDefaults.int` 인식 오류가 계속 보고되어 단독 신뢰는 낮고, 컴파일 유효성은 빌드 결과로 확인했다. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` +- 결과: `Scheme SodaLive is not currently configured for the test action.` +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: `Scheme SodaLive-dev is not currently configured for the test action.` +- 무엇/왜/어떻게: 초기 진입 시 카테고리 조회 후 기본 선택 과정에서 알림 리스트 API가 2회 호출되는지 여부를 search-mode(병렬 explore/librarian + grep/ast-grep)로 점검했다. +- 실행 명령: `grep` (`getPushNotificationList`, `initialize`, `selectedCategory`, `onAppear`), `ast_grep_search` (`getPushNotificationList(reset: true)`), background `explore`/`librarian` 결과 수집 +- 결과: `PushNotificationListView`의 최초 `onAppear`에서 `initialize()` 1회 진입, `PushNotificationListViewModel`의 `selectedCategory` didSet에서 리스트 호출 1회 발생, `initialize()` completion의 `trimmed.isEmpty` 가드로 추가 호출이 차단되어 카테고리 선택 과정에서의 중복 호출 버그는 확인되지 않았다.