feat: enhance models and services with Sendable conformance, add preview data for debugging

This commit is contained in:
2026-03-14 07:18:35 +07:00
parent a4b475b13f
commit 758f5ec05f
17 changed files with 851 additions and 198 deletions
+4
View File
@@ -29,6 +29,10 @@
<array>
<string>remote-notification</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
+4 -4
View File
@@ -1,6 +1,6 @@
import Foundation
struct AppNotification: Codable, Identifiable {
struct AppNotification: Codable, Identifiable, Sendable {
let id: UUID
let topic: String
let subject: String
@@ -22,19 +22,19 @@ struct AppNotification: Codable, Identifiable {
var isRead: Bool { readAt != nil }
}
enum NotificationStatus: String, Codable {
enum NotificationStatus: String, Codable, Sendable {
case sent
case delivered
case read
}
enum NotificationChannel: String, Codable {
enum NotificationChannel: String, Codable, Sendable {
case inApp = "in_app"
case push
case email
}
struct NotificationsPage: Codable {
struct NotificationsPage: Codable, Sendable {
let items: [AppNotification]
let total: Int
let page: Int
+1 -1
View File
@@ -1,6 +1,6 @@
import Foundation
struct SessionResponse: Codable, Identifiable {
struct SessionResponse: Codable, Identifiable, Sendable {
let id: UUID
let userAgent: String
let ipAddress: String
+1 -1
View File
@@ -1,6 +1,6 @@
import Foundation
struct TokenPair: Codable {
struct TokenPair: Codable, Sendable {
let accessToken: String
let refreshToken: String
let expiresAt: Date
+3 -3
View File
@@ -1,6 +1,6 @@
import Foundation
struct UserResponse: Codable, Identifiable {
struct UserResponse: Codable, Identifiable, Sendable {
let id: UUID
let email: String
let status: UserStatus
@@ -18,7 +18,7 @@ struct UserResponse: Codable, Identifiable {
}
}
enum UserStatus: String, Codable {
enum UserStatus: String, Codable, Sendable {
case pending
case active
case suspended
@@ -26,7 +26,7 @@ enum UserStatus: String, Codable {
}
// Helper for Any JSON values
struct AnyCodable: Codable {
struct AnyCodable: Codable, @unchecked Sendable {
let value: Any
init(_ value: Any) {
+5 -5
View File
@@ -1,11 +1,11 @@
import Foundation
struct LoginResponse: Decodable {
struct LoginResponse: Decodable, Sendable {
let user: UserResponse
let tokens: TokenPair
}
struct RegisterResponse: Decodable {
struct RegisterResponse: Decodable, Sendable {
let user: UserResponse
private enum CodingKeys: String, CodingKey {
@@ -20,7 +20,7 @@ struct RegisterResponse: Decodable {
}
}
struct VerifyEmailResponse: Decodable {
struct VerifyEmailResponse: Decodable, Sendable {
let user: UserResponse
}
@@ -64,8 +64,8 @@ actor AuthService {
}
}
struct ResendCodeResponse: Decodable {
struct ResendCodeResponse: Decodable, Sendable {
let message: String
}
struct EmptyResponse: Decodable {}
struct EmptyResponse: Decodable, Sendable {}
+157
View File
@@ -0,0 +1,157 @@
#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()
return [
AppNotification(
id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!,
topic: "Fire Alert",
subject: "Пожарная тревога",
body: "Обнаружено задымление на 12 этаже, корпус 9. Необходима немедленная эвакуация персонала.",
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A", "Датчик": "SM-4021"],
status: .delivered,
channel: .push,
readAt: nil,
createdAt: now.addingTimeInterval(-120),
updatedAt: now.addingTimeInterval(-120)
),
AppNotification(
id: UUID(uuidString: "10000000-0000-0000-0000-000000000002")!,
topic: "Security Alert",
subject: "Нарушение периметра",
body: "Зафиксировано несанкционированное проникновение через вход B2. Охрана уведомлена.",
metadata: ["Зона": "B2", "Камера": "CAM-17"],
status: .delivered,
channel: .push,
readAt: nil,
createdAt: now.addingTimeInterval(-300),
updatedAt: now.addingTimeInterval(-300)
),
AppNotification(
id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!,
topic: "Fire Alert",
subject: "Пожарная тревога",
body: "Сработала пожарная сигнализация в серверной. Автоматическая система пожаротушения активирована.",
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A"],
status: .read,
channel: .push,
readAt: now.addingTimeInterval(-3600),
createdAt: now.addingTimeInterval(-7200),
updatedAt: now.addingTimeInterval(-3600)
),
AppNotification(
id: UUID(uuidString: "10000000-0000-0000-0000-000000000004")!,
topic: "Medical Emergency",
subject: "Медицинская помощь",
body: "Запрос экстренной медицинской помощи на 3 этаже, кабинет 312. Бригада скорой помощи вызвана.",
metadata: ["Здание": "Корпус 9", "Этаж": "3", "Комната": "312"],
status: .read,
channel: .push,
readAt: now.addingTimeInterval(-5400),
createdAt: now.addingTimeInterval(-7200),
updatedAt: now.addingTimeInterval(-5400)
),
AppNotification(
id: UUID(uuidString: "10000000-0000-0000-0000-000000000005")!,
topic: "Water Leak",
subject: "Затопление",
body: "Обнаружена утечка воды в подвальном помещении. Аварийная служба на месте.",
metadata: ["Здание": "Корпус 3", "Этаж": "B1"],
status: .read,
channel: .inApp,
readAt: now.addingTimeInterval(-86400),
createdAt: now.addingTimeInterval(-90000),
updatedAt: now.addingTimeInterval(-86400)
),
AppNotification(
id: UUID(uuidString: "10000000-0000-0000-0000-000000000006")!,
topic: "Security Alert",
subject: "Тестирование системы",
body: "Плановое тестирование системы оповещения. Действий не требуется.",
metadata: nil,
status: .read,
channel: .inApp,
readAt: now.addingTimeInterval(-172800),
createdAt: now.addingTimeInterval(-180000),
updatedAt: now.addingTimeInterval(-172800)
),
]
}()
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
@@ -47,7 +47,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
// Update badge
if let badge = aps["badge"] as? Int {
await UIApplication.shared.setApplicationIconBadgeNumber(badge)
try? await UNUserNotificationCenter.current().setBadgeCount(badge)
}
}
@@ -85,9 +85,11 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
if currentActivities.count >= 3 {
// End the oldest
if let oldest = currentActivities.min(by: {
$0.contentState.startedAt < $1.contentState.startedAt
$0.content.state.startedAt < $1.content.state.startedAt
}) {
await oldest.end(ActivityContent(state: oldest.contentState, staleDate: nil), dismissalPolicy: .immediate)
let finalState = oldest.content.state
nonisolated(unsafe) let activity = oldest
await activity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
}
}
@@ -117,7 +119,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
}
// MARK: - UNUserNotificationCenterDelegate
func userNotificationCenter(
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
@@ -125,7 +127,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
completionHandler([.banner, .badge, .sound])
}
func userNotificationCenter(
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
+25
View File
@@ -12,6 +12,13 @@ class AuthViewModel: ObservableObject {
private let keychain = KeychainService.shared
func checkAuthStatus() async {
#if DEBUG
if PreviewData.isPreviewMode {
currentUser = PreviewData.mockUser
isAuthenticated = true
return
}
#endif
guard keychain.loadAccessToken() != nil else {
isAuthenticated = false
return
@@ -29,6 +36,15 @@ class AuthViewModel: ObservableObject {
}
}
#if DEBUG
func enterPreviewMode() async {
PreviewData.isPreviewMode = true
currentUser = PreviewData.mockUser
isAuthenticated = true
await PreviewData.startMockLiveActivity()
}
#endif
func login(email: String, password: String) async {
isLoading = true
error = nil
@@ -68,6 +84,15 @@ class AuthViewModel: ObservableObject {
}
func logout() async {
#if DEBUG
if PreviewData.isPreviewMode {
await PreviewData.stopMockLiveActivity()
PreviewData.isPreviewMode = false
isAuthenticated = false
currentUser = nil
return
}
#endif
isLoading = true
defer { isLoading = false }
do {
+34 -1
View File
@@ -16,6 +16,13 @@ class NotificationsViewModel: ObservableObject {
private var pollingTask: Task<Void, Never>?
func load() async {
#if DEBUG
if PreviewData.isPreviewMode {
notifications = PreviewData.mockNotifications
hasMore = false
return
}
#endif
isLoading = true
error = nil
currentPage = 1
@@ -31,6 +38,9 @@ class NotificationsViewModel: ObservableObject {
}
func loadMore() async {
#if DEBUG
if PreviewData.isPreviewMode { return }
#endif
guard !isLoadingMore && hasMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
@@ -47,6 +57,26 @@ class NotificationsViewModel: ObservableObject {
func markAsRead(_ notification: AppNotification) async {
guard !notification.isRead else { return }
#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
}
return
}
#endif
do {
try await service.markAsRead(id: notification.id)
if let index = notifications.firstIndex(where: { $0.id == notification.id }) {
@@ -71,6 +101,9 @@ class NotificationsViewModel: ObservableObject {
}
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 {
@@ -89,6 +122,6 @@ class NotificationsViewModel: ObservableObject {
private func updateBadge() {
let unreadCount = notifications.filter { !$0.isRead }.count
UIApplication.shared.applicationIconBadgeNumber = unreadCount
UNUserNotificationCenter.current().setBadgeCount(unreadCount)
}
}
+18
View File
@@ -11,6 +11,12 @@ class SettingsViewModel: ObservableObject {
private let service = NotificationsAPIService.shared
func loadSessions() async {
#if DEBUG
if PreviewData.isPreviewMode {
sessions = PreviewData.mockSessions
return
}
#endif
isLoading = true
defer { isLoading = false }
do {
@@ -21,6 +27,12 @@ class SettingsViewModel: ObservableObject {
}
func deleteSession(_ session: SessionResponse) async {
#if DEBUG
if PreviewData.isPreviewMode {
sessions.removeAll { $0.id == session.id }
return
}
#endif
do {
try await service.deleteSession(id: session.id)
sessions.removeAll { $0.id == session.id }
@@ -30,6 +42,12 @@ class SettingsViewModel: ObservableObject {
}
func changePassword(current: String, new: String) async -> Bool {
#if DEBUG
if PreviewData.isPreviewMode {
successMessage = "Пароль успешно изменён"
return true
}
#endif
isLoading = true
error = nil
defer { isLoading = false }
+11
View File
@@ -60,6 +60,17 @@ struct LoginView: View {
}
.font(.footnote)
#if DEBUG
Button {
Task { await authViewModel.enterPreviewMode() }
} label: {
Label("Демо-режим", systemImage: "play.circle.fill")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
#endif
Spacer()
}
.padding()
@@ -6,63 +6,244 @@ struct NotificationDetailView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 8) {
Text(notification.topic)
.font(.caption)
.foregroundStyle(.secondary)
Text(notification.subject)
.font(.title2.bold())
VStack(spacing: 0) {
// Hero header
headerSection
// Info cards
VStack(spacing: 16) {
detailsCard
if let metadata = notification.metadata, !metadata.isEmpty {
metadataCard(metadata)
}
statusCard
}
.padding(.horizontal, 16)
.padding(.top, 24)
.padding(.bottom, 32)
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Подробности:")
.font(.headline)
Text(notification.body)
.font(.body)
}
if let metadata = notification.metadata, !metadata.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Метаданные:")
// Mark as read button for unread notifications
if !notification.isRead {
Button {
Task { await viewModel.markAsRead(notification) }
} label: {
Text("Отметить прочитанным")
.font(.headline)
ForEach(metadata.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
HStack {
Text(key).foregroundStyle(.secondary)
Spacer()
Text(value)
}
.font(.footnote)
}
.foregroundStyle(.red)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.red.opacity(0.1))
)
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}
Divider()
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Получено") {
Text(notification.createdAt.formatted(date: .abbreviated, time: .shortened))
}
LabeledContent("Статус") {
Text(notification.status.rawValue)
}
LabeledContent("Канал") {
Text(notification.channel.rawValue)
}
}
.font(.footnote)
Spacer()
}
.padding()
}
.navigationTitle("Уведомление")
.background(Color(.systemGroupedBackground))
.navigationTitle("Подробности")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.markAsRead(notification)
}
}
// MARK: - Hero Header
private var headerSection: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(.white)
.frame(width: 88, height: 88)
.shadow(color: topicColor.opacity(0.3), radius: 12, y: 4)
Circle()
.fill(topicColor.opacity(0.15))
.frame(width: 80, height: 80)
Image(systemName: topicIcon)
.font(.system(size: 32))
.foregroundStyle(topicColor)
}
VStack(spacing: 6) {
Text(notification.subject)
.font(.title3.bold())
.multilineTextAlignment(.center)
Text(notification.createdAt.formatted(date: .abbreviated, time: .shortened))
.font(.subheadline)
.foregroundStyle(.secondary)
}
statusBadge
}
.padding(.vertical, 28)
.frame(maxWidth: .infinity)
}
// MARK: - Status Badge
private var statusBadge: some View {
let (text, color): (String, Color) = notification.isRead
? ("Прочитано", .green)
: ("Новое", .red)
return Text(text)
.font(.caption.bold())
.foregroundStyle(color)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
// MARK: - Details Card
private var detailsCard: some View {
VStack(alignment: .leading, spacing: 12) {
Label("Подробности", systemImage: "doc.text.fill")
.font(.subheadline.bold())
.foregroundStyle(.primary)
Text(notification.body)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
}
// MARK: - Metadata Card
private func metadataCard(_ metadata: [String: String]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Label("Информация", systemImage: "info.circle.fill")
.font(.subheadline.bold())
.foregroundStyle(.primary)
let sortedKeys = metadata.keys.sorted()
let columns = min(sortedKeys.count, 2)
if columns == 1 {
ForEach(sortedKeys, id: \.self) { key in
metadataItem(key: key, value: metadata[key] ?? "")
}
} else {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 2), spacing: 12) {
ForEach(sortedKeys, id: \.self) { key in
metadataItem(key: key, value: metadata[key] ?? "")
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
}
private func metadataItem(key: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(key)
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(value)
.font(.subheadline.bold())
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.systemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Status Card
private var statusCard: some View {
VStack(alignment: .leading, spacing: 12) {
Label("Статус", systemImage: "clock.fill")
.font(.subheadline.bold())
.foregroundStyle(.primary)
VStack(spacing: 8) {
infoRow(icon: "paperplane.fill", label: "Канал", value: channelLabel)
Divider()
infoRow(icon: "clock", label: "Получено", value: notification.createdAt.formatted(date: .abbreviated, time: .shortened))
if let readAt = notification.readAt {
Divider()
infoRow(icon: "checkmark.circle.fill", label: "Прочитано", value: readAt.formatted(date: .abbreviated, time: .shortened))
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
}
private func infoRow(icon: String, label: String, value: String) -> some View {
HStack {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 20)
Text(label)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text(value)
.font(.subheadline)
.foregroundStyle(.primary)
}
}
// MARK: - Helpers
private var channelLabel: String {
switch notification.channel {
case .inApp: return "В приложении"
case .push: return "Push"
case .email: return "Email"
}
}
private var topicIcon: String {
let lowered = notification.topic.lowercased()
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
return "flame.fill"
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
return "heart.fill"
} else if lowered.contains("security") || lowered.contains("безопас") {
return "shield.fill"
} else if lowered.contains("water") || lowered.contains("вод") || lowered.contains("затоп") {
return "drop.fill"
} else {
return "exclamationmark.triangle.fill"
}
}
private var topicColor: Color {
let lowered = notification.topic.lowercased()
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
return .red
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
return .green
} else if lowered.contains("security") || lowered.contains("безопас") {
return .blue
} else if lowered.contains("water") || lowered.contains("вод") || lowered.contains("затоп") {
return .cyan
} else {
return .orange
}
}
}
@@ -5,6 +5,14 @@ struct NotificationsView: View {
@StateObject private var viewModel = NotificationsViewModel()
@State private var showSettings = false
private var unreadNotifications: [AppNotification] {
viewModel.notifications.filter { !$0.isRead }
}
private var readNotifications: [AppNotification] {
viewModel.notifications.filter { $0.isRead }
}
var body: some View {
NavigationStack {
Group {
@@ -26,13 +34,28 @@ struct NotificationsView: View {
notificationsList
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Уведомления")
.toolbar {
#if DEBUG
if PreviewData.isPreviewMode {
ToolbarItem(placement: .topBarLeading) {
Text("ДЕМО")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.orange)
.clipShape(Capsule())
}
}
#endif
ToolbarItem(placement: .topBarTrailing) {
Button {
showSettings = true
} label: {
Image(systemName: "gear")
Image(systemName: "gearshape.fill")
.foregroundStyle(.secondary)
}
}
}
@@ -54,63 +77,224 @@ struct NotificationsView: View {
}
var notificationsList: some View {
List {
ForEach(viewModel.notifications) { notification in
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
NotificationRowView(notification: notification)
}
.swipeActions(edge: .leading) {
if !notification.isRead {
Button {
Task { await viewModel.markAsRead(notification) }
} label: {
Label("Прочитано", systemImage: "checkmark")
ScrollView {
LazyVStack(spacing: 0) {
if !unreadNotifications.isEmpty {
sectionHeader("Активные")
ForEach(unreadNotifications) { notification in
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
ActiveNotificationCard(notification: notification)
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.bottom, 12)
.onAppear {
if notification.id == viewModel.notifications.last?.id {
Task { await viewModel.loadMore() }
}
}
.tint(.blue)
}
}
.onAppear {
if notification.id == viewModel.notifications.last?.id {
Task { await viewModel.loadMore() }
}
}
}
if viewModel.isLoadingMore {
HStack {
Spacer()
if !readNotifications.isEmpty {
sectionHeader("Завершённые")
ForEach(readNotifications) { notification in
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
ResolvedNotificationCard(notification: notification)
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.bottom, 12)
.onAppear {
if notification.id == viewModel.notifications.last?.id {
Task { await viewModel.loadMore() }
}
}
}
}
if viewModel.isLoadingMore {
ProgressView()
Spacer()
.padding(.vertical, 20)
}
}
.padding(.top, 4)
}
.listStyle(.plain)
}
func sectionHeader(_ title: String) -> some View {
HStack {
Text(title)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.textCase(.uppercase)
Spacer()
}
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 8)
}
}
struct NotificationRowView: View {
// MARK: - Active (Unread) Card
struct ActiveNotificationCard: View {
let notification: AppNotification
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(notification.isRead ? Color.clear : Color.blue)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top) {
NotificationIconView(topic: notification.topic, isActive: true)
VStack(alignment: .leading, spacing: 2) {
Text(notification.subject)
.font(.headline)
.foregroundStyle(.white)
Text(notification.topic)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
}
Spacer()
VStack(alignment: .leading, spacing: 4) {
Text(notification.topic)
.font(.footnote)
.foregroundStyle(.secondary)
Text(notification.subject)
.font(.body)
.fontWeight(notification.isRead ? .regular : .semibold)
Text(notification.createdAt, style: .relative)
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(.white.opacity(0.7))
}
Spacer()
if !notification.body.isEmpty {
Text(notification.body)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.85))
.lineLimit(2)
}
HStack {
Spacer()
Text("Открыть")
.font(.subheadline.bold())
.foregroundStyle(Color.red)
.padding(.horizontal, 32)
.padding(.vertical, 10)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
Spacer()
}
}
.padding(16)
.background(
LinearGradient(
colors: [Color.red, Color.red.opacity(0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 20))
.shadow(color: .red.opacity(0.3), radius: 8, y: 4)
}
}
// MARK: - Resolved (Read) Card
struct ResolvedNotificationCard: View {
let notification: AppNotification
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
NotificationIconView(topic: notification.topic, isActive: false)
VStack(alignment: .leading, spacing: 2) {
Text(notification.subject)
.font(.headline)
.foregroundStyle(.primary)
Text(notification.topic)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(notification.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
Text(notification.createdAt.formatted(date: .omitted, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if !notification.body.isEmpty {
Text(notification.body)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
if let readAt = notification.readAt {
HStack(spacing: 4) {
Image(systemName: "checkmark")
.font(.caption2)
.foregroundStyle(.green)
Text("прочитано \(readAt.formatted(date: .abbreviated, time: .shortened))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(16)
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 20))
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
}
// MARK: - Notification Icon
struct NotificationIconView: View {
let topic: String
let isActive: Bool
private var iconName: String {
let lowered = topic.lowercased()
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
return "flame.fill"
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
return "heart.fill"
} else if lowered.contains("security") || lowered.contains("безопас") {
return "shield.fill"
} else if lowered.contains("water") || lowered.contains("вод") || lowered.contains("затоп") {
return "drop.fill"
} else {
return "exclamationmark.triangle.fill"
}
}
private var iconColor: Color {
let lowered = topic.lowercased()
if lowered.contains("fire") || lowered.contains("пожар") || lowered.contains("огонь") {
return .red
} else if lowered.contains("medical") || lowered.contains("медиц") || lowered.contains("здоров") {
return .green
} else if lowered.contains("security") || lowered.contains("безопас") {
return .blue
} else if lowered.contains("water") || lowered.contains("вод") || lowered.contains("затоп") {
return .cyan
} else {
return .orange
}
}
var body: some View {
ZStack {
Circle()
.fill(isActive ? .white.opacity(0.25) : iconColor.opacity(0.12))
.frame(width: 40, height: 40)
Image(systemName: iconName)
.font(.body)
.foregroundStyle(isActive ? .white : iconColor)
}
.padding(.vertical, 4)
}
}