feat: enhance notification handling with improved data structure and API integration
This commit is contained in:
@@ -398,11 +398,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = WA8SWY233K;
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Mayday/Info.plist;
|
INFOPLIST_FILE = Mayday/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Mayday;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -423,11 +425,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = WA8SWY233K;
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Mayday/Info.plist;
|
INFOPLIST_FILE = Mayday/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Mayday;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
+4
-13
@@ -20,6 +20,10 @@
|
|||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||||
|
<true/>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
@@ -29,10 +33,6 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSSupportsLiveActivities</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
||||||
<true/>
|
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
@@ -42,15 +42,6 @@
|
|||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -2,46 +2,107 @@ import Foundation
|
|||||||
|
|
||||||
struct AppNotification: Codable, Identifiable, Sendable {
|
struct AppNotification: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let topic: String
|
let userId: UUID
|
||||||
let subject: String
|
let scopeId: UUID?
|
||||||
|
let channel: NotificationChannel
|
||||||
|
let contentType: ContentType
|
||||||
|
let templateId: UUID?
|
||||||
|
let subject: String?
|
||||||
let body: String
|
let body: String
|
||||||
|
let source: String?
|
||||||
let metadata: [String: String]?
|
let metadata: [String: String]?
|
||||||
let status: NotificationStatus
|
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 readAt: Date?
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let updatedAt: Date
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
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 readAt = "read_at"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_at"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isRead: Bool { readAt != nil }
|
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 {
|
enum NotificationStatus: String, Codable, Sendable {
|
||||||
|
case pending
|
||||||
case sent
|
case sent
|
||||||
case delivered
|
case failed
|
||||||
case read
|
case read
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationChannel: String, Codable, Sendable {
|
enum NotificationChannel: String, Codable, Sendable {
|
||||||
case inApp = "in_app"
|
|
||||||
case push
|
|
||||||
case email
|
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 {
|
struct NotificationsPage: Codable, Sendable {
|
||||||
let items: [AppNotification]
|
let notifications: [AppNotification]
|
||||||
let total: Int
|
let total: Int
|
||||||
let page: Int
|
let unreadCount: Int
|
||||||
let perPage: Int
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case items, total, page
|
case notifications, total
|
||||||
case perPage = "per_page"
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ struct APIErrorResponse: Decodable {
|
|||||||
let errors: [String: [String]]?
|
let errors: [String: [String]]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum APIService {
|
||||||
|
case sso
|
||||||
|
case notification
|
||||||
|
}
|
||||||
|
|
||||||
enum Endpoint {
|
enum Endpoint {
|
||||||
// Auth
|
// Auth
|
||||||
case login(email: String, password: String)
|
case login(email: String, password: String)
|
||||||
@@ -45,11 +50,28 @@ enum Endpoint {
|
|||||||
case logoutAll
|
case logoutAll
|
||||||
case changePassword(current: String, new: String)
|
case changePassword(current: String, new: String)
|
||||||
// Notifications
|
// Notifications
|
||||||
case getNotifications(page: Int, perPage: Int)
|
case getNotifications(limit: Int, offset: Int, unreadOnly: Bool, scope: String?)
|
||||||
case markAsRead(id: UUID)
|
case markAsRead(id: UUID)
|
||||||
|
case markAllAsRead(scope: String?)
|
||||||
// Devices
|
// Devices
|
||||||
case registerDevice(token: String)
|
case listDevices
|
||||||
case unregisterDevice(token: String)
|
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 {
|
var path: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -66,17 +88,25 @@ enum Endpoint {
|
|||||||
case .changePassword: return "/users/me/change-password"
|
case .changePassword: return "/users/me/change-password"
|
||||||
case .getNotifications: return "/notifications"
|
case .getNotifications: return "/notifications"
|
||||||
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
|
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
|
||||||
case .registerDevice: return "/devices/register"
|
case .markAllAsRead: return "/notifications/read-all"
|
||||||
case .unregisterDevice: return "/devices/unregister"
|
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 {
|
var method: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .getMe, .getSessions, .getNotifications: return "GET"
|
case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences:
|
||||||
case .deleteSession: return "DELETE"
|
return "GET"
|
||||||
case .markAsRead: return "PATCH"
|
case .deleteSession, .unregisterDevice:
|
||||||
default: return "POST"
|
return "DELETE"
|
||||||
|
case .upsertPreference:
|
||||||
|
return "PUT"
|
||||||
|
default:
|
||||||
|
return "POST"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +135,20 @@ enum Endpoint {
|
|||||||
return ["refresh_token": token]
|
return ["refresh_token": token]
|
||||||
case .changePassword(let current, let new):
|
case .changePassword(let current, let new):
|
||||||
return ["current_password": current, "new_password": new]
|
return ["current_password": current, "new_password": new]
|
||||||
case .registerDevice(let token):
|
case .registerDevice(let token, let platform):
|
||||||
return ["token": token, "platform": "ios"]
|
return ["token": token, "platform": platform]
|
||||||
case .unregisterDevice(let token):
|
case .getNotifications(let limit, let offset, let unreadOnly, let scope):
|
||||||
return ["token": token]
|
var params: [String: Any] = ["limit": limit, "offset": offset]
|
||||||
case .getNotifications(let page, let perPage):
|
if unreadOnly { params["unread_only"] = true }
|
||||||
return ["page": page, "per_page": perPage]
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -120,25 +158,35 @@ enum Endpoint {
|
|||||||
actor HTTPClient {
|
actor HTTPClient {
|
||||||
static let shared = HTTPClient()
|
static let shared = HTTPClient()
|
||||||
|
|
||||||
private let baseURL: String
|
private let ssoBaseURL: String
|
||||||
|
private let notificationBaseURL: String
|
||||||
private let keychain = KeychainService.shared
|
private let keychain = KeychainService.shared
|
||||||
// Single in-flight refresh task; concurrent 401s await this rather than racing.
|
// Single in-flight refresh task; concurrent 401s await this rather than racing.
|
||||||
private var refreshTask: Task<Void, Error>?
|
private var refreshTask: Task<Void, Error>?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
baseURL = "http://localhost:8081"
|
ssoBaseURL = "http://localhost:8081"
|
||||||
|
notificationBaseURL = "http://localhost:8092"
|
||||||
#else
|
#else
|
||||||
baseURL = "https://api.chemodan.example/sso"
|
ssoBaseURL = "https://id.robonen.ru"
|
||||||
|
notificationBaseURL = "https://notify.robonen.ru"
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func baseURL(for service: APIService) -> String {
|
||||||
|
switch service {
|
||||||
|
case .sso: return ssoBaseURL
|
||||||
|
case .notification: return notificationBaseURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||||
try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performRequest<T: Decodable>(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T {
|
private func performRequest<T: Decodable>(_ 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
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,46 @@ actor NotificationsAPIService {
|
|||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func getNotifications(page: Int = 1, perPage: Int = 20) async throws -> NotificationsPage {
|
// MARK: - Notifications
|
||||||
try await client.request(.getNotifications(page: page, perPage: perPage))
|
|
||||||
|
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 {
|
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] {
|
func getSessions() async throws -> [SessionResponse] {
|
||||||
try await client.request(.getSessions)
|
try await client.request(.getSessions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,78 +18,127 @@ enum PreviewData {
|
|||||||
|
|
||||||
static let mockNotifications: [AppNotification] = {
|
static let mockNotifications: [AppNotification] = {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
let mockUserId = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
|
||||||
return [
|
return [
|
||||||
AppNotification(
|
AppNotification(
|
||||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!,
|
id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!,
|
||||||
topic: "Fire Alert",
|
userId: mockUserId,
|
||||||
|
scopeId: nil,
|
||||||
|
channel: .apns,
|
||||||
|
contentType: .plain,
|
||||||
|
templateId: nil,
|
||||||
subject: "Пожарная тревога",
|
subject: "Пожарная тревога",
|
||||||
body: "Обнаружено задымление на 12 этаже, корпус 9. Необходима немедленная эвакуация персонала.",
|
body: "Обнаружено задымление на 12 этаже, корпус 9. Необходима немедленная эвакуация персонала.",
|
||||||
|
source: "Fire Alert",
|
||||||
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A", "Датчик": "SM-4021"],
|
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A", "Датчик": "SM-4021"],
|
||||||
status: .delivered,
|
status: .sent,
|
||||||
channel: .push,
|
error: nil,
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
nextRetryAt: nil,
|
||||||
|
sentAt: now.addingTimeInterval(-120),
|
||||||
readAt: nil,
|
readAt: nil,
|
||||||
createdAt: now.addingTimeInterval(-120),
|
createdAt: now.addingTimeInterval(-120)
|
||||||
updatedAt: now.addingTimeInterval(-120)
|
|
||||||
),
|
),
|
||||||
AppNotification(
|
AppNotification(
|
||||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000002")!,
|
id: UUID(uuidString: "10000000-0000-0000-0000-000000000002")!,
|
||||||
topic: "Security Alert",
|
userId: mockUserId,
|
||||||
|
scopeId: nil,
|
||||||
|
channel: .apns,
|
||||||
|
contentType: .plain,
|
||||||
|
templateId: nil,
|
||||||
subject: "Нарушение периметра",
|
subject: "Нарушение периметра",
|
||||||
body: "Зафиксировано несанкционированное проникновение через вход B2. Охрана уведомлена.",
|
body: "Зафиксировано несанкционированное проникновение через вход B2. Охрана уведомлена.",
|
||||||
|
source: "Security Alert",
|
||||||
metadata: ["Зона": "B2", "Камера": "CAM-17"],
|
metadata: ["Зона": "B2", "Камера": "CAM-17"],
|
||||||
status: .delivered,
|
status: .sent,
|
||||||
channel: .push,
|
error: nil,
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
nextRetryAt: nil,
|
||||||
|
sentAt: now.addingTimeInterval(-300),
|
||||||
readAt: nil,
|
readAt: nil,
|
||||||
createdAt: now.addingTimeInterval(-300),
|
createdAt: now.addingTimeInterval(-300)
|
||||||
updatedAt: now.addingTimeInterval(-300)
|
|
||||||
),
|
),
|
||||||
AppNotification(
|
AppNotification(
|
||||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!,
|
id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!,
|
||||||
topic: "Fire Alert",
|
userId: mockUserId,
|
||||||
|
scopeId: nil,
|
||||||
|
channel: .apns,
|
||||||
|
contentType: .plain,
|
||||||
|
templateId: nil,
|
||||||
subject: "Пожарная тревога",
|
subject: "Пожарная тревога",
|
||||||
body: "Сработала пожарная сигнализация в серверной. Автоматическая система пожаротушения активирована.",
|
body: "Сработала пожарная сигнализация в серверной. Автоматическая система пожаротушения активирована.",
|
||||||
|
source: "Fire Alert",
|
||||||
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A"],
|
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A"],
|
||||||
status: .read,
|
status: .read,
|
||||||
channel: .push,
|
error: nil,
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
nextRetryAt: nil,
|
||||||
|
sentAt: now.addingTimeInterval(-7200),
|
||||||
readAt: now.addingTimeInterval(-3600),
|
readAt: now.addingTimeInterval(-3600),
|
||||||
createdAt: now.addingTimeInterval(-7200),
|
createdAt: now.addingTimeInterval(-7200)
|
||||||
updatedAt: now.addingTimeInterval(-3600)
|
|
||||||
),
|
),
|
||||||
AppNotification(
|
AppNotification(
|
||||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000004")!,
|
id: UUID(uuidString: "10000000-0000-0000-0000-000000000004")!,
|
||||||
topic: "Medical Emergency",
|
userId: mockUserId,
|
||||||
|
scopeId: nil,
|
||||||
|
channel: .apns,
|
||||||
|
contentType: .plain,
|
||||||
|
templateId: nil,
|
||||||
subject: "Медицинская помощь",
|
subject: "Медицинская помощь",
|
||||||
body: "Запрос экстренной медицинской помощи на 3 этаже, кабинет 312. Бригада скорой помощи вызвана.",
|
body: "Запрос экстренной медицинской помощи на 3 этаже, кабинет 312. Бригада скорой помощи вызвана.",
|
||||||
|
source: "Medical Emergency",
|
||||||
metadata: ["Здание": "Корпус 9", "Этаж": "3", "Комната": "312"],
|
metadata: ["Здание": "Корпус 9", "Этаж": "3", "Комната": "312"],
|
||||||
status: .read,
|
status: .read,
|
||||||
channel: .push,
|
error: nil,
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
nextRetryAt: nil,
|
||||||
|
sentAt: now.addingTimeInterval(-7200),
|
||||||
readAt: now.addingTimeInterval(-5400),
|
readAt: now.addingTimeInterval(-5400),
|
||||||
createdAt: now.addingTimeInterval(-7200),
|
createdAt: now.addingTimeInterval(-7200)
|
||||||
updatedAt: now.addingTimeInterval(-5400)
|
|
||||||
),
|
),
|
||||||
AppNotification(
|
AppNotification(
|
||||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000005")!,
|
id: UUID(uuidString: "10000000-0000-0000-0000-000000000005")!,
|
||||||
topic: "Water Leak",
|
userId: mockUserId,
|
||||||
|
scopeId: nil,
|
||||||
|
channel: .inApp,
|
||||||
|
contentType: .plain,
|
||||||
|
templateId: nil,
|
||||||
subject: "Затопление",
|
subject: "Затопление",
|
||||||
body: "Обнаружена утечка воды в подвальном помещении. Аварийная служба на месте.",
|
body: "Обнаружена утечка воды в подвальном помещении. Аварийная служба на месте.",
|
||||||
|
source: "Water Leak",
|
||||||
metadata: ["Здание": "Корпус 3", "Этаж": "B1"],
|
metadata: ["Здание": "Корпус 3", "Этаж": "B1"],
|
||||||
status: .read,
|
status: .read,
|
||||||
channel: .inApp,
|
error: nil,
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
nextRetryAt: nil,
|
||||||
|
sentAt: now.addingTimeInterval(-90000),
|
||||||
readAt: now.addingTimeInterval(-86400),
|
readAt: now.addingTimeInterval(-86400),
|
||||||
createdAt: now.addingTimeInterval(-90000),
|
createdAt: now.addingTimeInterval(-90000)
|
||||||
updatedAt: now.addingTimeInterval(-86400)
|
|
||||||
),
|
),
|
||||||
AppNotification(
|
AppNotification(
|
||||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000006")!,
|
id: UUID(uuidString: "10000000-0000-0000-0000-000000000006")!,
|
||||||
topic: "Security Alert",
|
userId: mockUserId,
|
||||||
|
scopeId: nil,
|
||||||
|
channel: .inApp,
|
||||||
|
contentType: .plain,
|
||||||
|
templateId: nil,
|
||||||
subject: "Тестирование системы",
|
subject: "Тестирование системы",
|
||||||
body: "Плановое тестирование системы оповещения. Действий не требуется.",
|
body: "Плановое тестирование системы оповещения. Действий не требуется.",
|
||||||
|
source: "Security Alert",
|
||||||
metadata: nil,
|
metadata: nil,
|
||||||
status: .read,
|
status: .read,
|
||||||
channel: .inApp,
|
error: nil,
|
||||||
|
attempts: 1,
|
||||||
|
maxAttempts: 3,
|
||||||
|
nextRetryAt: nil,
|
||||||
|
sentAt: now.addingTimeInterval(-180000),
|
||||||
readAt: now.addingTimeInterval(-172800),
|
readAt: now.addingTimeInterval(-172800),
|
||||||
createdAt: now.addingTimeInterval(-180000),
|
createdAt: now.addingTimeInterval(-180000)
|
||||||
updatedAt: now.addingTimeInterval(-172800)
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
|||||||
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
||||||
deviceToken = token
|
deviceToken = token
|
||||||
Task {
|
Task {
|
||||||
try? await HTTPClient.shared.request(.registerDevice(token: token)) as EmptyResponse
|
try? await HTTPClient.shared.request(.registerDevice(token: token, platform: "ios")) as DeviceToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
class NotificationsViewModel: ObservableObject {
|
class NotificationsViewModel: ObservableObject {
|
||||||
@Published var notifications: [AppNotification] = []
|
@Published var notifications: [AppNotification] = []
|
||||||
|
@Published var unreadCount = 0
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var isLoadingMore = false
|
@Published var isLoadingMore = false
|
||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
@Published var hasMore = true
|
@Published var hasMore = true
|
||||||
|
|
||||||
private let service = NotificationsAPIService.shared
|
private let service = NotificationsAPIService.shared
|
||||||
private var currentPage = 1
|
private let limit = 50
|
||||||
private let perPage = 20
|
private var currentOffset = 0
|
||||||
private var pollingTask: Task<Void, Never>?
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
@@ -25,12 +26,13 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
currentPage = 1
|
currentOffset = 0
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
let page = try await service.getNotifications(page: 1, perPage: perPage)
|
let page = try await service.getNotifications(limit: limit, offset: 0)
|
||||||
notifications = page.items
|
notifications = page.notifications
|
||||||
hasMore = page.items.count == perPage
|
unreadCount = page.unreadCount
|
||||||
|
hasMore = notifications.count < page.total
|
||||||
updateBadge()
|
updateBadge()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
@@ -45,11 +47,12 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
defer { isLoadingMore = false }
|
defer { isLoadingMore = false }
|
||||||
do {
|
do {
|
||||||
let nextPage = currentPage + 1
|
let nextOffset = notifications.count
|
||||||
let page = try await service.getNotifications(page: nextPage, perPage: perPage)
|
let page = try await service.getNotifications(limit: limit, offset: nextOffset)
|
||||||
notifications.append(contentsOf: page.items)
|
notifications.append(contentsOf: page.notifications)
|
||||||
currentPage = nextPage
|
unreadCount = page.unreadCount
|
||||||
hasMore = page.items.count == perPage
|
currentOffset = nextOffset
|
||||||
|
hasMore = notifications.count < page.total
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -60,19 +63,7 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
if PreviewData.isPreviewMode {
|
if PreviewData.isPreviewMode {
|
||||||
if let index = notifications.firstIndex(where: { $0.id == notification.id }) {
|
if let index = notifications.firstIndex(where: { $0.id == notification.id }) {
|
||||||
let updated = AppNotification(
|
notifications[index] = notification.withReadAt(Date())
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,19 +71,7 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
try await service.markAsRead(id: notification.id)
|
try await service.markAsRead(id: notification.id)
|
||||||
if let index = notifications.firstIndex(where: { $0.id == notification.id }) {
|
if let index = notifications.firstIndex(where: { $0.id == notification.id }) {
|
||||||
let updated = AppNotification(
|
notifications[index] = notification.withReadAt(Date())
|
||||||
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
|
|
||||||
}
|
}
|
||||||
updateBadge()
|
updateBadge()
|
||||||
} catch {
|
} 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() {
|
func startPolling() {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if PreviewData.isPreviewMode { return }
|
if PreviewData.isPreviewMode { return }
|
||||||
#endif
|
#endif
|
||||||
// Guard against starting a second polling loop if already running.
|
|
||||||
guard pollingTask == nil else { return }
|
guard pollingTask == nil else { return }
|
||||||
pollingTask = Task {
|
pollingTask = Task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
@@ -121,7 +111,6 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateBadge() {
|
private func updateBadge() {
|
||||||
let unreadCount = notifications.filter { !$0.isRead }.count
|
|
||||||
UNUserNotificationCenter.current().setBadgeCount(unreadCount)
|
UNUserNotificationCenter.current().setBadgeCount(unreadCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ struct NotificationDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Text(notification.subject)
|
Text(notification.subject ?? "")
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
@@ -212,13 +212,15 @@ struct NotificationDetailView: View {
|
|||||||
private var channelLabel: String {
|
private var channelLabel: String {
|
||||||
switch notification.channel {
|
switch notification.channel {
|
||||||
case .inApp: return String(localized: "channel_in_app")
|
case .inApp: return String(localized: "channel_in_app")
|
||||||
case .push: return "Push"
|
case .apns: return "Push"
|
||||||
case .email: return "Email"
|
case .email: return "Email"
|
||||||
|
case .telegram: return "Telegram"
|
||||||
|
case .webhook: return "Webhook"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var topicIcon: String {
|
private var topicIcon: String {
|
||||||
let lowered = notification.topic.lowercased()
|
let lowered = (notification.source ?? "").lowercased()
|
||||||
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
|
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
|
||||||
return "flame.fill"
|
return "flame.fill"
|
||||||
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
|
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
|
||||||
@@ -233,7 +235,7 @@ struct NotificationDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var topicColor: Color {
|
private var topicColor: Color {
|
||||||
let lowered = notification.topic.lowercased()
|
let lowered = (notification.source ?? "").lowercased()
|
||||||
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
|
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
|
||||||
return .red
|
return .red
|
||||||
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
|
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
|
||||||
|
|||||||
@@ -146,16 +146,18 @@ struct ActiveNotificationCard: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
NotificationIconView(topic: notification.topic, isActive: true)
|
NotificationIconView(source: notification.source, isActive: true)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(notification.subject)
|
Text(notification.subject ?? "")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text(notification.topic)
|
if let source = notification.source {
|
||||||
|
Text(source)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -204,16 +206,18 @@ struct ResolvedNotificationCard: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
NotificationIconView(topic: notification.topic, isActive: false)
|
NotificationIconView(source: notification.source, isActive: false)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(notification.subject)
|
Text(notification.subject ?? "")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
Text(notification.topic)
|
if let source = notification.source {
|
||||||
|
Text(source)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -255,11 +259,11 @@ struct ResolvedNotificationCard: View {
|
|||||||
// MARK: - Notification Icon
|
// MARK: - Notification Icon
|
||||||
|
|
||||||
struct NotificationIconView: View {
|
struct NotificationIconView: View {
|
||||||
let topic: String
|
let source: String?
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
|
|
||||||
private var iconName: String {
|
private var iconName: String {
|
||||||
let lowered = topic.lowercased()
|
let lowered = (source ?? "").lowercased()
|
||||||
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
|
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
|
||||||
return "flame.fill"
|
return "flame.fill"
|
||||||
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
|
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
|
||||||
@@ -274,7 +278,7 @@ struct NotificationIconView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var iconColor: Color {
|
private var iconColor: Color {
|
||||||
let lowered = topic.lowercased()
|
let lowered = (source ?? "").lowercased()
|
||||||
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
|
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
|
||||||
return .red
|
return .red
|
||||||
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
|
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
|
||||||
|
|||||||
Reference in New Issue
Block a user