feat(notification): 알림함 진입 및 딥링크 라우팅을 추가한다
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user