From 0947c048c1fc9509715ef42b0fad78764daba77a Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 15 Mar 2026 06:13:22 +0700 Subject: [PATCH] feat: enhance notification handling with improved data structure and API integration --- Mayday.xcodeproj/project.pbxproj | 4 + Mayday/Info.plist | 91 +++++++--------- Mayday/Models/AppNotification.swift | 89 ++++++++++++--- Mayday/Services/HTTPClient.swift | 86 +++++++++++---- Mayday/Services/NotificationsAPIService.swift | 38 ++++++- Mayday/Services/PreviewData.swift | 101 +++++++++++++----- Mayday/Services/PushNotificationService.swift | 2 +- .../ViewModels/NotificationsViewModel.swift | 67 +++++------- .../NotificationDetailView.swift | 10 +- .../Notifications/NotificationsView.swift | 30 +++--- 10 files changed, 349 insertions(+), 169 deletions(-) diff --git a/Mayday.xcodeproj/project.pbxproj b/Mayday.xcodeproj/project.pbxproj index ca40268..6ac4ea4 100644 --- a/Mayday.xcodeproj/project.pbxproj +++ b/Mayday.xcodeproj/project.pbxproj @@ -398,11 +398,13 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = WA8SWY233K; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Mayday/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Mayday; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -423,11 +425,13 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = WA8SWY233K; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Mayday/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Mayday; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Mayday/Info.plist b/Mayday/Info.plist index a56f332..c96ba2f 100644 --- a/Mayday/Info.plist +++ b/Mayday/Info.plist @@ -2,55 +2,46 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UIBackgroundModes - - remote-notification - - NSSupportsLiveActivities - - NSSupportsLiveActivitiesFrequentUpdates - - UILaunchScreen - - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + remote-notification + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + diff --git a/Mayday/Models/AppNotification.swift b/Mayday/Models/AppNotification.swift index 2635f52..6f63b7a 100644 --- a/Mayday/Models/AppNotification.swift +++ b/Mayday/Models/AppNotification.swift @@ -2,46 +2,107 @@ import Foundation struct AppNotification: Codable, Identifiable, Sendable { let id: UUID - let topic: String - let subject: String + let userId: UUID + let scopeId: UUID? + let channel: NotificationChannel + let contentType: ContentType + let templateId: UUID? + let subject: String? let body: String + let source: String? let metadata: [String: String]? let status: NotificationStatus - let channel: NotificationChannel + let error: String? + let attempts: Int + let maxAttempts: Int + let nextRetryAt: Date? + let sentAt: Date? let readAt: Date? let createdAt: Date - let updatedAt: Date enum CodingKeys: String, CodingKey { - case id, topic, subject, body, metadata, status, channel + case id + case userId = "user_id" + case scopeId = "scope_id" + case channel + case contentType = "content_type" + case templateId = "template_id" + case subject, body, source, metadata, status, error, attempts + case maxAttempts = "max_attempts" + case nextRetryAt = "next_retry_at" + case sentAt = "sent_at" case readAt = "read_at" case createdAt = "created_at" - case updatedAt = "updated_at" } var isRead: Bool { readAt != nil } + + func withReadAt(_ date: Date) -> AppNotification { + AppNotification( + id: id, userId: userId, scopeId: scopeId, channel: channel, + contentType: contentType, templateId: templateId, subject: subject, + body: body, source: source, metadata: metadata, status: .read, + error: error, attempts: attempts, maxAttempts: maxAttempts, + nextRetryAt: nextRetryAt, sentAt: sentAt, readAt: date, createdAt: createdAt + ) + } } enum NotificationStatus: String, Codable, Sendable { + case pending case sent - case delivered + case failed case read } enum NotificationChannel: String, Codable, Sendable { - case inApp = "in_app" - case push case email + case telegram + case inApp = "in_app" + case webhook + case apns +} + +enum ContentType: String, Codable, Sendable { + case plain + case html + case markdown } struct NotificationsPage: Codable, Sendable { - let items: [AppNotification] + let notifications: [AppNotification] let total: Int - let page: Int - let perPage: Int + let unreadCount: Int enum CodingKeys: String, CodingKey { - case items, total, page - case perPage = "per_page" + case notifications, total + case unreadCount = "unread_count" + } +} + +struct DeviceToken: Codable, Identifiable, Sendable { + let id: UUID + let userId: UUID + let platform: String + let token: String + let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case userId = "user_id" + case platform, token + case createdAt = "created_at" + } +} + +struct NotificationPreference: Codable, Sendable { + let userId: UUID + let channel: NotificationChannel + let enabled: Bool + let config: [String: String]? + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case channel, enabled, config } } diff --git a/Mayday/Services/HTTPClient.swift b/Mayday/Services/HTTPClient.swift index 6739e5f..20ec2d6 100644 --- a/Mayday/Services/HTTPClient.swift +++ b/Mayday/Services/HTTPClient.swift @@ -30,6 +30,11 @@ struct APIErrorResponse: Decodable { let errors: [String: [String]]? } +enum APIService { + case sso + case notification +} + enum Endpoint { // Auth case login(email: String, password: String) @@ -45,11 +50,28 @@ enum Endpoint { case logoutAll case changePassword(current: String, new: String) // Notifications - case getNotifications(page: Int, perPage: Int) + case getNotifications(limit: Int, offset: Int, unreadOnly: Bool, scope: String?) case markAsRead(id: UUID) + case markAllAsRead(scope: String?) // Devices - case registerDevice(token: String) - case unregisterDevice(token: String) + case listDevices + case registerDevice(token: String, platform: String) + case unregisterDevice(id: UUID) + // Preferences + case getPreferences + case upsertPreference(channel: String, enabled: Bool, config: [String: String]?) + + var service: APIService { + switch self { + case .login, .register, .verifyEmail, .resendCode, .refresh, .logout, + .getMe, .getSessions, .deleteSession, .logoutAll, .changePassword: + return .sso + case .getNotifications, .markAsRead, .markAllAsRead, + .listDevices, .registerDevice, .unregisterDevice, + .getPreferences, .upsertPreference: + return .notification + } + } var path: String { switch self { @@ -66,17 +88,25 @@ enum Endpoint { case .changePassword: return "/users/me/change-password" case .getNotifications: return "/notifications" case .markAsRead(let id): return "/notifications/\(id.uuidString)/read" - case .registerDevice: return "/devices/register" - case .unregisterDevice: return "/devices/unregister" + case .markAllAsRead: return "/notifications/read-all" + case .listDevices: return "/devices" + case .registerDevice: return "/devices" + case .unregisterDevice(let id): return "/devices/\(id.uuidString)" + case .getPreferences: return "/preferences" + case .upsertPreference: return "/preferences" } } var method: String { switch self { - case .getMe, .getSessions, .getNotifications: return "GET" - case .deleteSession: return "DELETE" - case .markAsRead: return "PATCH" - default: return "POST" + case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences: + return "GET" + case .deleteSession, .unregisterDevice: + return "DELETE" + case .upsertPreference: + return "PUT" + default: + return "POST" } } @@ -105,12 +135,20 @@ enum Endpoint { return ["refresh_token": token] case .changePassword(let current, let new): return ["current_password": current, "new_password": new] - case .registerDevice(let token): - return ["token": token, "platform": "ios"] - case .unregisterDevice(let token): - return ["token": token] - case .getNotifications(let page, let perPage): - return ["page": page, "per_page": perPage] + case .registerDevice(let token, let platform): + return ["token": token, "platform": platform] + case .getNotifications(let limit, let offset, let unreadOnly, let scope): + var params: [String: Any] = ["limit": limit, "offset": offset] + if unreadOnly { params["unread_only"] = true } + if let scope { params["scope"] = scope } + return params + case .markAllAsRead(let scope): + if let scope { return ["scope": scope] } + return nil + case .upsertPreference(let channel, let enabled, let config): + var params: [String: Any] = ["channel": channel, "enabled": enabled] + if let config { params["config"] = config } + return params default: return nil } @@ -120,25 +158,35 @@ enum Endpoint { actor HTTPClient { static let shared = HTTPClient() - private let baseURL: String + private let ssoBaseURL: String + private let notificationBaseURL: String private let keychain = KeychainService.shared // Single in-flight refresh task; concurrent 401s await this rather than racing. private var refreshTask: Task? private init() { #if DEBUG - baseURL = "http://localhost:8081" + ssoBaseURL = "http://localhost:8081" + notificationBaseURL = "http://localhost:8092" #else - baseURL = "https://api.chemodan.example/sso" + ssoBaseURL = "https://id.robonen.ru" + notificationBaseURL = "https://notify.robonen.ru" #endif } + private func baseURL(for service: APIService) -> String { + switch service { + case .sso: return ssoBaseURL + case .notification: return notificationBaseURL + } + } + func request(_ endpoint: Endpoint) async throws -> T { try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth) } private func performRequest(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T { - guard let url = URL(string: baseURL + endpoint.path) else { + guard let url = URL(string: baseURL(for: endpoint.service) + endpoint.path) else { throw APIError.invalidURL } diff --git a/Mayday/Services/NotificationsAPIService.swift b/Mayday/Services/NotificationsAPIService.swift index 26d16b6..9cf1d11 100644 --- a/Mayday/Services/NotificationsAPIService.swift +++ b/Mayday/Services/NotificationsAPIService.swift @@ -6,14 +6,46 @@ actor NotificationsAPIService { private init() {} - func getNotifications(page: Int = 1, perPage: Int = 20) async throws -> NotificationsPage { - try await client.request(.getNotifications(page: page, perPage: perPage)) + // MARK: - Notifications + + func getNotifications(limit: Int = 50, offset: Int = 0, unreadOnly: Bool = false, scope: String? = nil) async throws -> NotificationsPage { + try await client.request(.getNotifications(limit: limit, offset: offset, unreadOnly: unreadOnly, scope: scope)) } func markAsRead(id: UUID) async throws { - let _: AppNotification = try await client.request(.markAsRead(id: id)) + let _: EmptyResponse = try await client.request(.markAsRead(id: id)) } + func markAllAsRead(scope: String? = nil) async throws { + let _: EmptyResponse = try await client.request(.markAllAsRead(scope: scope)) + } + + // MARK: - Devices + + func listDevices() async throws -> [DeviceToken] { + try await client.request(.listDevices) + } + + func registerDevice(token: String, platform: String = "ios") async throws -> DeviceToken { + try await client.request(.registerDevice(token: token, platform: platform)) + } + + func unregisterDevice(id: UUID) async throws { + let _: EmptyResponse = try await client.request(.unregisterDevice(id: id)) + } + + // MARK: - Preferences + + func getPreferences() async throws -> [NotificationPreference] { + try await client.request(.getPreferences) + } + + func upsertPreference(channel: String, enabled: Bool, config: [String: String]? = nil) async throws { + let _: EmptyResponse = try await client.request(.upsertPreference(channel: channel, enabled: enabled, config: config)) + } + + // MARK: - SSO (User Management) + func getSessions() async throws -> [SessionResponse] { try await client.request(.getSessions) } diff --git a/Mayday/Services/PreviewData.swift b/Mayday/Services/PreviewData.swift index f154505..eae0d32 100644 --- a/Mayday/Services/PreviewData.swift +++ b/Mayday/Services/PreviewData.swift @@ -18,78 +18,127 @@ enum PreviewData { static let mockNotifications: [AppNotification] = { let now = Date() + let mockUserId = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! return [ AppNotification( id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!, - topic: "Fire Alert", + userId: mockUserId, + scopeId: nil, + channel: .apns, + contentType: .plain, + templateId: nil, subject: "Пожарная тревога", body: "Обнаружено задымление на 12 этаже, корпус 9. Необходима немедленная эвакуация персонала.", + source: "Fire Alert", metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A", "Датчик": "SM-4021"], - status: .delivered, - channel: .push, + status: .sent, + error: nil, + attempts: 1, + maxAttempts: 3, + nextRetryAt: nil, + sentAt: now.addingTimeInterval(-120), readAt: nil, - createdAt: now.addingTimeInterval(-120), - updatedAt: now.addingTimeInterval(-120) + createdAt: now.addingTimeInterval(-120) ), AppNotification( id: UUID(uuidString: "10000000-0000-0000-0000-000000000002")!, - topic: "Security Alert", + userId: mockUserId, + scopeId: nil, + channel: .apns, + contentType: .plain, + templateId: nil, subject: "Нарушение периметра", body: "Зафиксировано несанкционированное проникновение через вход B2. Охрана уведомлена.", + source: "Security Alert", metadata: ["Зона": "B2", "Камера": "CAM-17"], - status: .delivered, - channel: .push, + status: .sent, + error: nil, + attempts: 1, + maxAttempts: 3, + nextRetryAt: nil, + sentAt: now.addingTimeInterval(-300), readAt: nil, - createdAt: now.addingTimeInterval(-300), - updatedAt: now.addingTimeInterval(-300) + createdAt: now.addingTimeInterval(-300) ), AppNotification( id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!, - topic: "Fire Alert", + userId: mockUserId, + scopeId: nil, + channel: .apns, + contentType: .plain, + templateId: nil, subject: "Пожарная тревога", body: "Сработала пожарная сигнализация в серверной. Автоматическая система пожаротушения активирована.", + source: "Fire Alert", metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A"], status: .read, - channel: .push, + error: nil, + attempts: 1, + maxAttempts: 3, + nextRetryAt: nil, + sentAt: now.addingTimeInterval(-7200), readAt: now.addingTimeInterval(-3600), - createdAt: now.addingTimeInterval(-7200), - updatedAt: now.addingTimeInterval(-3600) + createdAt: now.addingTimeInterval(-7200) ), AppNotification( id: UUID(uuidString: "10000000-0000-0000-0000-000000000004")!, - topic: "Medical Emergency", + userId: mockUserId, + scopeId: nil, + channel: .apns, + contentType: .plain, + templateId: nil, subject: "Медицинская помощь", body: "Запрос экстренной медицинской помощи на 3 этаже, кабинет 312. Бригада скорой помощи вызвана.", + source: "Medical Emergency", metadata: ["Здание": "Корпус 9", "Этаж": "3", "Комната": "312"], status: .read, - channel: .push, + error: nil, + attempts: 1, + maxAttempts: 3, + nextRetryAt: nil, + sentAt: now.addingTimeInterval(-7200), readAt: now.addingTimeInterval(-5400), - createdAt: now.addingTimeInterval(-7200), - updatedAt: now.addingTimeInterval(-5400) + createdAt: now.addingTimeInterval(-7200) ), AppNotification( id: UUID(uuidString: "10000000-0000-0000-0000-000000000005")!, - topic: "Water Leak", + userId: mockUserId, + scopeId: nil, + channel: .inApp, + contentType: .plain, + templateId: nil, subject: "Затопление", body: "Обнаружена утечка воды в подвальном помещении. Аварийная служба на месте.", + source: "Water Leak", metadata: ["Здание": "Корпус 3", "Этаж": "B1"], status: .read, - channel: .inApp, + error: nil, + attempts: 1, + maxAttempts: 3, + nextRetryAt: nil, + sentAt: now.addingTimeInterval(-90000), readAt: now.addingTimeInterval(-86400), - createdAt: now.addingTimeInterval(-90000), - updatedAt: now.addingTimeInterval(-86400) + createdAt: now.addingTimeInterval(-90000) ), AppNotification( id: UUID(uuidString: "10000000-0000-0000-0000-000000000006")!, - topic: "Security Alert", + userId: mockUserId, + scopeId: nil, + channel: .inApp, + contentType: .plain, + templateId: nil, subject: "Тестирование системы", body: "Плановое тестирование системы оповещения. Действий не требуется.", + source: "Security Alert", metadata: nil, status: .read, - channel: .inApp, + error: nil, + attempts: 1, + maxAttempts: 3, + nextRetryAt: nil, + sentAt: now.addingTimeInterval(-180000), readAt: now.addingTimeInterval(-172800), - createdAt: now.addingTimeInterval(-180000), - updatedAt: now.addingTimeInterval(-172800) + createdAt: now.addingTimeInterval(-180000) ), ] }() diff --git a/Mayday/Services/PushNotificationService.swift b/Mayday/Services/PushNotificationService.swift index aaad4dd..848ca5c 100644 --- a/Mayday/Services/PushNotificationService.swift +++ b/Mayday/Services/PushNotificationService.swift @@ -32,7 +32,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined() deviceToken = token Task { - try? await HTTPClient.shared.request(.registerDevice(token: token)) as EmptyResponse + try? await HTTPClient.shared.request(.registerDevice(token: token, platform: "ios")) as DeviceToken } } diff --git a/Mayday/ViewModels/NotificationsViewModel.swift b/Mayday/ViewModels/NotificationsViewModel.swift index d66bc8c..6ab75c9 100644 --- a/Mayday/ViewModels/NotificationsViewModel.swift +++ b/Mayday/ViewModels/NotificationsViewModel.swift @@ -5,14 +5,15 @@ import UIKit @MainActor class NotificationsViewModel: ObservableObject { @Published var notifications: [AppNotification] = [] + @Published var unreadCount = 0 @Published var isLoading = false @Published var isLoadingMore = false @Published var error: String? @Published var hasMore = true private let service = NotificationsAPIService.shared - private var currentPage = 1 - private let perPage = 20 + private let limit = 50 + private var currentOffset = 0 private var pollingTask: Task? func load() async { @@ -25,12 +26,13 @@ class NotificationsViewModel: ObservableObject { #endif isLoading = true error = nil - currentPage = 1 + currentOffset = 0 defer { isLoading = false } do { - let page = try await service.getNotifications(page: 1, perPage: perPage) - notifications = page.items - hasMore = page.items.count == perPage + let page = try await service.getNotifications(limit: limit, offset: 0) + notifications = page.notifications + unreadCount = page.unreadCount + hasMore = notifications.count < page.total updateBadge() } catch { self.error = error.localizedDescription @@ -45,11 +47,12 @@ class NotificationsViewModel: ObservableObject { isLoadingMore = true defer { isLoadingMore = false } do { - let nextPage = currentPage + 1 - let page = try await service.getNotifications(page: nextPage, perPage: perPage) - notifications.append(contentsOf: page.items) - currentPage = nextPage - hasMore = page.items.count == perPage + let nextOffset = notifications.count + let page = try await service.getNotifications(limit: limit, offset: nextOffset) + notifications.append(contentsOf: page.notifications) + unreadCount = page.unreadCount + currentOffset = nextOffset + hasMore = notifications.count < page.total } catch { self.error = error.localizedDescription } @@ -60,19 +63,7 @@ class NotificationsViewModel: ObservableObject { #if DEBUG if PreviewData.isPreviewMode { if let index = notifications.firstIndex(where: { $0.id == notification.id }) { - let updated = AppNotification( - id: notification.id, - topic: notification.topic, - subject: notification.subject, - body: notification.body, - metadata: notification.metadata, - status: .read, - channel: notification.channel, - readAt: Date(), - createdAt: notification.createdAt, - updatedAt: Date() - ) - notifications[index] = updated + notifications[index] = notification.withReadAt(Date()) } return } @@ -80,19 +71,7 @@ class NotificationsViewModel: ObservableObject { do { try await service.markAsRead(id: notification.id) if let index = notifications.firstIndex(where: { $0.id == notification.id }) { - let updated = AppNotification( - id: notification.id, - topic: notification.topic, - subject: notification.subject, - body: notification.body, - metadata: notification.metadata, - status: .read, - channel: notification.channel, - readAt: Date(), - createdAt: notification.createdAt, - updatedAt: Date() - ) - notifications[index] = updated + notifications[index] = notification.withReadAt(Date()) } updateBadge() } catch { @@ -100,11 +79,22 @@ class NotificationsViewModel: ObservableObject { } } + func markAllAsRead() async { + #if DEBUG + if PreviewData.isPreviewMode { return } + #endif + do { + try await service.markAllAsRead() + await load() + } catch { + self.error = error.localizedDescription + } + } + func startPolling() { #if DEBUG if PreviewData.isPreviewMode { return } #endif - // Guard against starting a second polling loop if already running. guard pollingTask == nil else { return } pollingTask = Task { while !Task.isCancelled { @@ -121,7 +111,6 @@ class NotificationsViewModel: ObservableObject { } private func updateBadge() { - let unreadCount = notifications.filter { !$0.isRead }.count UNUserNotificationCenter.current().setBadgeCount(unreadCount) } } diff --git a/Mayday/Views/Notifications/NotificationDetailView.swift b/Mayday/Views/Notifications/NotificationDetailView.swift index a5c7b6f..cc798b2 100644 --- a/Mayday/Views/Notifications/NotificationDetailView.swift +++ b/Mayday/Views/Notifications/NotificationDetailView.swift @@ -70,7 +70,7 @@ struct NotificationDetailView: View { } VStack(spacing: 6) { - Text(notification.subject) + Text(notification.subject ?? "") .font(.title3.bold()) .multilineTextAlignment(.center) @@ -212,13 +212,15 @@ struct NotificationDetailView: View { private var channelLabel: String { switch notification.channel { case .inApp: return String(localized: "channel_in_app") - case .push: return "Push" + case .apns: return "Push" case .email: return "Email" + case .telegram: return "Telegram" + case .webhook: return "Webhook" } } private var topicIcon: String { - let lowered = notification.topic.lowercased() + let lowered = (notification.source ?? "").lowercased() if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") { return "flame.fill" } else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") { @@ -233,7 +235,7 @@ struct NotificationDetailView: View { } private var topicColor: Color { - let lowered = notification.topic.lowercased() + let lowered = (notification.source ?? "").lowercased() if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") { return .red } else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") { diff --git a/Mayday/Views/Notifications/NotificationsView.swift b/Mayday/Views/Notifications/NotificationsView.swift index 0ed61b3..d128370 100644 --- a/Mayday/Views/Notifications/NotificationsView.swift +++ b/Mayday/Views/Notifications/NotificationsView.swift @@ -146,15 +146,17 @@ struct ActiveNotificationCard: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top) { - NotificationIconView(topic: notification.topic, isActive: true) + NotificationIconView(source: notification.source, isActive: true) VStack(alignment: .leading, spacing: 2) { - Text(notification.subject) + Text(notification.subject ?? "") .font(.headline) .foregroundStyle(.white) - Text(notification.topic) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.8)) + if let source = notification.source { + Text(source) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + } } Spacer() @@ -204,15 +206,17 @@ struct ResolvedNotificationCard: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top) { - NotificationIconView(topic: notification.topic, isActive: false) + NotificationIconView(source: notification.source, isActive: false) VStack(alignment: .leading, spacing: 2) { - Text(notification.subject) + Text(notification.subject ?? "") .font(.headline) .foregroundStyle(.primary) - Text(notification.topic) - .font(.subheadline) - .foregroundStyle(.secondary) + if let source = notification.source { + Text(source) + .font(.subheadline) + .foregroundStyle(.secondary) + } } Spacer() @@ -255,11 +259,11 @@ struct ResolvedNotificationCard: View { // MARK: - Notification Icon struct NotificationIconView: View { - let topic: String + let source: String? let isActive: Bool private var iconName: String { - let lowered = topic.lowercased() + let lowered = (source ?? "").lowercased() if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") { return "flame.fill" } else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") { @@ -274,7 +278,7 @@ struct NotificationIconView: View { } private var iconColor: Color { - let lowered = topic.lowercased() + let lowered = (source ?? "").lowercased() if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") { return .red } else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {