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("здоров") {