feat: enhance models and services with Sendable conformance, add preview data for debugging
This commit is contained in:
@@ -32,9 +32,10 @@
|
|||||||
AA000001000023 /* ChangePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000023 /* ChangePasswordView.swift */; };
|
AA000001000023 /* ChangePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000023 /* ChangePasswordView.swift */; };
|
||||||
AA000001000024 /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000024 /* SessionsView.swift */; };
|
AA000001000024 /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000024 /* SessionsView.swift */; };
|
||||||
AA000001000025 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000025 /* Assets.xcassets */; };
|
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 */; };
|
AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000030 /* MaydayLiveActivityBundle.swift */; };
|
||||||
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000031 /* MaydayLiveActivityLiveActivity.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, ); }; };
|
AA000007000001 /* MaydayLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA000008000001 /* MaydayLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -89,22 +90,23 @@
|
|||||||
AA000002000024 /* SessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = "<group>"; };
|
AA000002000024 /* SessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = "<group>"; };
|
||||||
AA000002000025 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
AA000002000025 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
AA000002000026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
AA000002000026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
AA000002000026A /* PreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewData.swift; sourceTree = "<group>"; };
|
||||||
AA000002000030 /* MaydayLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityBundle.swift; sourceTree = "<group>"; };
|
AA000002000030 /* MaydayLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityBundle.swift; sourceTree = "<group>"; };
|
||||||
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityLiveActivity.swift; sourceTree = "<group>"; };
|
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityLiveActivity.swift; sourceTree = "<group>"; };
|
||||||
AA000002000033 /* Info.plist (Extension) */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
AA000002000033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
AA000008000001 /* MaydayLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MaydayLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
AA000009000001 /* Mayday.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mayday.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
AA000010000001 /* Frameworks (App) */ = {
|
AA000010000001 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
AA000010000002 /* Frameworks (Extension) */ = {
|
AA000010000002 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -114,7 +116,7 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
AA000011000001 /* Root */ = {
|
AA000011000001 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AA000011000002 /* Mayday */,
|
AA000011000002 /* Mayday */,
|
||||||
@@ -123,15 +125,6 @@
|
|||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
AA000011000099 /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
AA000009000001 /* Mayday.app */,
|
|
||||||
AA000008000001 /* MaydayLiveActivity.appex */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
AA000011000002 /* Mayday */ = {
|
AA000011000002 /* Mayday */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -168,6 +161,7 @@
|
|||||||
AA000002000011 /* AuthService.swift */,
|
AA000002000011 /* AuthService.swift */,
|
||||||
AA000002000012 /* NotificationsAPIService.swift */,
|
AA000002000012 /* NotificationsAPIService.swift */,
|
||||||
AA000002000013 /* PushNotificationService.swift */,
|
AA000002000013 /* PushNotificationService.swift */,
|
||||||
|
AA000002000026A /* PreviewData.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -226,11 +220,20 @@
|
|||||||
children = (
|
children = (
|
||||||
AA000002000030 /* MaydayLiveActivityBundle.swift */,
|
AA000002000030 /* MaydayLiveActivityBundle.swift */,
|
||||||
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */,
|
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */,
|
||||||
AA000002000033 /* Info.plist (Extension) */,
|
AA000002000033 /* Info.plist */,
|
||||||
);
|
);
|
||||||
path = MaydayLiveActivity;
|
path = MaydayLiveActivity;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AA000011000099 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AA000009000001 /* Mayday.app */,
|
||||||
|
AA000008000001 /* MaydayLiveActivity.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -297,7 +300,7 @@
|
|||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = AA000011000001 /* Root */;
|
mainGroup = AA000011000001;
|
||||||
productRefGroup = AA000011000099 /* Products */;
|
productRefGroup = AA000011000099 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -309,7 +312,7 @@
|
|||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
AA000014000001 /* Resources (App) */ = {
|
AA000014000001 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -317,7 +320,7 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
AA000014000002 /* Resources (Extension) */ = {
|
AA000014000002 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -327,7 +330,7 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
AA000013000001 /* Sources (App) */ = {
|
AA000013000001 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -355,16 +358,17 @@
|
|||||||
AA000001000022 /* SettingsView.swift in Sources */,
|
AA000001000022 /* SettingsView.swift in Sources */,
|
||||||
AA000001000023 /* ChangePasswordView.swift in Sources */,
|
AA000001000023 /* ChangePasswordView.swift in Sources */,
|
||||||
AA000001000024 /* SessionsView.swift in Sources */,
|
AA000001000024 /* SessionsView.swift in Sources */,
|
||||||
|
AA000001000026 /* PreviewData.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
AA000013000002 /* Sources (Extension) */ = {
|
AA000013000002 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */,
|
AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */,
|
||||||
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */,
|
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */,
|
||||||
AA000001000032 /* AlertAttributes.swift in Sources (Extension) */,
|
AA000001000032 /* AlertAttributes.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -379,23 +383,24 @@
|
|||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
AA000016000001 /* Debug (App) */ = {
|
AA000016000001 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
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;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Mayday/Info.plist;
|
INFOPLIST_FILE = Mayday/Info.plist;
|
||||||
|
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 = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday";
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 6.0;
|
SWIFT_VERSION = 6.0;
|
||||||
@@ -403,23 +408,24 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
AA000016000002 /* Release (App) */ = {
|
AA000016000002 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
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;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Mayday/Info.plist;
|
INFOPLIST_FILE = Mayday/Info.plist;
|
||||||
|
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 = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday";
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 6.0;
|
SWIFT_VERSION = 6.0;
|
||||||
@@ -427,7 +433,7 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
AA000016000003 /* Debug (Extension) */ = {
|
AA000016000003 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -442,7 +448,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday.liveactivity";
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday.liveactivity;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -451,7 +457,7 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
AA000016000004 /* Release (Extension) */ = {
|
AA000016000004 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -466,7 +472,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday.liveactivity";
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday.liveactivity;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -475,7 +481,7 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
AA000016000005 /* Debug (Project) */ = {
|
AA000016000005 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
@@ -537,7 +543,7 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
AA000016000006 /* Release (Project) */ = {
|
AA000016000006 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
@@ -623,7 +629,6 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
};
|
};
|
||||||
rootObject = AA000004000001 /* Project object */;
|
rootObject = AA000004000001 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AppNotification: Codable, Identifiable {
|
struct AppNotification: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let topic: String
|
let topic: String
|
||||||
let subject: String
|
let subject: String
|
||||||
@@ -22,19 +22,19 @@ struct AppNotification: Codable, Identifiable {
|
|||||||
var isRead: Bool { readAt != nil }
|
var isRead: Bool { readAt != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationStatus: String, Codable {
|
enum NotificationStatus: String, Codable, Sendable {
|
||||||
case sent
|
case sent
|
||||||
case delivered
|
case delivered
|
||||||
case read
|
case read
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationChannel: String, Codable {
|
enum NotificationChannel: String, Codable, Sendable {
|
||||||
case inApp = "in_app"
|
case inApp = "in_app"
|
||||||
case push
|
case push
|
||||||
case email
|
case email
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NotificationsPage: Codable {
|
struct NotificationsPage: Codable, Sendable {
|
||||||
let items: [AppNotification]
|
let items: [AppNotification]
|
||||||
let total: Int
|
let total: Int
|
||||||
let page: Int
|
let page: Int
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct SessionResponse: Codable, Identifiable {
|
struct SessionResponse: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let userAgent: String
|
let userAgent: String
|
||||||
let ipAddress: String
|
let ipAddress: String
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct TokenPair: Codable {
|
struct TokenPair: Codable, Sendable {
|
||||||
let accessToken: String
|
let accessToken: String
|
||||||
let refreshToken: String
|
let refreshToken: String
|
||||||
let expiresAt: Date
|
let expiresAt: Date
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct UserResponse: Codable, Identifiable {
|
struct UserResponse: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let email: String
|
let email: String
|
||||||
let status: UserStatus
|
let status: UserStatus
|
||||||
@@ -18,7 +18,7 @@ struct UserResponse: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserStatus: String, Codable {
|
enum UserStatus: String, Codable, Sendable {
|
||||||
case pending
|
case pending
|
||||||
case active
|
case active
|
||||||
case suspended
|
case suspended
|
||||||
@@ -26,7 +26,7 @@ enum UserStatus: String, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper for Any JSON values
|
// Helper for Any JSON values
|
||||||
struct AnyCodable: Codable {
|
struct AnyCodable: Codable, @unchecked Sendable {
|
||||||
let value: Any
|
let value: Any
|
||||||
|
|
||||||
init(_ value: Any) {
|
init(_ value: Any) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct LoginResponse: Decodable {
|
struct LoginResponse: Decodable, Sendable {
|
||||||
let user: UserResponse
|
let user: UserResponse
|
||||||
let tokens: TokenPair
|
let tokens: TokenPair
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RegisterResponse: Decodable {
|
struct RegisterResponse: Decodable, Sendable {
|
||||||
let user: UserResponse
|
let user: UserResponse
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@@ -20,7 +20,7 @@ struct RegisterResponse: Decodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VerifyEmailResponse: Decodable {
|
struct VerifyEmailResponse: Decodable, Sendable {
|
||||||
let user: UserResponse
|
let user: UserResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +64,8 @@ actor AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ResendCodeResponse: Decodable {
|
struct ResendCodeResponse: Decodable, Sendable {
|
||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmptyResponse: Decodable {}
|
struct EmptyResponse: Decodable, Sendable {}
|
||||||
|
|||||||
@@ -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
|
// Update badge
|
||||||
if let badge = aps["badge"] as? Int {
|
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 {
|
if currentActivities.count >= 3 {
|
||||||
// End the oldest
|
// End the oldest
|
||||||
if let oldest = currentActivities.min(by: {
|
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
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
func userNotificationCenter(
|
nonisolated func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
@@ -125,7 +127,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
|||||||
completionHandler([.banner, .badge, .sound])
|
completionHandler([.banner, .badge, .sound])
|
||||||
}
|
}
|
||||||
|
|
||||||
func userNotificationCenter(
|
nonisolated func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ class AuthViewModel: ObservableObject {
|
|||||||
private let keychain = KeychainService.shared
|
private let keychain = KeychainService.shared
|
||||||
|
|
||||||
func checkAuthStatus() async {
|
func checkAuthStatus() async {
|
||||||
|
#if DEBUG
|
||||||
|
if PreviewData.isPreviewMode {
|
||||||
|
currentUser = PreviewData.mockUser
|
||||||
|
isAuthenticated = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
guard keychain.loadAccessToken() != nil else {
|
guard keychain.loadAccessToken() != nil else {
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
return
|
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 {
|
func login(email: String, password: String) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
@@ -68,6 +84,15 @@ class AuthViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logout() async {
|
func logout() async {
|
||||||
|
#if DEBUG
|
||||||
|
if PreviewData.isPreviewMode {
|
||||||
|
await PreviewData.stopMockLiveActivity()
|
||||||
|
PreviewData.isPreviewMode = false
|
||||||
|
isAuthenticated = false
|
||||||
|
currentUser = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
private var pollingTask: Task<Void, Never>?
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
|
#if DEBUG
|
||||||
|
if PreviewData.isPreviewMode {
|
||||||
|
notifications = PreviewData.mockNotifications
|
||||||
|
hasMore = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
@@ -31,6 +38,9 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadMore() async {
|
func loadMore() async {
|
||||||
|
#if DEBUG
|
||||||
|
if PreviewData.isPreviewMode { return }
|
||||||
|
#endif
|
||||||
guard !isLoadingMore && hasMore else { return }
|
guard !isLoadingMore && hasMore else { return }
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
defer { isLoadingMore = false }
|
defer { isLoadingMore = false }
|
||||||
@@ -47,6 +57,26 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
|
|
||||||
func markAsRead(_ notification: AppNotification) async {
|
func markAsRead(_ notification: AppNotification) async {
|
||||||
guard !notification.isRead else { return }
|
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 {
|
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 }) {
|
||||||
@@ -71,6 +101,9 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startPolling() {
|
func startPolling() {
|
||||||
|
#if DEBUG
|
||||||
|
if PreviewData.isPreviewMode { return }
|
||||||
|
#endif
|
||||||
// Guard against starting a second polling loop if already running.
|
// Guard against starting a second polling loop if already running.
|
||||||
guard pollingTask == nil else { return }
|
guard pollingTask == nil else { return }
|
||||||
pollingTask = Task {
|
pollingTask = Task {
|
||||||
@@ -89,6 +122,6 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func updateBadge() {
|
private func updateBadge() {
|
||||||
let unreadCount = notifications.filter { !$0.isRead }.count
|
let unreadCount = notifications.filter { !$0.isRead }.count
|
||||||
UIApplication.shared.applicationIconBadgeNumber = unreadCount
|
UNUserNotificationCenter.current().setBadgeCount(unreadCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ class SettingsViewModel: ObservableObject {
|
|||||||
private let service = NotificationsAPIService.shared
|
private let service = NotificationsAPIService.shared
|
||||||
|
|
||||||
func loadSessions() async {
|
func loadSessions() async {
|
||||||
|
#if DEBUG
|
||||||
|
if PreviewData.isPreviewMode {
|
||||||
|
sessions = PreviewData.mockSessions
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
@@ -21,6 +27,12 @@ class SettingsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteSession(_ session: SessionResponse) async {
|
func deleteSession(_ session: SessionResponse) async {
|
||||||
|
#if DEBUG
|
||||||
|
if PreviewData.isPreviewMode {
|
||||||
|
sessions.removeAll { $0.id == session.id }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#endif
|
||||||
do {
|
do {
|
||||||
try await service.deleteSession(id: session.id)
|
try await service.deleteSession(id: session.id)
|
||||||
sessions.removeAll { $0.id == session.id }
|
sessions.removeAll { $0.id == session.id }
|
||||||
@@ -30,6 +42,12 @@ class SettingsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func changePassword(current: String, new: String) async -> Bool {
|
func changePassword(current: String, new: String) async -> Bool {
|
||||||
|
#if DEBUG
|
||||||
|
if PreviewData.isPreviewMode {
|
||||||
|
successMessage = "Пароль успешно изменён"
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ struct LoginView: View {
|
|||||||
}
|
}
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
Button {
|
||||||
|
Task { await authViewModel.enterPreviewMode() }
|
||||||
|
} label: {
|
||||||
|
Label("Демо-режим", systemImage: "play.circle.fill")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
#endif
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@@ -6,63 +6,244 @@ struct NotificationDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
// Hero header
|
||||||
Text(notification.topic)
|
headerSection
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(notification.subject)
|
|
||||||
.font(.title2.bold())
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
// Info cards
|
||||||
|
VStack(spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
detailsCard
|
||||||
Text("Подробности:")
|
|
||||||
.font(.headline)
|
|
||||||
Text(notification.body)
|
|
||||||
.font(.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let metadata = notification.metadata, !metadata.isEmpty {
|
if let metadata = notification.metadata, !metadata.isEmpty {
|
||||||
Divider()
|
metadataCard(metadata)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
}
|
||||||
Text("Метаданные:")
|
|
||||||
|
statusCard
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 24)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
|
||||||
|
// Mark as read button for unread notifications
|
||||||
|
if !notification.isRead {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.markAsRead(notification) }
|
||||||
|
} label: {
|
||||||
|
Text("Отметить прочитанным")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
ForEach(metadata.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
.foregroundStyle(.red)
|
||||||
HStack {
|
.frame(maxWidth: .infinity)
|
||||||
Text(key).foregroundStyle(.secondary)
|
.padding(.vertical, 14)
|
||||||
Spacer()
|
.background(
|
||||||
Text(value)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(Color.red.opacity(0.1))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.font(.footnote)
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
Divider()
|
.navigationTitle("Подробности")
|
||||||
|
|
||||||
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("Уведомление")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.task {
|
||||||
await viewModel.markAsRead(notification)
|
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()
|
@StateObject private var viewModel = NotificationsViewModel()
|
||||||
@State private var showSettings = false
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
@@ -26,13 +34,28 @@ struct NotificationsView: View {
|
|||||||
notificationsList
|
notificationsList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
.navigationTitle("Уведомления")
|
.navigationTitle("Уведомления")
|
||||||
.toolbar {
|
.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) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showSettings = true
|
showSettings = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gear")
|
Image(systemName: "gearshape.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,63 +77,224 @@ struct NotificationsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var notificationsList: some View {
|
var notificationsList: some View {
|
||||||
List {
|
ScrollView {
|
||||||
ForEach(viewModel.notifications) { notification in
|
LazyVStack(spacing: 0) {
|
||||||
|
if !unreadNotifications.isEmpty {
|
||||||
|
sectionHeader("Активные")
|
||||||
|
ForEach(unreadNotifications) { notification in
|
||||||
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
|
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
|
||||||
NotificationRowView(notification: notification)
|
ActiveNotificationCard(notification: notification)
|
||||||
}
|
|
||||||
.swipeActions(edge: .leading) {
|
|
||||||
if !notification.isRead {
|
|
||||||
Button {
|
|
||||||
Task { await viewModel.markAsRead(notification) }
|
|
||||||
} label: {
|
|
||||||
Label("Прочитано", systemImage: "checkmark")
|
|
||||||
}
|
|
||||||
.tint(.blue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if notification.id == viewModel.notifications.last?.id {
|
if notification.id == viewModel.notifications.last?.id {
|
||||||
Task { await viewModel.loadMore() }
|
Task { await viewModel.loadMore() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if viewModel.isLoadingMore {
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Spacer()
|
.padding(.vertical, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding(.top, 4)
|
||||||
.listStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NotificationRowView: View {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Active (Unread) Card
|
||||||
|
|
||||||
|
struct ActiveNotificationCard: View {
|
||||||
let notification: AppNotification
|
let notification: AppNotification
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Circle()
|
HStack(alignment: .top) {
|
||||||
.fill(notification.isRead ? Color.clear : Color.blue)
|
NotificationIconView(topic: notification.topic, isActive: true)
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(notification.topic)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(notification.subject)
|
Text(notification.subject)
|
||||||
.font(.body)
|
.font(.headline)
|
||||||
.fontWeight(notification.isRead ? .regular : .semibold)
|
.foregroundStyle(.white)
|
||||||
|
Text(notification.topic)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
Text(notification.createdAt, style: .relative)
|
Text(notification.createdAt, style: .relative)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,6 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
<string>com.apple.widgetkit-extension</string>
|
<string>com.apple.widgetkit-extension</string>
|
||||||
<key>NSExtensionPrincipalClass</key>
|
|
||||||
<string>$(PRODUCT_MODULE_NAME).MaydayLiveActivityBundle</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -5,49 +5,54 @@ import SwiftUI
|
|||||||
struct MaydayLiveActivityLiveActivity: Widget {
|
struct MaydayLiveActivityLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: AlertAttributes.self) { context in
|
ActivityConfiguration(for: AlertAttributes.self) { context in
|
||||||
// Lock Screen / Notification Center
|
|
||||||
lockScreenView(context: context)
|
lockScreenView(context: context)
|
||||||
.activityBackgroundTint(severityColor(context.attributes.severity).opacity(0.15))
|
.activityBackgroundTint(severityColor(context.attributes.severity).opacity(0.12))
|
||||||
.activitySystemActionForegroundColor(.primary)
|
.activitySystemActionForegroundColor(.primary)
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
Label(context.attributes.topic, systemImage: "exclamationmark.triangle.fill")
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: severityIcon(context.attributes.severity))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
Text(context.attributes.topic)
|
||||||
|
.font(.caption.bold())
|
||||||
|
}
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.foregroundStyle(severityColor(context.attributes.severity))
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
statusBadge(context.state.status)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(context.state.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
if let value = context.state.value {
|
if let value = context.state.value {
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.caption.bold())
|
.font(.caption)
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.foregroundStyle(severityColor(context.attributes.severity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
Spacer()
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
VStack(alignment: .trailing, spacing: 3) {
|
||||||
statusBadge(context.state.status)
|
HStack(spacing: 3) {
|
||||||
// Text(date, style: .timer) updates automatically without re-render.
|
Image(systemName: "clock")
|
||||||
HStack(spacing: 2) {
|
|
||||||
Text("Длит.:")
|
|
||||||
Text(context.state.startedAt, style: .timer)
|
|
||||||
}
|
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
|
Text(context.state.startedAt, style: .timer)
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
}
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
Text(context.state.startedAt.formatted(date: .omitted, time: .shortened))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: severityIcon(context.attributes.severity))
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.foregroundStyle(severityColor(context.attributes.severity))
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
let shortTopic = context.attributes.topic.components(separatedBy: "/").last ?? context.attributes.topic
|
let shortTopic = context.attributes.topic.components(separatedBy: "/").last ?? context.attributes.topic
|
||||||
@@ -56,7 +61,7 @@ struct MaydayLiveActivityLiveActivity: Widget {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: severityIcon(context.attributes.severity))
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.foregroundStyle(severityColor(context.attributes.severity))
|
||||||
}
|
}
|
||||||
.keylineTint(severityColor(context.attributes.severity))
|
.keylineTint(severityColor(context.attributes.severity))
|
||||||
@@ -65,25 +70,22 @@ struct MaydayLiveActivityLiveActivity: Widget {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func lockScreenView(context: ActivityViewContext<AlertAttributes>) -> some View {
|
func lockScreenView(context: ActivityViewContext<AlertAttributes>) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
ZStack {
|
||||||
.font(.title2)
|
Circle()
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.fill(severityColor(context.attributes.severity).opacity(0.15))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Image(systemName: severityIcon(context.attributes.severity))
|
||||||
Text(context.attributes.topic)
|
.font(.title3)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(context.state.title)
|
|
||||||
.font(.subheadline.bold())
|
|
||||||
if let value = context.state.value {
|
|
||||||
Text(value)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.foregroundStyle(severityColor(context.attributes.severity))
|
||||||
}
|
}
|
||||||
// Text(date, style: .relative) updates automatically without re-render.
|
|
||||||
Text(context.state.startedAt, style: .relative)
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
.font(.caption2)
|
Text(context.state.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Text(context.attributes.topic)
|
||||||
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,31 @@ struct MaydayLiveActivityLiveActivity: Widget {
|
|||||||
|
|
||||||
statusBadge(context.state.status)
|
statusBadge(context.state.status)
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(context.state.startedAt, style: .relative)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -101,18 +127,27 @@ struct MaydayLiveActivityLiveActivity: Widget {
|
|||||||
: ("завершён", .green)
|
: ("завершён", .green)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.caption2.bold())
|
.font(.caption2.bold())
|
||||||
.padding(.horizontal, 6)
|
.textCase(.uppercase)
|
||||||
.padding(.vertical, 2)
|
.padding(.horizontal, 8)
|
||||||
.background(color.opacity(0.2))
|
.padding(.vertical, 3)
|
||||||
|
.background(color.opacity(0.15))
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
.cornerRadius(4)
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
func severityColor(_ severity: Severity) -> Color {
|
func severityColor(_ severity: Severity) -> Color {
|
||||||
switch severity {
|
switch severity {
|
||||||
case .critical: return .red
|
case .critical: return .red
|
||||||
case .warning: return .yellow
|
case .warning: return .orange
|
||||||
case .info: return .blue
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user