feat(notification): 알림함 진입 및 딥링크 라우팅을 추가한다
This commit is contained in:
21
SodaLive/Resources/Assets.xcassets/ic_bell.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/ic_bell.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_bell.imageset/ic_bell.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/ic_bell.imageset/ic_bell.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
21
SodaLive/Resources/Assets.xcassets/ic_bell_settings.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/ic_bell_settings.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "ic_bell_settings.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_bell_settings.imageset/ic_bell_settings.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/ic_bell_settings.imageset/ic_bell_settings.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -16,6 +16,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
" · %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
" (" : {
|
" (" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4139,6 +4142,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"목" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Thu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "木"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"모집완료" : {
|
"모집완료" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -4171,22 +4190,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"목" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Thu"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "木"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"무료" : {
|
"무료" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -5818,6 +5821,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"알림이 없습니다." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"앱 버전 정보" : {
|
"앱 버전 정보" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -8638,22 +8644,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"캔" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Cans"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "CAN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"캐릭터 정보" : {
|
"캐릭터 정보" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -8670,6 +8660,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"캔" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Cans"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "CAN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"캔 충전" : {
|
"캔 충전" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
190
SodaLive/Sources/App/AppDeepLinkHandler.swift
Normal file
190
SodaLive/Sources/App/AppDeepLinkHandler.swift
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppDeepLinkAction {
|
||||||
|
case live(roomId: Int)
|
||||||
|
case content(contentId: Int)
|
||||||
|
case series(seriesId: Int)
|
||||||
|
case community(creatorId: Int)
|
||||||
|
case message
|
||||||
|
case audition
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppDeepLinkHandler {
|
||||||
|
static func handle(url: URL) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ class AppState: ObservableObject {
|
|||||||
@Published var pushMessageId = 0
|
@Published var pushMessageId = 0
|
||||||
@Published var pushAudioContentId = 0
|
@Published var pushAudioContentId = 0
|
||||||
@Published var pushSeriesId = 0
|
@Published var pushSeriesId = 0
|
||||||
|
@Published var pendingDeepLinkAction: AppDeepLinkAction? = nil
|
||||||
@Published var roomId = 0 {
|
@Published var roomId = 0 {
|
||||||
didSet {
|
didSet {
|
||||||
if roomId <= 0 {
|
if roomId <= 0 {
|
||||||
@@ -142,6 +143,12 @@ class AppState: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func consumePendingDeepLinkAction() -> AppDeepLinkAction? {
|
||||||
|
let action = pendingDeepLinkAction
|
||||||
|
pendingDeepLinkAction = nil
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
// 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침
|
// 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침
|
||||||
func softRestart() {
|
func softRestart() {
|
||||||
isRestartApp = true
|
isRestartApp = true
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ enum AppStep {
|
|||||||
|
|
||||||
case message
|
case message
|
||||||
|
|
||||||
|
case notificationList
|
||||||
|
|
||||||
case pointStatus(refresh: () -> Void)
|
case pointStatus(refresh: () -> Void)
|
||||||
|
|
||||||
case audition
|
case audition
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ struct SodaLiveApp: App {
|
|||||||
comps.path.lowercased() == "/result" {
|
comps.path.lowercased() == "/result" {
|
||||||
canPgPaymentViewModel.handleVerifyOpenURL(url)
|
canPgPaymentViewModel.handleVerifyOpenURL(url)
|
||||||
} else {
|
} else {
|
||||||
|
if AppDeepLinkHandler.handle(url: url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_ = LoginManager.shared.application(UIApplication.shared, open: url, options: [:])
|
_ = LoginManager.shared.application(UIApplication.shared, open: url, options: [:])
|
||||||
ApplicationDelegate.shared.application(UIApplication.shared, open: url, options: [:])
|
ApplicationDelegate.shared.application(UIApplication.shared, open: url, options: [:])
|
||||||
AppsFlyerLib.shared().handleOpen(url)
|
AppsFlyerLib.shared().handleOpen(url)
|
||||||
|
|||||||
@@ -324,6 +324,9 @@ struct AppStepLayerView: View {
|
|||||||
case .message:
|
case .message:
|
||||||
MessageView()
|
MessageView()
|
||||||
|
|
||||||
|
case .notificationList:
|
||||||
|
PushNotificationListView()
|
||||||
|
|
||||||
case .pointStatus(let refresh):
|
case .pointStatus(let refresh):
|
||||||
PointStatusView(refresh: refresh)
|
PointStatusView(refresh: refresh)
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ struct HomeTabView: View {
|
|||||||
.shared
|
.shared
|
||||||
.setAppStep(step: .myBox(currentTab: .orderlist))
|
.setAppStep(step: .myBox(currentTab: .orderlist))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Image("ic_bell")
|
||||||
|
.onTapGesture {
|
||||||
|
AppState.shared
|
||||||
|
.setAppStep(step: .notificationList)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct GetPushNotificationCategoryResponse: Decodable {
|
||||||
|
let categories: [String]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
SodaLive/Sources/Notification/List/PushNotificationApi.swift
Normal file
50
SodaLive/Sources/Notification/List/PushNotificationApi.swift
Normal file
@@ -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))"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..<viewModel.items.count, id: \.self) { index in
|
||||||
|
let item = viewModel.items[index]
|
||||||
|
PushNotificationListItemView(item: item)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.onTapItem(item)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if index == viewModel.items.count - 1 {
|
||||||
|
viewModel.getPushNotificationList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.items.isEmpty && !viewModel.isLoading {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image("ic_no_item")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
|
||||||
|
Text("알림이 없습니다.")
|
||||||
|
.appFont(size: 10.7, weight: .medium)
|
||||||
|
.foregroundColor(Color(hex: "bbbbbb"))
|
||||||
|
}
|
||||||
|
.frame(width: screenSize().width, height: screenSize().height / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !isInitialized {
|
||||||
|
viewModel.initialize()
|
||||||
|
isInitialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
|
||||||
|
GeometryReader { geo in
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.errorMessage)
|
||||||
|
.padding(.vertical, 13.3)
|
||||||
|
.padding(.horizontal, 6.7)
|
||||||
|
.frame(width: geo.size.width - 66.7, alignment: .center)
|
||||||
|
.appFont(size: 12, weight: .medium)
|
||||||
|
.background(Color(hex: "3bb9f1"))
|
||||||
|
.foregroundColor(Color.white)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.cornerRadius(20)
|
||||||
|
.padding(.top, 66.7)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleBar: some View {
|
||||||
|
ZStack {
|
||||||
|
DetailNavigationBar(title: "알림")
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image("ic_bell_settings")
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
AppState.shared.setAppStep(step: .notificationSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 13.3)
|
||||||
|
.frame(height: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
final class PushNotificationListViewModel: ObservableObject {
|
||||||
|
private let repository = PushNotificationRepository()
|
||||||
|
private var subscription = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
@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<GetPushNotificationListResponse>.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<GetPushNotificationCategoryResponse>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import CombineMoya
|
||||||
|
import Moya
|
||||||
|
|
||||||
|
final class PushNotificationRepository {
|
||||||
|
private let api = MoyaProvider<PushNotificationApi>()
|
||||||
|
|
||||||
|
func getPushNotificationCategories() -> AnyPublisher<Response, MoyaError> {
|
||||||
|
api.requestPublisher(.getPushNotificationCategories)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPushNotificationList(page: Int, size: Int, category: String?) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
api.requestPublisher(.getPushNotificationList(page: page, size: size, category: category))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,14 @@ struct SplashView: View {
|
|||||||
private func nextAppStep() {
|
private func nextAppStep() {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
|
||||||
withAnimation {
|
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 !UserDefaults.string(forKey: UserDefaultsKey.token).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
if AppState.shared.pushRoomId > 0 {
|
if AppState.shared.pushRoomId > 0 {
|
||||||
liveViewModel.enterLiveRoom(roomId: AppState.shared.pushRoomId)
|
liveViewModel.enterLiveRoom(roomId: AppState.shared.pushRoomId)
|
||||||
|
|||||||
54
docs/20260312_알림리스트구현.md
Normal file
54
docs/20260312_알림리스트구현.md
Normal file
@@ -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` 가드로 추가 호출이 차단되어 카테고리 선택 과정에서의 중복 호출 버그는 확인되지 않았다.
|
||||||
Reference in New Issue
Block a user