diff --git a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift index 2f26829..8698902 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift @@ -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 { @@ -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 { @@ -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] { @@ -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 @@ -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) + } } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index a59bc5f..da35c22 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -6,6 +6,12 @@ }, "%@ 검색" : { + }, + "%lld" : { + + }, + "%lld개 필터가 적용됨" : { + }, "lime_green" : { "extractionState" : "manual", @@ -265,6 +271,9 @@ }, "계정 연동" : { + }, + "기간" : { + }, "로그아웃" : { @@ -277,6 +286,9 @@ }, "마감일" : { + }, + "모든 필터 지우기" : { + }, "미리보기" : { @@ -334,6 +346,9 @@ }, "완료" : { + }, + "읽지 않음" : { + }, "작년" : { @@ -343,6 +358,9 @@ }, "정렬 옵션" : { + }, + "정렬: %@" : { + }, "정말 탈퇴하시겠습니까?" : { diff --git a/DevLog/UI/PushNotification/PushNotificationView.swift b/DevLog/UI/PushNotification/PushNotificationView.swift index 3536d13..aeab0f2 100644 --- a/DevLog/UI/PushNotification/PushNotificationView.swift +++ b/DevLog/UI/PushNotification/PushNotificationView.swift @@ -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("받은 푸시 알람") @@ -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() @@ -82,7 +164,6 @@ struct PushNotificationView: View { } } .padding(.vertical, 5) - .listRowBackground(Color.clear) .swipeActions(edge: .leading) { Button { viewModel.send(.toggleRead(notification)) @@ -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 {