Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 152 additions & 2 deletions DevLog/Presentation/ViewModel/PushNotificationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ final class PushNotificationViewModel: Store {
var toastType: ToastType?
var isLoading: Bool = false
var pendingTask: (PushNotification, Int)?
var sortOption: SortOption
var timeFilter: TimeFilter
var showUnreadOnly: Bool
}

enum Action {
Expand All @@ -31,6 +34,10 @@ final class PushNotificationViewModel: Store {
case setToast(isPresented: Bool, type: ToastType? = nil)
case setLoading(Bool)
case setNotifications([PushNotification])
case toggleSortOption
case setTimeFilter(TimeFilter)
case toggleUnreadOnly
case resetFilters
}

enum SideEffect {
Expand All @@ -47,19 +54,125 @@ final class PushNotificationViewModel: Store {
case delete
}

@Published private(set) var state: State = .init()
enum SortOption: CaseIterable {
case latest
case oldest

var title: String {
switch self {
case .latest: return "최신순"
case .oldest: return "예전순"
}
}
}

enum TimeFilter: Equatable {
case none
case hours(Int)
case days(Int)

var id: String {
switch self {
case .none: return "none"
case .hours(let value): return "hours-\(value)"
case .days(let value): return "days-\(value)"
}
}

var title: String {
switch self {
case .none:
return "전체"
case .hours(let value):
return "최근 \(value)시간"
case .days(let value):
return "최근 \(value)일"
}
}

static var availableOptions: [TimeFilter] {[
.none,
.hours(1),
.hours(6),
.hours(24),
.days(3),
.days(7)
]
}

init(id: String) {
if id == "none" {
self = .none
} else if id.hasPrefix("hours-") {
let value = Int(id.replacingOccurrences(of: "hours-", with: "")) ?? 0
self = value > 0 ? .hours(value) : .none
} else if id.hasPrefix("days-") {
let value = Int(id.replacingOccurrences(of: "days-", with: "")) ?? 0
self = value > 0 ? .days(value) : .none
} else {
self = .none
}
}
}

@Published private(set) var state: State
private let fetchUseCase: FetchPushNotificationsUseCase
private let deleteUseCase: DeletePushNotificationUseCase
private let toggleReadUseCase: TogglePushNotificationReadUseCase
private let userDefaults: UserDefaults

private enum DefaultsKey {
static let sortOption = "PushNotification.sortOption"
static let timeFilter = "PushNotification.timeFilter"
static let showUnreadOnly = "PushNotification.showUnreadOnly"
}

init(
fetchUseCase: FetchPushNotificationsUseCase,
deleteUseCase: DeletePushNotificationUseCase,
toggleReadUseCase: TogglePushNotificationReadUseCase
toggleReadUseCase: TogglePushNotificationReadUseCase,
userDefaults: UserDefaults = .standard
) {
self.fetchUseCase = fetchUseCase
self.deleteUseCase = deleteUseCase
self.toggleReadUseCase = toggleReadUseCase
self.userDefaults = userDefaults
self.state = State(
sortOption: Self.loadSortOption(userDefaults: userDefaults),
timeFilter: Self.loadTimeFilter(userDefaults: userDefaults),
showUnreadOnly: userDefaults.bool(forKey: DefaultsKey.showUnreadOnly)
)
}

var displayedNotifications: [PushNotification] {
var items = state.notifications

if state.showUnreadOnly {
items = items.filter { $0.isRead == false }
}

if case let .hours(value) = state.timeFilter {
let threshold = Date().addingTimeInterval(-Double(value) * 3600.0)
items = items.filter { $0.receivedAt >= threshold }
} else if case let .days(value) = state.timeFilter {
let threshold = Date().addingTimeInterval(-Double(value) * 86400.0)
items = items.filter { $0.receivedAt >= threshold }
}

switch state.sortOption {
case .latest:
return items.sorted { $0.receivedAt > $1.receivedAt }
case .oldest:
return items.sorted { $0.receivedAt < $1.receivedAt }
}
}

var appliedFilterCount: Int {
var count = 0
if state.sortOption != .latest { count += 1 }
if state.timeFilter != .none { count += 1 }
if state.showUnreadOnly { count += 1 }
return count
}

func reduce(with action: Action) -> [SideEffect] {
Expand Down Expand Up @@ -96,6 +209,22 @@ final class PushNotificationViewModel: Store {
state.isLoading = value
case .setNotifications(let notifications):
state.notifications = notifications
case .toggleSortOption:
state.sortOption = state.sortOption == .latest ? .oldest : .latest
saveSortOption(state.sortOption)
case .setTimeFilter(let filter):
state.timeFilter = filter
saveTimeFilter(filter)
case .toggleUnreadOnly:
state.showUnreadOnly.toggle()
userDefaults.set(state.showUnreadOnly, forKey: DefaultsKey.showUnreadOnly)
case .resetFilters:
state.sortOption = .latest
state.timeFilter = .none
state.showUnreadOnly = false
saveSortOption(.latest)
saveTimeFilter(.none)
userDefaults.set(false, forKey: DefaultsKey.showUnreadOnly)
}

self.state = state
Expand Down Expand Up @@ -166,4 +295,25 @@ private extension PushNotificationViewModel {
}
state.showToast = isPresented
}

static func loadSortOption(userDefaults: UserDefaults) -> SortOption {
guard let rawValue = userDefaults.string(forKey: DefaultsKey.sortOption) else {
return .latest
}
return rawValue == "oldest" ? .oldest : .latest
}

static func loadTimeFilter(userDefaults: UserDefaults) -> TimeFilter {
let id = userDefaults.string(forKey: DefaultsKey.timeFilter) ?? "none"
return TimeFilter(id: id)
}

func saveSortOption(_ option: SortOption) {
let value = option == .oldest ? "oldest" : "latest"
userDefaults.set(value, forKey: DefaultsKey.sortOption)
}

func saveTimeFilter(_ filter: TimeFilter) {
userDefaults.set(filter.id, forKey: DefaultsKey.timeFilter)
}
}
18 changes: 18 additions & 0 deletions DevLog/Resource/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
},
"%@ 검색" : {

},
"%lld" : {

},
"%lld개 필터가 적용됨" : {

},
"lime_green" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -265,6 +271,9 @@
},
"계정 연동" : {

},
"기간" : {

},
"로그아웃" : {

Expand All @@ -277,6 +286,9 @@
},
"마감일" : {

},
"모든 필터 지우기" : {

},
"미리보기" : {

Expand Down Expand Up @@ -334,6 +346,9 @@
},
"완료" : {

},
"읽지 않음" : {

},
"작년" : {

Expand All @@ -343,6 +358,9 @@
},
"정렬 옵션" : {

},
"정렬: %@" : {

},
"정말 탈퇴하시겠습니까?" : {

Expand Down
109 changes: 95 additions & 14 deletions DevLog/UI/PushNotification/PushNotificationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,31 @@ import SwiftUI
struct PushNotificationView: View {
@StateObject private var router = NavigationRouter()
@StateObject var viewModel: PushNotificationViewModel
@Environment(\.colorScheme) private var colorScheme

var body: some View {
NavigationStack(path: $router.path) {
VStack {
if viewModel.state.notifications.isEmpty {
Spacer()
Text("받은 알림이 없습니다.")
.foregroundStyle(Color.gray)
Spacer()
} else {
List(viewModel.state.notifications, id: \.id) { notification in
notificationRow(notification)
List {
Section {
if viewModel.displayedNotifications.isEmpty {
HStack {
Spacer()
Text("받은 알림이 없습니다.")
.foregroundStyle(Color.gray)
Spacer()
}
.listRowSeparator(.hidden)
} else {
ForEach(viewModel.displayedNotifications, id: \.id) { notification in
notificationRow(notification)
}
}
.listStyle(.plain)
} header: {
headerView
}
.listRowBackground(Color.clear)
}
.frame(maxWidth: .infinity, alignment: .center)
.listStyle(.plain)
.background(Color(.secondarySystemBackground))
.onAppear { viewModel.send(.fetchNotifications) }
.navigationTitle("받은 푸시 알람")
Expand Down Expand Up @@ -56,6 +64,80 @@ struct PushNotificationView: View {
}
}

private var headerView: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
if 0 < viewModel.appliedFilterCount {
Menu {
Text("\(viewModel.appliedFilterCount)개 필터가 적용됨")
Button(role: .destructive) {
viewModel.send(.resetFilters)
} label: {
Text("모든 필터 지우기")
}
} label: {
HStack(spacing: 6) {
Image(systemName: "line.3.horizontal.decrease")
filterBadge
}
}
.adaptiveButtonStyle()
}

Button {
viewModel.send(.toggleSortOption)
} label: {
Text("정렬: \(viewModel.state.sortOption.title)")
}
.adaptiveButtonStyle(viewModel.state.sortOption == .oldest ? .blue : .clear)

Menu {
ForEach(PushNotificationViewModel.TimeFilter.availableOptions, id: \.id) { option in
Button {
viewModel.send(.setTimeFilter(option))
} label: {
HStack {
Text(option.title)
Spacer()
if viewModel.state.timeFilter == option {
Image(systemName: "checkmark")
.tint(.blue)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
} label: {
Text("기간")
}
.adaptiveButtonStyle(viewModel.state.timeFilter == .none ? .clear : .blue)

Button {
viewModel.send(.toggleUnreadOnly)
} label: {
Text("읽지 않음")
}
.adaptiveButtonStyle(viewModel.state.showUnreadOnly ? .blue : .clear)
}
}
.scrollIndicators(.never)
}

private var filterBadge: some View {
let isDark = colorScheme == .dark
let blue = Color(uiColor: .systemBlue) // 흰 배경에 따른 청록색화 방지
let textColor: Color = isDark ? blue : .white
let backgroundColor: Color = isDark ? .white : blue

return Text("\(viewModel.appliedFilterCount)")
.font(.caption2.weight(.bold))
.foregroundColor(textColor)
.lineLimit(1)
.minimumScaleFactor(0.6)
.frame(width: 20, height: 20)
.background(Circle().fill(backgroundColor))
}

private func notificationRow(_ notification: PushNotification) -> some View {
HStack {
Circle()
Expand All @@ -82,7 +164,6 @@ struct PushNotificationView: View {
}
}
.padding(.vertical, 5)
.listRowBackground(Color.clear)
.swipeActions(edge: .leading) {
Button {
viewModel.send(.toggleRead(notification))
Expand All @@ -102,10 +183,10 @@ struct PushNotificationView: View {
}
}
}

private func timeAgoText(from date: Date, now: Date) -> String {
let seconds = Int(now.timeIntervalSince(date))

if seconds < 60 {
return "\(max(0, seconds))초 전"
} else if seconds < 3600 {
Expand Down