From 758f5ec05f06fa1f074de95c1124b832cdab5788 Mon Sep 17 00:00:00 2001 From: robonen Date: Sat, 14 Mar 2026 07:18:35 +0700 Subject: [PATCH] feat: enhance models and services with Sendable conformance, add preview data for debugging --- Mayday.xcodeproj/project.pbxproj | 73 ++--- Mayday/Info.plist | 4 + Mayday/Models/AppNotification.swift | 8 +- Mayday/Models/Session.swift | 2 +- Mayday/Models/TokenPair.swift | 2 +- Mayday/Models/User.swift | 6 +- Mayday/Services/AuthService.swift | 10 +- Mayday/Services/PreviewData.swift | 157 ++++++++++ Mayday/Services/PushNotificationService.swift | 12 +- Mayday/ViewModels/AuthViewModel.swift | 25 ++ .../ViewModels/NotificationsViewModel.swift | 35 ++- Mayday/ViewModels/SettingsViewModel.swift | 18 ++ Mayday/Views/Auth/LoginView.swift | 11 + .../NotificationDetailView.swift | 275 +++++++++++++++--- .../Notifications/NotificationsView.swift | 262 ++++++++++++++--- MaydayLiveActivity/Info.plist | 2 - .../MaydayLiveActivityLiveActivity.swift | 147 ++++++---- 17 files changed, 851 insertions(+), 198 deletions(-) create mode 100644 Mayday/Services/PreviewData.swift diff --git a/Mayday.xcodeproj/project.pbxproj b/Mayday.xcodeproj/project.pbxproj index 0c3c8c2..d69fe5d 100644 --- a/Mayday.xcodeproj/project.pbxproj +++ b/Mayday.xcodeproj/project.pbxproj @@ -32,9 +32,10 @@ AA000001000023 /* ChangePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000023 /* ChangePasswordView.swift */; }; AA000001000024 /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000024 /* SessionsView.swift */; }; AA000001000025 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000025 /* Assets.xcassets */; }; + AA000001000026 /* PreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000026A /* PreviewData.swift */; }; AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000030 /* MaydayLiveActivityBundle.swift */; }; AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000031 /* MaydayLiveActivityLiveActivity.swift */; }; - AA000001000032 /* AlertAttributes.swift in Sources (Extension) */ = {isa = PBXBuildFile; fileRef = AA000002000008 /* AlertAttributes.swift */; }; + AA000001000032 /* AlertAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000008 /* AlertAttributes.swift */; }; AA000007000001 /* MaydayLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA000008000001 /* MaydayLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -89,22 +90,23 @@ AA000002000024 /* SessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = ""; }; AA000002000025 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; AA000002000026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA000002000026A /* PreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewData.swift; sourceTree = ""; }; AA000002000030 /* MaydayLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityBundle.swift; sourceTree = ""; }; AA000002000031 /* MaydayLiveActivityLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityLiveActivity.swift; sourceTree = ""; }; - AA000002000033 /* Info.plist (Extension) */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA000002000033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AA000008000001 /* MaydayLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MaydayLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA000009000001 /* Mayday.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mayday.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - AA000010000001 /* Frameworks (App) */ = { + AA000010000001 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - AA000010000002 /* Frameworks (Extension) */ = { + AA000010000002 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -114,7 +116,7 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - AA000011000001 /* Root */ = { + AA000011000001 = { isa = PBXGroup; children = ( AA000011000002 /* Mayday */, @@ -123,15 +125,6 @@ ); sourceTree = ""; }; - AA000011000099 /* Products */ = { - isa = PBXGroup; - children = ( - AA000009000001 /* Mayday.app */, - AA000008000001 /* MaydayLiveActivity.appex */, - ); - name = Products; - sourceTree = ""; - }; AA000011000002 /* Mayday */ = { isa = PBXGroup; children = ( @@ -168,6 +161,7 @@ AA000002000011 /* AuthService.swift */, AA000002000012 /* NotificationsAPIService.swift */, AA000002000013 /* PushNotificationService.swift */, + AA000002000026A /* PreviewData.swift */, ); path = Services; sourceTree = ""; @@ -226,11 +220,20 @@ children = ( AA000002000030 /* MaydayLiveActivityBundle.swift */, AA000002000031 /* MaydayLiveActivityLiveActivity.swift */, - AA000002000033 /* Info.plist (Extension) */, + AA000002000033 /* Info.plist */, ); path = MaydayLiveActivity; sourceTree = ""; }; + AA000011000099 /* Products */ = { + isa = PBXGroup; + children = ( + AA000009000001 /* Mayday.app */, + AA000008000001 /* MaydayLiveActivity.appex */, + ); + name = Products; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -297,7 +300,7 @@ en, Base, ); - mainGroup = AA000011000001 /* Root */; + mainGroup = AA000011000001; productRefGroup = AA000011000099 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -309,7 +312,7 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - AA000014000001 /* Resources (App) */ = { + AA000014000001 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -317,7 +320,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - AA000014000002 /* Resources (Extension) */ = { + AA000014000002 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -327,7 +330,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - AA000013000001 /* Sources (App) */ = { + AA000013000001 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -355,16 +358,17 @@ AA000001000022 /* SettingsView.swift in Sources */, AA000001000023 /* ChangePasswordView.swift in Sources */, AA000001000024 /* SessionsView.swift in Sources */, + AA000001000026 /* PreviewData.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - AA000013000002 /* Sources (Extension) */ = { + AA000013000002 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */, AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */, - AA000001000032 /* AlertAttributes.swift in Sources (Extension) */, + AA000001000032 /* AlertAttributes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -379,23 +383,24 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - AA000016000001 /* Debug (App) */ = { + AA000016000001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = WA8SWY233K; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Mayday/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday"; + PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; @@ -403,23 +408,24 @@ }; name = Debug; }; - AA000016000002 /* Release (App) */ = { + AA000016000002 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = WA8SWY233K; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Mayday/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday"; + PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; @@ -427,7 +433,7 @@ }; name = Release; }; - AA000016000003 /* Debug (Extension) */ = { + AA000016000003 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; @@ -442,7 +448,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday.liveactivity"; + PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -451,7 +457,7 @@ }; name = Debug; }; - AA000016000004 /* Release (Extension) */ = { + AA000016000004 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; @@ -466,7 +472,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday.liveactivity"; + PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -475,7 +481,7 @@ }; name = Release; }; - AA000016000005 /* Debug (Project) */ = { + AA000016000005 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -537,7 +543,7 @@ }; name = Debug; }; - AA000016000006 /* Release (Project) */ = { + AA000016000006 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -623,7 +629,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - }; rootObject = AA000004000001 /* Project object */; } diff --git a/Mayday/Info.plist b/Mayday/Info.plist index c60f521..edda67b 100644 --- a/Mayday/Info.plist +++ b/Mayday/Info.plist @@ -29,6 +29,10 @@ remote-notification + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + UILaunchScreen UIRequiredDeviceCapabilities diff --git a/Mayday/Models/AppNotification.swift b/Mayday/Models/AppNotification.swift index 9f439b8..2635f52 100644 --- a/Mayday/Models/AppNotification.swift +++ b/Mayday/Models/AppNotification.swift @@ -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 diff --git a/Mayday/Models/Session.swift b/Mayday/Models/Session.swift index b18811f..fd822d2 100644 --- a/Mayday/Models/Session.swift +++ b/Mayday/Models/Session.swift @@ -1,6 +1,6 @@ import Foundation -struct SessionResponse: Codable, Identifiable { +struct SessionResponse: Codable, Identifiable, Sendable { let id: UUID let userAgent: String let ipAddress: String diff --git a/Mayday/Models/TokenPair.swift b/Mayday/Models/TokenPair.swift index 1b2d6d9..ff8a57d 100644 --- a/Mayday/Models/TokenPair.swift +++ b/Mayday/Models/TokenPair.swift @@ -1,6 +1,6 @@ import Foundation -struct TokenPair: Codable { +struct TokenPair: Codable, Sendable { let accessToken: String let refreshToken: String let expiresAt: Date diff --git a/Mayday/Models/User.swift b/Mayday/Models/User.swift index bca3c1e..a516268 100644 --- a/Mayday/Models/User.swift +++ b/Mayday/Models/User.swift @@ -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) { diff --git a/Mayday/Services/AuthService.swift b/Mayday/Services/AuthService.swift index 4827108..cd696fa 100644 --- a/Mayday/Services/AuthService.swift +++ b/Mayday/Services/AuthService.swift @@ -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 {} diff --git a/Mayday/Services/PreviewData.swift b/Mayday/Services/PreviewData.swift new file mode 100644 index 0000000..f154505 --- /dev/null +++ b/Mayday/Services/PreviewData.swift @@ -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.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.request( + attributes: attributes, + content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(3600)) + ) + } + + static func stopMockLiveActivity() async { + for activity in Activity.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 diff --git a/Mayday/Services/PushNotificationService.swift b/Mayday/Services/PushNotificationService.swift index 9b3b532..aaad4dd 100644 --- a/Mayday/Services/PushNotificationService.swift +++ b/Mayday/Services/PushNotificationService.swift @@ -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 diff --git a/Mayday/ViewModels/AuthViewModel.swift b/Mayday/ViewModels/AuthViewModel.swift index e3902d6..11c6c76 100644 --- a/Mayday/ViewModels/AuthViewModel.swift +++ b/Mayday/ViewModels/AuthViewModel.swift @@ -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 { diff --git a/Mayday/ViewModels/NotificationsViewModel.swift b/Mayday/ViewModels/NotificationsViewModel.swift index 26db44d..d66bc8c 100644 --- a/Mayday/ViewModels/NotificationsViewModel.swift +++ b/Mayday/ViewModels/NotificationsViewModel.swift @@ -16,6 +16,13 @@ class NotificationsViewModel: ObservableObject { private var pollingTask: Task? 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) } } diff --git a/Mayday/ViewModels/SettingsViewModel.swift b/Mayday/ViewModels/SettingsViewModel.swift index a0a78da..273aef3 100644 --- a/Mayday/ViewModels/SettingsViewModel.swift +++ b/Mayday/ViewModels/SettingsViewModel.swift @@ -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 } diff --git a/Mayday/Views/Auth/LoginView.swift b/Mayday/Views/Auth/LoginView.swift index bf18ca2..926b125 100644 --- a/Mayday/Views/Auth/LoginView.swift +++ b/Mayday/Views/Auth/LoginView.swift @@ -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() diff --git a/Mayday/Views/Notifications/NotificationDetailView.swift b/Mayday/Views/Notifications/NotificationDetailView.swift index 5b23737..b3b3ece 100644 --- a/Mayday/Views/Notifications/NotificationDetailView.swift +++ b/Mayday/Views/Notifications/NotificationDetailView.swift @@ -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 + } + } } diff --git a/Mayday/Views/Notifications/NotificationsView.swift b/Mayday/Views/Notifications/NotificationsView.swift index 929e3b7..7fc927f 100644 --- a/Mayday/Views/Notifications/NotificationsView.swift +++ b/Mayday/Views/Notifications/NotificationsView.swift @@ -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) } } diff --git a/MaydayLiveActivity/Info.plist b/MaydayLiveActivity/Info.plist index a2ac9bc..806c31b 100644 --- a/MaydayLiveActivity/Info.plist +++ b/MaydayLiveActivity/Info.plist @@ -24,8 +24,6 @@ NSExtensionPointIdentifier com.apple.widgetkit-extension - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).MaydayLiveActivityBundle diff --git a/MaydayLiveActivity/MaydayLiveActivityLiveActivity.swift b/MaydayLiveActivity/MaydayLiveActivityLiveActivity.swift index df3808c..39d4164 100644 --- a/MaydayLiveActivity/MaydayLiveActivityLiveActivity.swift +++ b/MaydayLiveActivity/MaydayLiveActivityLiveActivity.swift @@ -5,49 +5,54 @@ import SwiftUI struct MaydayLiveActivityLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: AlertAttributes.self) { context in - // Lock Screen / Notification Center lockScreenView(context: context) - .activityBackgroundTint(severityColor(context.attributes.severity).opacity(0.15)) + .activityBackgroundTint(severityColor(context.attributes.severity).opacity(0.12)) .activitySystemActionForegroundColor(.primary) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Label(context.attributes.topic, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(severityColor(context.attributes.severity)) + HStack(spacing: 6) { + Image(systemName: severityIcon(context.attributes.severity)) + .font(.caption) + Text(context.attributes.topic) + .font(.caption.bold()) + } + .foregroundStyle(severityColor(context.attributes.severity)) } DynamicIslandExpandedRegion(.trailing) { - if let value = context.state.value { - Text(value) - .font(.caption.bold()) - .foregroundStyle(severityColor(context.attributes.severity)) - } + statusBadge(context.state.status) } DynamicIslandExpandedRegion(.bottom) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(context.state.title) - .font(.subheadline.bold()) - Text("Начало: \(context.state.startedAt.formatted(date: .omitted, time: .shortened))") - .font(.caption2) - .foregroundStyle(.secondary) - } - Spacer() - VStack(alignment: .trailing, spacing: 2) { - statusBadge(context.state.status) - // Text(date, style: .timer) updates automatically without re-render. - HStack(spacing: 2) { - Text("Длит.:") - Text(context.state.startedAt, style: .timer) + VStack(spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(context.state.title) + .font(.subheadline.bold()) + if let value = context.state.value { + Text(value) + .font(.caption) + .foregroundStyle(severityColor(context.attributes.severity)) + } + } + Spacer() + VStack(alignment: .trailing, spacing: 3) { + HStack(spacing: 3) { + Image(systemName: "clock") + .font(.caption2) + Text(context.state.startedAt, style: .timer) + .font(.caption.monospacedDigit()) + } + .foregroundStyle(.secondary) + Text(context.state.startedAt.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(.tertiary) } - .font(.caption2) - .foregroundStyle(.secondary) } } - .padding(.horizontal) + .padding(.horizontal, 4) } } compactLeading: { - Image(systemName: "exclamationmark.triangle.fill") + Image(systemName: severityIcon(context.attributes.severity)) .foregroundStyle(severityColor(context.attributes.severity)) } compactTrailing: { let shortTopic = context.attributes.topic.components(separatedBy: "/").last ?? context.attributes.topic @@ -56,7 +61,7 @@ struct MaydayLiveActivityLiveActivity: Widget { .font(.caption2) .lineLimit(1) } minimal: { - Image(systemName: "exclamationmark.triangle.fill") + Image(systemName: severityIcon(context.attributes.severity)) .foregroundStyle(severityColor(context.attributes.severity)) } .keylineTint(severityColor(context.attributes.severity)) @@ -65,33 +70,54 @@ struct MaydayLiveActivityLiveActivity: Widget { @ViewBuilder func lockScreenView(context: ActivityViewContext) -> some View { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.title2) - .foregroundStyle(severityColor(context.attributes.severity)) - - VStack(alignment: .leading, spacing: 4) { - Text(context.attributes.topic) - .font(.caption) - .foregroundStyle(.secondary) - Text(context.state.title) - .font(.subheadline.bold()) - if let value = context.state.value { - Text(value) - .font(.caption) + VStack(spacing: 12) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(severityColor(context.attributes.severity).opacity(0.15)) + .frame(width: 44, height: 44) + Image(systemName: severityIcon(context.attributes.severity)) + .font(.title3) .foregroundStyle(severityColor(context.attributes.severity)) } - // Text(date, style: .relative) updates automatically without re-render. - Text(context.state.startedAt, style: .relative) - .font(.caption2) - .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 3) { + Text(context.state.title) + .font(.subheadline.bold()) + Text(context.attributes.topic) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + statusBadge(context.state.status) } - Spacer() + HStack(spacing: 16) { + if let value = context.state.value { + HStack(spacing: 4) { + Circle() + .fill(severityColor(context.attributes.severity)) + .frame(width: 6, height: 6) + Text(value) + .font(.caption.bold()) + .foregroundStyle(severityColor(context.attributes.severity)) + } + } - statusBadge(context.state.status) + Spacer() + + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.caption2) + Text(context.state.startedAt, style: .relative) + .font(.caption) + } + .foregroundStyle(.secondary) + } } - .padding() + .padding(16) } @ViewBuilder @@ -101,18 +127,27 @@ struct MaydayLiveActivityLiveActivity: Widget { : ("завершён", .green) Text(text) .font(.caption2.bold()) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(color.opacity(0.2)) + .textCase(.uppercase) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(color.opacity(0.15)) .foregroundStyle(color) - .cornerRadius(4) + .clipShape(Capsule()) } func severityColor(_ severity: Severity) -> Color { switch severity { case .critical: return .red - case .warning: return .yellow + case .warning: return .orange case .info: return .blue } } + + func severityIcon(_ severity: Severity) -> String { + switch severity { + case .critical: return "flame.fill" + case .warning: return "exclamationmark.triangle.fill" + case .info: return "info.circle.fill" + } + } }