Skip to content

Commit b51250c

Browse files
authored
[#61] 내비게이션바에 알람의 정렬과 필터링 선택 UI와 로직을 구성한다 (#86)
* feat: 필터링 UI 로직 구현 * feat: 로직 뷰모델로 이동 * ui: 필터링이 선택된 버튼은 파란색으로 처리 * feat: 설정값을 UserDefatuls에 저장
1 parent 335a78b commit b51250c

3 files changed

Lines changed: 265 additions & 16 deletions

File tree

DevLog/Presentation/ViewModel/PushNotificationViewModel.swift

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ final class PushNotificationViewModel: Store {
1919
var toastType: ToastType?
2020
var isLoading: Bool = false
2121
var pendingTask: (PushNotification, Int)?
22+
var sortOption: SortOption
23+
var timeFilter: TimeFilter
24+
var showUnreadOnly: Bool
2225
}
2326

2427
enum Action {
@@ -31,6 +34,10 @@ final class PushNotificationViewModel: Store {
3134
case setToast(isPresented: Bool, type: ToastType? = nil)
3235
case setLoading(Bool)
3336
case setNotifications([PushNotification])
37+
case toggleSortOption
38+
case setTimeFilter(TimeFilter)
39+
case toggleUnreadOnly
40+
case resetFilters
3441
}
3542

3643
enum SideEffect {
@@ -47,19 +54,125 @@ final class PushNotificationViewModel: Store {
4754
case delete
4855
}
4956

50-
@Published private(set) var state: State = .init()
57+
enum SortOption: CaseIterable {
58+
case latest
59+
case oldest
60+
61+
var title: String {
62+
switch self {
63+
case .latest: return "최신순"
64+
case .oldest: return "예전순"
65+
}
66+
}
67+
}
68+
69+
enum TimeFilter: Equatable {
70+
case none
71+
case hours(Int)
72+
case days(Int)
73+
74+
var id: String {
75+
switch self {
76+
case .none: return "none"
77+
case .hours(let value): return "hours-\(value)"
78+
case .days(let value): return "days-\(value)"
79+
}
80+
}
81+
82+
var title: String {
83+
switch self {
84+
case .none:
85+
return "전체"
86+
case .hours(let value):
87+
return "최근 \(value)시간"
88+
case .days(let value):
89+
return "최근 \(value)"
90+
}
91+
}
92+
93+
static var availableOptions: [TimeFilter] {[
94+
.none,
95+
.hours(1),
96+
.hours(6),
97+
.hours(24),
98+
.days(3),
99+
.days(7)
100+
]
101+
}
102+
103+
init(id: String) {
104+
if id == "none" {
105+
self = .none
106+
} else if id.hasPrefix("hours-") {
107+
let value = Int(id.replacingOccurrences(of: "hours-", with: "")) ?? 0
108+
self = value > 0 ? .hours(value) : .none
109+
} else if id.hasPrefix("days-") {
110+
let value = Int(id.replacingOccurrences(of: "days-", with: "")) ?? 0
111+
self = value > 0 ? .days(value) : .none
112+
} else {
113+
self = .none
114+
}
115+
}
116+
}
117+
118+
@Published private(set) var state: State
51119
private let fetchUseCase: FetchPushNotificationsUseCase
52120
private let deleteUseCase: DeletePushNotificationUseCase
53121
private let toggleReadUseCase: TogglePushNotificationReadUseCase
122+
private let userDefaults: UserDefaults
123+
124+
private enum DefaultsKey {
125+
static let sortOption = "PushNotification.sortOption"
126+
static let timeFilter = "PushNotification.timeFilter"
127+
static let showUnreadOnly = "PushNotification.showUnreadOnly"
128+
}
54129

55130
init(
56131
fetchUseCase: FetchPushNotificationsUseCase,
57132
deleteUseCase: DeletePushNotificationUseCase,
58-
toggleReadUseCase: TogglePushNotificationReadUseCase
133+
toggleReadUseCase: TogglePushNotificationReadUseCase,
134+
userDefaults: UserDefaults = .standard
59135
) {
60136
self.fetchUseCase = fetchUseCase
61137
self.deleteUseCase = deleteUseCase
62138
self.toggleReadUseCase = toggleReadUseCase
139+
self.userDefaults = userDefaults
140+
self.state = State(
141+
sortOption: Self.loadSortOption(userDefaults: userDefaults),
142+
timeFilter: Self.loadTimeFilter(userDefaults: userDefaults),
143+
showUnreadOnly: userDefaults.bool(forKey: DefaultsKey.showUnreadOnly)
144+
)
145+
}
146+
147+
var displayedNotifications: [PushNotification] {
148+
var items = state.notifications
149+
150+
if state.showUnreadOnly {
151+
items = items.filter { $0.isRead == false }
152+
}
153+
154+
if case let .hours(value) = state.timeFilter {
155+
let threshold = Date().addingTimeInterval(-Double(value) * 3600.0)
156+
items = items.filter { $0.receivedAt >= threshold }
157+
} else if case let .days(value) = state.timeFilter {
158+
let threshold = Date().addingTimeInterval(-Double(value) * 86400.0)
159+
items = items.filter { $0.receivedAt >= threshold }
160+
}
161+
162+
switch state.sortOption {
163+
case .latest:
164+
return items.sorted { $0.receivedAt > $1.receivedAt }
165+
case .oldest:
166+
return items.sorted { $0.receivedAt < $1.receivedAt }
167+
}
168+
}
169+
170+
var appliedFilterCount: Int {
171+
var count = 0
172+
if state.sortOption != .latest { count += 1 }
173+
if state.timeFilter != .none { count += 1 }
174+
if state.showUnreadOnly { count += 1 }
175+
return count
63176
}
64177

65178
func reduce(with action: Action) -> [SideEffect] {
@@ -96,6 +209,22 @@ final class PushNotificationViewModel: Store {
96209
state.isLoading = value
97210
case .setNotifications(let notifications):
98211
state.notifications = notifications
212+
case .toggleSortOption:
213+
state.sortOption = state.sortOption == .latest ? .oldest : .latest
214+
saveSortOption(state.sortOption)
215+
case .setTimeFilter(let filter):
216+
state.timeFilter = filter
217+
saveTimeFilter(filter)
218+
case .toggleUnreadOnly:
219+
state.showUnreadOnly.toggle()
220+
userDefaults.set(state.showUnreadOnly, forKey: DefaultsKey.showUnreadOnly)
221+
case .resetFilters:
222+
state.sortOption = .latest
223+
state.timeFilter = .none
224+
state.showUnreadOnly = false
225+
saveSortOption(.latest)
226+
saveTimeFilter(.none)
227+
userDefaults.set(false, forKey: DefaultsKey.showUnreadOnly)
99228
}
100229

101230
self.state = state
@@ -166,4 +295,25 @@ private extension PushNotificationViewModel {
166295
}
167296
state.showToast = isPresented
168297
}
298+
299+
static func loadSortOption(userDefaults: UserDefaults) -> SortOption {
300+
guard let rawValue = userDefaults.string(forKey: DefaultsKey.sortOption) else {
301+
return .latest
302+
}
303+
return rawValue == "oldest" ? .oldest : .latest
304+
}
305+
306+
static func loadTimeFilter(userDefaults: UserDefaults) -> TimeFilter {
307+
let id = userDefaults.string(forKey: DefaultsKey.timeFilter) ?? "none"
308+
return TimeFilter(id: id)
309+
}
310+
311+
func saveSortOption(_ option: SortOption) {
312+
let value = option == .oldest ? "oldest" : "latest"
313+
userDefaults.set(value, forKey: DefaultsKey.sortOption)
314+
}
315+
316+
func saveTimeFilter(_ filter: TimeFilter) {
317+
userDefaults.set(filter.id, forKey: DefaultsKey.timeFilter)
318+
}
169319
}

DevLog/Resource/Localizable.xcstrings

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
},
77
"%@ 검색" : {
88

9+
},
10+
"%lld" : {
11+
12+
},
13+
"%lld개 필터가 적용됨" : {
14+
915
},
1016
"lime_green" : {
1117
"extractionState" : "manual",
@@ -265,6 +271,9 @@
265271
},
266272
"계정 연동" : {
267273

274+
},
275+
"기간" : {
276+
268277
},
269278
"로그아웃" : {
270279

@@ -277,6 +286,9 @@
277286
},
278287
"마감일" : {
279288

289+
},
290+
"모든 필터 지우기" : {
291+
280292
},
281293
"미리보기" : {
282294

@@ -334,6 +346,9 @@
334346
},
335347
"완료" : {
336348

349+
},
350+
"읽지 않음" : {
351+
337352
},
338353
"작년" : {
339354

@@ -343,6 +358,9 @@
343358
},
344359
"정렬 옵션" : {
345360

361+
},
362+
"정렬: %@" : {
363+
346364
},
347365
"정말 탈퇴하시겠습니까?" : {
348366

DevLog/UI/PushNotification/PushNotificationView.swift

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,31 @@ import SwiftUI
1010
struct PushNotificationView: View {
1111
@StateObject private var router = NavigationRouter()
1212
@StateObject var viewModel: PushNotificationViewModel
13+
@Environment(\.colorScheme) private var colorScheme
1314

1415
var body: some View {
1516
NavigationStack(path: $router.path) {
16-
VStack {
17-
if viewModel.state.notifications.isEmpty {
18-
Spacer()
19-
Text("받은 알림이 없습니다.")
20-
.foregroundStyle(Color.gray)
21-
Spacer()
22-
} else {
23-
List(viewModel.state.notifications, id: \.id) { notification in
24-
notificationRow(notification)
17+
List {
18+
Section {
19+
if viewModel.displayedNotifications.isEmpty {
20+
HStack {
21+
Spacer()
22+
Text("받은 알림이 없습니다.")
23+
.foregroundStyle(Color.gray)
24+
Spacer()
25+
}
26+
.listRowSeparator(.hidden)
27+
} else {
28+
ForEach(viewModel.displayedNotifications, id: \.id) { notification in
29+
notificationRow(notification)
30+
}
2531
}
26-
.listStyle(.plain)
32+
} header: {
33+
headerView
2734
}
35+
.listRowBackground(Color.clear)
2836
}
29-
.frame(maxWidth: .infinity, alignment: .center)
37+
.listStyle(.plain)
3038
.background(Color(.secondarySystemBackground))
3139
.onAppear { viewModel.send(.fetchNotifications) }
3240
.navigationTitle("받은 푸시 알람")
@@ -56,6 +64,80 @@ struct PushNotificationView: View {
5664
}
5765
}
5866

67+
private var headerView: some View {
68+
ScrollView(.horizontal) {
69+
HStack(spacing: 8) {
70+
if 0 < viewModel.appliedFilterCount {
71+
Menu {
72+
Text("\(viewModel.appliedFilterCount)개 필터가 적용됨")
73+
Button(role: .destructive) {
74+
viewModel.send(.resetFilters)
75+
} label: {
76+
Text("모든 필터 지우기")
77+
}
78+
} label: {
79+
HStack(spacing: 6) {
80+
Image(systemName: "line.3.horizontal.decrease")
81+
filterBadge
82+
}
83+
}
84+
.adaptiveButtonStyle()
85+
}
86+
87+
Button {
88+
viewModel.send(.toggleSortOption)
89+
} label: {
90+
Text("정렬: \(viewModel.state.sortOption.title)")
91+
}
92+
.adaptiveButtonStyle(viewModel.state.sortOption == .oldest ? .blue : .clear)
93+
94+
Menu {
95+
ForEach(PushNotificationViewModel.TimeFilter.availableOptions, id: \.id) { option in
96+
Button {
97+
viewModel.send(.setTimeFilter(option))
98+
} label: {
99+
HStack {
100+
Text(option.title)
101+
Spacer()
102+
if viewModel.state.timeFilter == option {
103+
Image(systemName: "checkmark")
104+
.tint(.blue)
105+
}
106+
}
107+
.frame(maxWidth: .infinity, alignment: .leading)
108+
}
109+
}
110+
} label: {
111+
Text("기간")
112+
}
113+
.adaptiveButtonStyle(viewModel.state.timeFilter == .none ? .clear : .blue)
114+
115+
Button {
116+
viewModel.send(.toggleUnreadOnly)
117+
} label: {
118+
Text("읽지 않음")
119+
}
120+
.adaptiveButtonStyle(viewModel.state.showUnreadOnly ? .blue : .clear)
121+
}
122+
}
123+
.scrollIndicators(.never)
124+
}
125+
126+
private var filterBadge: some View {
127+
let isDark = colorScheme == .dark
128+
let blue = Color(uiColor: .systemBlue) // 흰 배경에 따른 청록색화 방지
129+
let textColor: Color = isDark ? blue : .white
130+
let backgroundColor: Color = isDark ? .white : blue
131+
132+
return Text("\(viewModel.appliedFilterCount)")
133+
.font(.caption2.weight(.bold))
134+
.foregroundColor(textColor)
135+
.lineLimit(1)
136+
.minimumScaleFactor(0.6)
137+
.frame(width: 20, height: 20)
138+
.background(Circle().fill(backgroundColor))
139+
}
140+
59141
private func notificationRow(_ notification: PushNotification) -> some View {
60142
HStack {
61143
Circle()
@@ -82,7 +164,6 @@ struct PushNotificationView: View {
82164
}
83165
}
84166
.padding(.vertical, 5)
85-
.listRowBackground(Color.clear)
86167
.swipeActions(edge: .leading) {
87168
Button {
88169
viewModel.send(.toggleRead(notification))
@@ -102,10 +183,10 @@ struct PushNotificationView: View {
102183
}
103184
}
104185
}
105-
186+
106187
private func timeAgoText(from date: Date, now: Date) -> String {
107188
let seconds = Int(now.timeIntervalSince(date))
108-
189+
109190
if seconds < 60 {
110191
return "\(max(0, seconds))초 전"
111192
} else if seconds < 3600 {

0 commit comments

Comments
 (0)