Add notification badge count and MainTabView with notification tab FRE-4740 FRE-4739
- Add getUnreadCount() endpoint to NotificationsServiceProtocol - Add NotificationUnreadCountResponse model - Add badgeCount and fetchUnreadCount() to NotificationsViewModel - Update markAsRead/markAllAsRead to decrement badge count - Create MainTabView with Home, Challenges, Clubs, Notifications tabs - Add unread badge on notification tab using .badge() modifier - Support injected ViewModel in NotificationsView for shared state - Add badge count tests to NotificationServiceTests - Fetch unread count on app launch and tab switch Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -90,3 +90,7 @@ struct NotificationMarkAllReadResponse: Decodable {
|
||||
let success: Bool
|
||||
let markedCount: Int
|
||||
}
|
||||
|
||||
struct NotificationUnreadCountResponse: Decodable {
|
||||
let count: Int
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ protocol NotificationsServiceProtocol: Sendable {
|
||||
func list(params: NotificationListParams) async throws -> [NotificationItem]
|
||||
func markAsRead(id: String) async throws
|
||||
func markAllAsRead() async throws
|
||||
func getUnreadCount() async throws -> Int
|
||||
}
|
||||
|
||||
// MARK: - Default Service
|
||||
@@ -62,6 +63,17 @@ class NotificationsService: NotificationsServiceProtocol {
|
||||
_ = try JSONDecoder().decode(NotificationMarkAllReadResponse.self, from: data)
|
||||
}
|
||||
|
||||
func getUnreadCount() async throws -> Int {
|
||||
let url = baseURL.appendingPathComponent("/api/notifications/unread-count")
|
||||
let request = try buildRequest(url: url)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(NotificationUnreadCountResponse.self, from: data)
|
||||
return decoded.count
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest {
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
class NotificationsViewModel: ObservableObject {
|
||||
@Published var notifications: [NotificationItem] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var badgeCount: Int = 0
|
||||
@Published var lastRefreshDate: Date?
|
||||
@Published var error: NotificationError?
|
||||
|
||||
@@ -25,6 +26,7 @@ class NotificationsViewModel: ObservableObject {
|
||||
do {
|
||||
let fetchedNotifications = try await notificationsService.list()
|
||||
notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt }
|
||||
badgeCount = notifications.filter { !$0.isRead }.count
|
||||
} catch let error as NotificationError {
|
||||
self.error = error
|
||||
} catch {
|
||||
@@ -36,12 +38,22 @@ class NotificationsViewModel: ObservableObject {
|
||||
await fetchNotifications()
|
||||
}
|
||||
|
||||
func fetchUnreadCount() async {
|
||||
do {
|
||||
let count = try await notificationsService.getUnreadCount()
|
||||
badgeCount = count
|
||||
} catch {
|
||||
print("Failed to fetch unread count: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func markAsRead(id: String) async {
|
||||
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
|
||||
|
||||
do {
|
||||
try await notificationsService.markAsRead(id: id)
|
||||
notifications[index].isRead = true
|
||||
badgeCount = max(0, badgeCount - 1)
|
||||
objectWillChange.send()
|
||||
} catch {
|
||||
print("Failed to mark notification as read: \(error)")
|
||||
@@ -57,6 +69,7 @@ class NotificationsViewModel: ObservableObject {
|
||||
for index in notifications.indices {
|
||||
notifications[index].isRead = true
|
||||
}
|
||||
badgeCount = 0
|
||||
objectWillChange.send()
|
||||
} catch {
|
||||
print("Failed to mark all as read: \(error)")
|
||||
|
||||
79
Lendair/Views/MainTabView.swift
Normal file
79
Lendair/Views/MainTabView.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab: AppTab = .home
|
||||
@StateObject private var notificationVM = NotificationsViewModel()
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
Group {
|
||||
TrainingPlanView()
|
||||
.tag(AppTab.home)
|
||||
.tabItem {
|
||||
Label(AppTab.home.title, systemImage: AppTab.home.icon)
|
||||
}
|
||||
|
||||
ChallengesView()
|
||||
.tag(AppTab.challenges)
|
||||
.tabItem {
|
||||
Label(AppTab.challenges.title, systemImage: AppTab.challenges.icon)
|
||||
}
|
||||
|
||||
ClubsView()
|
||||
.tag(AppTab.clubs)
|
||||
.tabItem {
|
||||
Label(AppTab.clubs.title, systemImage: AppTab.clubs.icon)
|
||||
}
|
||||
|
||||
NotificationsView(viewModel: notificationVM)
|
||||
.tag(AppTab.notifications)
|
||||
.tabItem {
|
||||
Label(AppTab.notifications.title, systemImage: AppTab.notifications.icon)
|
||||
}
|
||||
.badge(notificationVM.badgeCount)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await notificationVM.fetchUnreadCount()
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedTab) { _, newTab in
|
||||
if newTab == .notifications {
|
||||
Task {
|
||||
await notificationVM.fetchNotifications()
|
||||
await notificationVM.fetchUnreadCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppTab: String, CaseIterable {
|
||||
case home
|
||||
case challenges
|
||||
case clubs
|
||||
case notifications
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: return "Home"
|
||||
case .challenges: return "Challenges"
|
||||
case .clubs: return "Clubs"
|
||||
case .notifications: return "Notifications"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .home: return "house.fill"
|
||||
case .challenges: return "flag.fill"
|
||||
case .clubs: return "person.3.fill"
|
||||
case .notifications: return "bell.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainTabView()
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NotificationsView: View {
|
||||
@StateObject private var viewModel = NotificationsViewModel()
|
||||
@StateObject private var viewModel: NotificationsViewModel
|
||||
@State private var showingRefreshIndicator = false
|
||||
|
||||
init(viewModel: NotificationsViewModel = NotificationsViewModel()) {
|
||||
self._viewModel = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
|
||||
Reference in New Issue
Block a user