refactor: notifications and settings view models; enhance login and registration UI
This commit is contained in:
@@ -5,23 +5,8 @@ struct LoginResponse: Decodable, Sendable {
|
||||
let tokens: TokenPair
|
||||
}
|
||||
|
||||
struct RegisterResponse: Decodable, Sendable {
|
||||
let user: UserResponse
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, email, status, metadata, roles
|
||||
case emailVerifiedAt = "email_verified_at"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
user = try UserResponse(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
struct VerifyEmailResponse: Decodable, Sendable {
|
||||
let user: UserResponse
|
||||
struct MessageResponse: Decodable, Sendable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
actor AuthService {
|
||||
@@ -42,13 +27,12 @@ actor AuthService {
|
||||
return response
|
||||
}
|
||||
|
||||
func verifyEmail(email: String, code: String) async throws -> UserResponse {
|
||||
let response: VerifyEmailResponse = try await client.request(.verifyEmail(email: email, code: code))
|
||||
return response.user
|
||||
func verifyEmail(email: String, code: String) async throws {
|
||||
let _: MessageResponse = try await client.request(.verifyEmail(email: email, code: code))
|
||||
}
|
||||
|
||||
func resendCode(email: String) async throws {
|
||||
let _: ResendCodeResponse = try await client.request(.resendCode(email: email))
|
||||
let _: MessageResponse = try await client.request(.resendCode(email: email))
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
@@ -64,8 +48,4 @@ actor AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
struct ResendCodeResponse: Decodable, Sendable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable, Sendable {}
|
||||
|
||||
@@ -166,8 +166,8 @@ actor HTTPClient {
|
||||
|
||||
private init() {
|
||||
#if DEBUG
|
||||
ssoBaseURL = "http://localhost:8081"
|
||||
notificationBaseURL = "http://localhost:8092"
|
||||
ssoBaseURL = "http://192.168.3.7:8081"
|
||||
notificationBaseURL = "http://192.168.3.7:8092"
|
||||
#else
|
||||
ssoBaseURL = "https://id.robonen.ru"
|
||||
notificationBaseURL = "https://notify.robonen.ru"
|
||||
@@ -254,6 +254,13 @@ actor HTTPClient {
|
||||
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
// 204 No Content — return empty decodable if possible
|
||||
if httpResponse.statusCode == 204 || data.isEmpty {
|
||||
if let empty = EmptyResponse() as? T {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
do {
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
enum PreviewData {
|
||||
nonisolated(unsafe) static var isPreviewMode = false
|
||||
|
||||
static let mockUser = UserResponse(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
|
||||
email: "demo@mayday.app",
|
||||
status: .active,
|
||||
metadata: nil,
|
||||
emailVerifiedAt: Date(),
|
||||
roles: ["user"],
|
||||
createdAt: Date().addingTimeInterval(-90 * 86400),
|
||||
updatedAt: Date()
|
||||
)
|
||||
|
||||
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")!,
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .apns,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Пожарная тревога",
|
||||
body: "Обнаружено задымление на 12 этаже, корпус 9. Необходима немедленная эвакуация персонала.",
|
||||
source: "Fire Alert",
|
||||
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A", "Датчик": "SM-4021"],
|
||||
status: .sent,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-120),
|
||||
readAt: nil,
|
||||
createdAt: now.addingTimeInterval(-120)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000002")!,
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .apns,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Нарушение периметра",
|
||||
body: "Зафиксировано несанкционированное проникновение через вход B2. Охрана уведомлена.",
|
||||
source: "Security Alert",
|
||||
metadata: ["Зона": "B2", "Камера": "CAM-17"],
|
||||
status: .sent,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-300),
|
||||
readAt: nil,
|
||||
createdAt: now.addingTimeInterval(-300)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!,
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .apns,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Пожарная тревога",
|
||||
body: "Сработала пожарная сигнализация в серверной. Автоматическая система пожаротушения активирована.",
|
||||
source: "Fire Alert",
|
||||
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A"],
|
||||
status: .read,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-7200),
|
||||
readAt: now.addingTimeInterval(-3600),
|
||||
createdAt: now.addingTimeInterval(-7200)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000004")!,
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .apns,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Медицинская помощь",
|
||||
body: "Запрос экстренной медицинской помощи на 3 этаже, кабинет 312. Бригада скорой помощи вызвана.",
|
||||
source: "Medical Emergency",
|
||||
metadata: ["Здание": "Корпус 9", "Этаж": "3", "Комната": "312"],
|
||||
status: .read,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-7200),
|
||||
readAt: now.addingTimeInterval(-5400),
|
||||
createdAt: now.addingTimeInterval(-7200)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000005")!,
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .inApp,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Затопление",
|
||||
body: "Обнаружена утечка воды в подвальном помещении. Аварийная служба на месте.",
|
||||
source: "Water Leak",
|
||||
metadata: ["Здание": "Корпус 3", "Этаж": "B1"],
|
||||
status: .read,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-90000),
|
||||
readAt: now.addingTimeInterval(-86400),
|
||||
createdAt: now.addingTimeInterval(-90000)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000006")!,
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .inApp,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Тестирование системы",
|
||||
body: "Плановое тестирование системы оповещения. Действий не требуется.",
|
||||
source: "Security Alert",
|
||||
metadata: nil,
|
||||
status: .read,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-180000),
|
||||
readAt: now.addingTimeInterval(-172800),
|
||||
createdAt: now.addingTimeInterval(-180000)
|
||||
),
|
||||
]
|
||||
}()
|
||||
|
||||
static let mockSessions: [SessionResponse] = {
|
||||
let now = Date()
|
||||
return [
|
||||
SessionResponse(
|
||||
id: UUID(uuidString: "20000000-0000-0000-0000-000000000001")!,
|
||||
userAgent: "Mayday/1.0 (iPhone; iOS 18.3)",
|
||||
ipAddress: "192.168.1.42",
|
||||
isCurrent: true,
|
||||
createdAt: now.addingTimeInterval(-3600),
|
||||
expiresAt: now.addingTimeInterval(7 * 86400)
|
||||
),
|
||||
SessionResponse(
|
||||
id: UUID(uuidString: "20000000-0000-0000-0000-000000000002")!,
|
||||
userAgent: "Mayday/1.0 (iPad; iPadOS 18.3)",
|
||||
ipAddress: "192.168.1.100",
|
||||
isCurrent: false,
|
||||
createdAt: now.addingTimeInterval(-86400),
|
||||
expiresAt: now.addingTimeInterval(6 * 86400)
|
||||
),
|
||||
]
|
||||
}()
|
||||
|
||||
static func startMockLiveActivity() async {
|
||||
// End any existing demo activities first
|
||||
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == "demo-fire-alert" {
|
||||
let state = activity.content.state
|
||||
await activity.end(ActivityContent(state: state, staleDate: nil), dismissalPolicy: .immediate)
|
||||
}
|
||||
|
||||
let attributes = AlertAttributes(
|
||||
topic: "Fire Alert",
|
||||
alertId: "demo-fire-alert",
|
||||
severity: .critical
|
||||
)
|
||||
let state = AlertAttributes.ContentState(
|
||||
title: "Пожарная тревога",
|
||||
value: "Корпус 9, этаж 12",
|
||||
status: .active,
|
||||
startedAt: Date().addingTimeInterval(-120),
|
||||
updatedAt: Date()
|
||||
)
|
||||
_ = try? Activity<AlertAttributes>.request(
|
||||
attributes: attributes,
|
||||
content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(3600))
|
||||
)
|
||||
}
|
||||
|
||||
static func stopMockLiveActivity() async {
|
||||
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == "demo-fire-alert" {
|
||||
let resolvedState = AlertAttributes.ContentState(
|
||||
title: "Пожарная тревога",
|
||||
value: "Корпус 9, этаж 12",
|
||||
status: .resolved,
|
||||
startedAt: activity.content.state.startedAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
await activity.end(ActivityContent(state: resolvedState, staleDate: nil), dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -39,7 +39,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
||||
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async {
|
||||
guard let aps = userInfo["aps"] as? [String: Any] else { return }
|
||||
|
||||
// Handle Live Activity push
|
||||
// Handle explicit Live Activity push (event inside aps)
|
||||
if let event = aps["event"] as? String {
|
||||
await handleLiveActivityPush(event: event, userInfo: userInfo, aps: aps)
|
||||
return
|
||||
@@ -49,6 +49,30 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
||||
if let badge = aps["badge"] as? Int {
|
||||
try? await UNUserNotificationCenter.current().setBadgeCount(badge)
|
||||
}
|
||||
|
||||
// Start a Live Activity from a regular push if metadata contains severity
|
||||
if let metadata = userInfo["metadata"] as? [String: String],
|
||||
let severityStr = metadata["severity"],
|
||||
let severity = Severity(rawValue: severityStr),
|
||||
let source = userInfo["source"] as? String,
|
||||
let subject = userInfo["subject"] as? String {
|
||||
|
||||
let alertId = (userInfo["notificationId"] as? String) ?? UUID().uuidString
|
||||
let contentState = AlertAttributes.ContentState(
|
||||
title: subject,
|
||||
value: metadata["value"],
|
||||
status: .active,
|
||||
startedAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
await startLiveActivity(
|
||||
userInfo: userInfo,
|
||||
contentState: contentState,
|
||||
topic: source,
|
||||
alertId: alertId,
|
||||
severity: severity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLiveActivityPush(event: String, userInfo: [AnyHashable: Any], aps: [String: Any]) async {
|
||||
@@ -59,7 +83,13 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
||||
|
||||
switch event {
|
||||
case "start":
|
||||
await startLiveActivity(userInfo: userInfo, contentState: contentState)
|
||||
if let attributes = userInfo["attributes"] as? [String: Any],
|
||||
let topic = attributes["topic"] as? String,
|
||||
let alertId = attributes["alertId"] as? String,
|
||||
let severityStr = attributes["severity"] as? String,
|
||||
let severity = Severity(rawValue: severityStr) {
|
||||
await startLiveActivity(userInfo: userInfo, contentState: contentState, topic: topic, alertId: alertId, severity: severity)
|
||||
}
|
||||
case "update":
|
||||
await updateLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
|
||||
case "end":
|
||||
@@ -69,13 +99,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
||||
}
|
||||
}
|
||||
|
||||
private func startLiveActivity(userInfo: [AnyHashable: Any], contentState: AlertAttributes.ContentState) async {
|
||||
guard let attributes = userInfo["attributes"] as? [String: Any],
|
||||
let topic = attributes["topic"] as? String,
|
||||
let alertId = attributes["alertId"] as? String,
|
||||
let severityStr = attributes["severity"] as? String,
|
||||
let severity = Severity(rawValue: severityStr) else { return }
|
||||
|
||||
private func startLiveActivity(userInfo: [AnyHashable: Any], contentState: AlertAttributes.ContentState, topic: String, alertId: String, severity: Severity) async {
|
||||
// Info-level alerts don't warrant a persistent Live Activity — they are low-priority
|
||||
// and should only appear as a standard banner notification.
|
||||
guard severity != .info else { return }
|
||||
|
||||
Reference in New Issue
Block a user