Compare commits
10 Commits
d642bffcaa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 802d32e9a0 | |||
| d991d06f17 | |||
| 37b87ececd | |||
| 0947c048c1 | |||
| 7675f66488 | |||
| 8a15572fb9 | |||
| 758f5ec05f | |||
| a4b475b13f | |||
| 597787a6c9 | |||
| 9259a3693a |
@@ -8,5 +8,9 @@ DerivedData/
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
.swiftpm/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
AA000001000011 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000011 /* AuthService.swift */; };
|
||||
AA000001000012 /* NotificationsAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000012 /* NotificationsAPIService.swift */; };
|
||||
AA000001000013 /* PushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000013 /* PushNotificationService.swift */; };
|
||||
AA000001000099 /* LiveActivityRegistrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000099 /* LiveActivityRegistrationService.swift */; };
|
||||
AA000001000100 /* DevicePlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000100 /* DevicePlatform.swift */; };
|
||||
AA000001000014 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000014 /* AuthViewModel.swift */; };
|
||||
AA000001000015 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000015 /* NotificationsViewModel.swift */; };
|
||||
AA000001000016 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000016 /* SettingsViewModel.swift */; };
|
||||
@@ -32,9 +34,19 @@
|
||||
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 */; };
|
||||
AA000001000027 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000027 /* Localizable.xcstrings */; };
|
||||
AA000001000028 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000028 /* InfoPlist.xcstrings */; };
|
||||
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 */; };
|
||||
AA000001000033 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000027 /* Localizable.xcstrings */; };
|
||||
AA000001000034 /* AppTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000034 /* AppTextField.swift */; };
|
||||
AA000001000035 /* AppSecureField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000035 /* AppSecureField.swift */; };
|
||||
AA000001000036 /* AppBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000036 /* AppBackground.swift */; };
|
||||
AA000001000037 /* CardContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000037 /* CardContainer.swift */; };
|
||||
AA000001000038 /* NotificationIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000038 /* NotificationIconView.swift */; };
|
||||
AA000001000039 /* OTPDigitField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000039 /* OTPDigitField.swift */; };
|
||||
AA000001000040 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000040 /* LaunchScreen.storyboard */; };
|
||||
AA000007000001 /* MaydayLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA000008000001 /* MaydayLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -76,6 +88,8 @@
|
||||
AA000002000011 /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
|
||||
AA000002000012 /* NotificationsAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsAPIService.swift; sourceTree = "<group>"; };
|
||||
AA000002000013 /* PushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationService.swift; sourceTree = "<group>"; };
|
||||
AA000002000099 /* LiveActivityRegistrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistrationService.swift; sourceTree = "<group>"; };
|
||||
AA000002000100 /* DevicePlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePlatform.swift; sourceTree = "<group>"; };
|
||||
AA000002000014 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = "<group>"; };
|
||||
AA000002000015 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = "<group>"; };
|
||||
AA000002000016 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -89,22 +103,32 @@
|
||||
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>"; };
|
||||
AA000002000026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
AA000002000027 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
AA000002000028 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||
AA000002000029 /* Mayday.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mayday.entitlements; 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>"; };
|
||||
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>"; };
|
||||
AA000002000034 /* AppTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTextField.swift; sourceTree = "<group>"; };
|
||||
AA000002000035 /* AppSecureField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecureField.swift; sourceTree = "<group>"; };
|
||||
AA000002000036 /* AppBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBackground.swift; sourceTree = "<group>"; };
|
||||
AA000002000037 /* CardContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardContainer.swift; sourceTree = "<group>"; };
|
||||
AA000002000038 /* NotificationIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationIconView.swift; sourceTree = "<group>"; };
|
||||
AA000002000039 /* OTPDigitField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPDigitField.swift; sourceTree = "<group>"; };
|
||||
AA000002000040 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
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 +138,7 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AA000011000001 /* Root */ = {
|
||||
AA000011000001 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA000011000002 /* Mayday */,
|
||||
@@ -123,15 +147,6 @@
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA000011000099 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA000009000001 /* Mayday.app */,
|
||||
AA000008000001 /* MaydayLiveActivity.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA000011000002 /* Mayday */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -140,6 +155,10 @@
|
||||
AA000002000003 /* AppDelegate.swift */,
|
||||
AA000002000025 /* Assets.xcassets */,
|
||||
AA000002000026 /* Info.plist */,
|
||||
AA000002000027 /* Localizable.xcstrings */,
|
||||
AA000002000028 /* InfoPlist.xcstrings */,
|
||||
AA000002000029 /* Mayday.entitlements */,
|
||||
AA000002000040 /* LaunchScreen.storyboard */,
|
||||
AA000011000003 /* Models */,
|
||||
AA000011000004 /* Services */,
|
||||
AA000011000005 /* ViewModels */,
|
||||
@@ -168,6 +187,8 @@
|
||||
AA000002000011 /* AuthService.swift */,
|
||||
AA000002000012 /* NotificationsAPIService.swift */,
|
||||
AA000002000013 /* PushNotificationService.swift */,
|
||||
AA000002000099 /* LiveActivityRegistrationService.swift */,
|
||||
AA000002000100 /* DevicePlatform.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -188,6 +209,7 @@
|
||||
AA000011000007 /* Auth */,
|
||||
AA000011000008 /* Notifications */,
|
||||
AA000011000009 /* Settings */,
|
||||
AA000011000011 /* UIKit */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -226,11 +248,33 @@
|
||||
children = (
|
||||
AA000002000030 /* MaydayLiveActivityBundle.swift */,
|
||||
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */,
|
||||
AA000002000033 /* Info.plist (Extension) */,
|
||||
AA000002000033 /* Info.plist */,
|
||||
);
|
||||
path = MaydayLiveActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA000011000011 /* UIKit */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA000002000034 /* AppTextField.swift */,
|
||||
AA000002000035 /* AppSecureField.swift */,
|
||||
AA000002000036 /* AppBackground.swift */,
|
||||
AA000002000037 /* CardContainer.swift */,
|
||||
AA000002000038 /* NotificationIconView.swift */,
|
||||
AA000002000039 /* OTPDigitField.swift */,
|
||||
);
|
||||
path = UIKit;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA000011000099 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA000009000001 /* Mayday.app */,
|
||||
AA000008000001 /* MaydayLiveActivity.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -297,7 +341,7 @@
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = AA000011000001 /* Root */;
|
||||
mainGroup = AA000011000001;
|
||||
productRefGroup = AA000011000099 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -309,25 +353,29 @@
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
AA000014000001 /* Resources (App) */ = {
|
||||
AA000014000001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA000001000025 /* Assets.xcassets in Resources */,
|
||||
AA000001000027 /* Localizable.xcstrings in Resources */,
|
||||
AA000001000028 /* InfoPlist.xcstrings in Resources */,
|
||||
AA000001000040 /* LaunchScreen.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA000014000002 /* Resources (Extension) */ = {
|
||||
AA000014000002 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA000001000033 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
AA000013000001 /* Sources (App) */ = {
|
||||
AA000013000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -344,6 +392,8 @@
|
||||
AA000001000011 /* AuthService.swift in Sources */,
|
||||
AA000001000012 /* NotificationsAPIService.swift in Sources */,
|
||||
AA000001000013 /* PushNotificationService.swift in Sources */,
|
||||
AA000001000099 /* LiveActivityRegistrationService.swift in Sources */,
|
||||
AA000001000100 /* DevicePlatform.swift in Sources */,
|
||||
AA000001000014 /* AuthViewModel.swift in Sources */,
|
||||
AA000001000015 /* NotificationsViewModel.swift in Sources */,
|
||||
AA000001000016 /* SettingsViewModel.swift in Sources */,
|
||||
@@ -355,16 +405,22 @@
|
||||
AA000001000022 /* SettingsView.swift in Sources */,
|
||||
AA000001000023 /* ChangePasswordView.swift in Sources */,
|
||||
AA000001000024 /* SessionsView.swift in Sources */,
|
||||
AA000001000034 /* AppTextField.swift in Sources */,
|
||||
AA000001000035 /* AppSecureField.swift in Sources */,
|
||||
AA000001000036 /* AppBackground.swift in Sources */,
|
||||
AA000001000037 /* CardContainer.swift in Sources */,
|
||||
AA000001000038 /* NotificationIconView.swift in Sources */,
|
||||
AA000001000039 /* OTPDigitField.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,60 +435,78 @@
|
||||
/* 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;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Mayday/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Mayday;
|
||||
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)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA000016000002 /* Release (App) */ = {
|
||||
AA000016000002 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Mayday/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Mayday;
|
||||
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)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AA000016000003 /* Debug (Extension) */ = {
|
||||
AA000016000003 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MaydayLiveActivity/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -442,7 +516,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,12 +525,12 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA000016000004 /* Release (Extension) */ = {
|
||||
AA000016000004 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MaydayLiveActivity/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
@@ -466,7 +540,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 +549,7 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AA000016000005 /* Debug (Project) */ = {
|
||||
AA000016000005 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
@@ -537,7 +611,7 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA000016000006 /* Release (Project) */ = {
|
||||
AA000016000006 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
@@ -623,7 +697,6 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
};
|
||||
rootObject = AA000004000001 /* Project object */;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1540"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA000005000001"
|
||||
BuildableName = "Mayday.app"
|
||||
BlueprintName = "Mayday"
|
||||
ReferencedContainer = "container:Mayday.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA000005000001"
|
||||
BuildableName = "Mayday.app"
|
||||
BlueprintName = "Mayday"
|
||||
ReferencedContainer = "container:Mayday.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA000005000001"
|
||||
BuildableName = "Mayday.app"
|
||||
BlueprintName = "Mayday"
|
||||
ReferencedContainer = "container:Mayday.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA000005000002"
|
||||
BuildableName = "MaydayLiveActivity.appex"
|
||||
BlueprintName = "MaydayLiveActivity"
|
||||
ReferencedContainer = "container:Mayday.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA000005000001"
|
||||
BuildableName = "Mayday.app"
|
||||
BlueprintName = "Mayday"
|
||||
ReferencedContainer = "container:Mayday.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<RemoteRunnable
|
||||
runnableDebuggingMode = "2"
|
||||
BundleIdentifier = "com.apple.springboard">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA000005000002"
|
||||
BuildableName = "MaydayLiveActivity.appex"
|
||||
BlueprintName = "MaydayLiveActivity"
|
||||
ReferencedContainer = "container:Mayday.xcodeproj">
|
||||
</BuildableReference>
|
||||
</RemoteRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA000005000001"
|
||||
BuildableName = "Mayday.app"
|
||||
BlueprintName = "Mayday"
|
||||
ReferencedContainer = "container:Mayday.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetKind"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetDefaultView"
|
||||
value = "timeline"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetFamily"
|
||||
value = "systemMedium"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA000005000001"
|
||||
BuildableName = "Mayday.app"
|
||||
BlueprintName = "Mayday"
|
||||
ReferencedContainer = "container:Mayday.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.188",
|
||||
"green" : "0.231",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.227",
|
||||
"green" : "0.271",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 467 B |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 660 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1,8 +1,111 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"filename" : "AppIcon-20@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-20@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-20@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-76@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-76@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-83.5@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-1024@1x.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.188",
|
||||
"green" : "0.231",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.227",
|
||||
"green" : "0.271",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "0.478",
|
||||
"red" : "0.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "0.518",
|
||||
"red" : "0.039"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.349",
|
||||
"green" : "0.780",
|
||||
"red" : "0.204"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.345",
|
||||
"green" : "0.820",
|
||||
"red" : "0.188"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.000",
|
||||
"green" : "0.584",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.039",
|
||||
"green" : "0.624",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var authViewModel: AuthViewModel
|
||||
@Environment(AuthViewModel.self) private var authViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if authViewModel.isAuthenticated {
|
||||
if authViewModel.isCheckingAuth {
|
||||
Color(.systemBackground)
|
||||
.ignoresSafeArea()
|
||||
} else if authViewModel.isAuthenticated {
|
||||
NotificationsView()
|
||||
} else {
|
||||
LoginView()
|
||||
@@ -16,3 +19,8 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environment(AuthViewModel())
|
||||
}
|
||||
|
||||
@@ -2,53 +2,46 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSUserNotificationsUsageDescription</key>
|
||||
<string>Mayday использует уведомления для оповещения о критических событиях.</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"sourceLanguage" : "ru",
|
||||
"strings" : {
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Mayday"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSUserNotificationsUsageDescription" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mayday uses notifications to alert you about critical events."
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mayday использует уведомления для оповещения о критических событиях."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23094" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23084"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="Logo" translatesAutoresizingMaskIntoConstraints="NO" id="Lgo-Im-001">
|
||||
<rect key="frame" x="156.5" y="386" width="80" height="80"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="80" id="Lgw-001"/>
|
||||
<constraint firstAttribute="height" constant="80" id="Lgh-001"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Lgo-Im-001" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="Lgx-001"/>
|
||||
<constraint firstItem="Lgo-Im-001" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="Lgy-001"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Logo" width="160" height="160"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -2,13 +2,13 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct MaydayApp: App {
|
||||
@StateObject private var authViewModel = AuthViewModel()
|
||||
@State private var authViewModel = AuthViewModel()
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(authViewModel)
|
||||
.environment(authViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,61 @@ struct AlertAttributes: ActivityAttributes {
|
||||
let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, value, status
|
||||
case startedAt = "startedAt"
|
||||
case updatedAt = "updatedAt"
|
||||
case title, value, status, startedAt, updatedAt
|
||||
}
|
||||
|
||||
init(title: String, value: String?, status: AlertStatus, startedAt: Date, updatedAt: Date) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.status = status
|
||||
self.startedAt = startedAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = try c.decode(String.self, forKey: .title)
|
||||
value = try c.decodeIfPresent(String.self, forKey: .value)
|
||||
status = try c.decode(AlertStatus.self, forKey: .status)
|
||||
startedAt = try Self.decodeDate(from: c, forKey: .startedAt)
|
||||
updatedAt = try Self.decodeDate(from: c, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(title, forKey: .title)
|
||||
try c.encodeIfPresent(value, forKey: .value)
|
||||
try c.encode(status, forKey: .status)
|
||||
try c.encode(Self.isoFormatter.string(from: startedAt), forKey: .startedAt)
|
||||
try c.encode(Self.isoFormatter.string(from: updatedAt), forKey: .updatedAt)
|
||||
}
|
||||
|
||||
// ActivityKit uses the default JSONDecoder when materialising ContentState
|
||||
// from a remote push payload, so dates round-trip as ISO8601 strings
|
||||
// matching the backend's time.RFC3339[Nano] output. Both formatters are
|
||||
// pre-built and treated as immutable; ISO8601DateFormatter is documented
|
||||
// thread-safe for read use.
|
||||
nonisolated(unsafe) private static let isoWithFractional: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
nonisolated(unsafe) private static let isoWithoutFractional: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
// Single shared encoder format: ISO8601 without fractional seconds,
|
||||
// since iOS reconstructs Date from string and sub-second precision is
|
||||
// irrelevant for the alert timestamps we surface.
|
||||
private static var isoFormatter: ISO8601DateFormatter { isoWithoutFractional }
|
||||
|
||||
private static func decodeDate(from c: KeyedDecodingContainer<CodingKeys>, forKey key: CodingKeys) throws -> Date {
|
||||
let s = try c.decode(String.self, forKey: key)
|
||||
if let d = isoWithFractional.date(from: s) ?? isoWithoutFractional.date(from: s) {
|
||||
return d
|
||||
}
|
||||
throw DecodingError.dataCorruptedError(forKey: key, in: c, debugDescription: "Invalid ISO8601 date: \(s)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,114 @@
|
||||
import Foundation
|
||||
|
||||
struct AppNotification: Codable, Identifiable {
|
||||
struct AppNotification: Codable, Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let topic: String
|
||||
let subject: String
|
||||
let userId: UUID
|
||||
let scopeId: UUID?
|
||||
let channel: NotificationChannel
|
||||
let contentType: ContentType
|
||||
let templateId: UUID?
|
||||
let subject: String?
|
||||
let body: String
|
||||
let source: String?
|
||||
let metadata: [String: String]?
|
||||
let status: NotificationStatus
|
||||
let channel: NotificationChannel
|
||||
let error: String?
|
||||
let attempts: Int
|
||||
let maxAttempts: Int
|
||||
let nextRetryAt: Date?
|
||||
let sentAt: Date?
|
||||
let readAt: Date?
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, topic, subject, body, metadata, status, channel
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case scopeId = "scope_id"
|
||||
case channel
|
||||
case contentType = "content_type"
|
||||
case templateId = "template_id"
|
||||
case subject, body, source, metadata, status, error, attempts
|
||||
case maxAttempts = "max_attempts"
|
||||
case nextRetryAt = "next_retry_at"
|
||||
case sentAt = "sent_at"
|
||||
case readAt = "read_at"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
var isRead: Bool { readAt != nil }
|
||||
|
||||
func withReadAt(_ date: Date) -> AppNotification {
|
||||
AppNotification(
|
||||
id: id, userId: userId, scopeId: scopeId, channel: channel,
|
||||
contentType: contentType, templateId: templateId, subject: subject,
|
||||
body: body, source: source, metadata: metadata, status: .read,
|
||||
error: error, attempts: attempts, maxAttempts: maxAttempts,
|
||||
nextRetryAt: nextRetryAt, sentAt: sentAt, readAt: date, createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationStatus: String, Codable {
|
||||
enum NotificationStatus: String, Codable, Sendable {
|
||||
case pending
|
||||
case sent
|
||||
case delivered
|
||||
case failed
|
||||
case read
|
||||
}
|
||||
|
||||
enum NotificationChannel: String, Codable {
|
||||
case inApp = "in_app"
|
||||
case push
|
||||
enum NotificationChannel: String, Codable, Sendable {
|
||||
case email
|
||||
case telegram
|
||||
case inApp = "in_app"
|
||||
case webhook
|
||||
case apns
|
||||
}
|
||||
|
||||
struct NotificationsPage: Codable {
|
||||
let items: [AppNotification]
|
||||
let total: Int
|
||||
let page: Int
|
||||
let perPage: Int
|
||||
enum ContentType: String, Codable, Sendable {
|
||||
case plain
|
||||
case html
|
||||
case markdown
|
||||
}
|
||||
|
||||
struct NotificationsList: Decodable, Sendable {
|
||||
let notifications: [AppNotification]
|
||||
let unreadCount: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items, total, page
|
||||
case perPage = "per_page"
|
||||
case notifications
|
||||
case unreadCount = "unread_count"
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsPage: Sendable {
|
||||
let notifications: [AppNotification]
|
||||
let unreadCount: Int
|
||||
let total: Int
|
||||
let hasMore: Bool
|
||||
}
|
||||
|
||||
struct DeviceToken: Codable, Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let userId: UUID
|
||||
let platform: String
|
||||
let token: String
|
||||
let createdAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case platform, token
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationPreference: Codable, Sendable {
|
||||
let userId: UUID
|
||||
let channel: NotificationChannel
|
||||
let enabled: Bool
|
||||
let config: [String: String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case channel, enabled, config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct SessionResponse: Codable, Identifiable {
|
||||
struct SessionResponse: Codable, Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let userAgent: String
|
||||
let ipAddress: String
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct TokenPair: Codable {
|
||||
struct TokenPair: Codable, Sendable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let expiresAt: Date
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct UserResponse: Codable, Identifiable {
|
||||
struct UserResponse: Codable, Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let email: String
|
||||
let status: UserStatus
|
||||
@@ -16,9 +16,32 @@ struct UserResponse: Codable, Identifiable {
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
init(id: UUID, email: String, status: UserStatus, metadata: [String: AnyCodable]? = nil, emailVerifiedAt: Date? = nil, roles: [String] = [], createdAt: Date, updatedAt: Date) {
|
||||
self.id = id
|
||||
self.email = email
|
||||
self.status = status
|
||||
self.metadata = metadata
|
||||
self.emailVerifiedAt = emailVerifiedAt
|
||||
self.roles = roles
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
email = try container.decode(String.self, forKey: .email)
|
||||
status = try container.decode(UserStatus.self, forKey: .status)
|
||||
metadata = try container.decodeIfPresent([String: AnyCodable].self, forKey: .metadata)
|
||||
emailVerifiedAt = try container.decodeIfPresent(Date.self, forKey: .emailVerifiedAt)
|
||||
roles = try container.decodeIfPresent([String].self, forKey: .roles) ?? []
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
enum UserStatus: String, Codable {
|
||||
enum UserStatus: String, Codable, Sendable {
|
||||
case pending
|
||||
case active
|
||||
case suspended
|
||||
@@ -26,7 +49,7 @@ enum UserStatus: String, Codable {
|
||||
}
|
||||
|
||||
// Helper for Any JSON values
|
||||
struct AnyCodable: Codable {
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) {
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let user: UserResponse
|
||||
let tokens: TokenPair
|
||||
enum LoginStatus: String, Decodable, Sendable {
|
||||
case authenticated
|
||||
case mfaRequired = "mfa_required"
|
||||
}
|
||||
|
||||
struct RegisterResponse: Decodable {
|
||||
let user: UserResponse
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, email, status, metadata, roles
|
||||
case emailVerifiedAt = "email_verified_at"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
user = try UserResponse(from: decoder)
|
||||
struct LoginResponse: Decodable, Sendable {
|
||||
let status: LoginStatus
|
||||
let user: UserResponse?
|
||||
let tokens: TokenPair?
|
||||
let mfaToken: String?
|
||||
let methods: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status, user, tokens, methods
|
||||
case mfaToken = "mfa_token"
|
||||
}
|
||||
}
|
||||
|
||||
struct VerifyEmailResponse: Decodable {
|
||||
let user: UserResponse
|
||||
struct MessageResponse: Decodable, Sendable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
actor AuthService {
|
||||
@@ -33,8 +31,16 @@ actor AuthService {
|
||||
|
||||
func login(email: String, password: String) async throws -> UserResponse {
|
||||
let response: LoginResponse = try await client.request(.login(email: email, password: password))
|
||||
try keychain.saveTokens(response.tokens)
|
||||
return response.user
|
||||
switch response.status {
|
||||
case .authenticated:
|
||||
guard let user = response.user, let tokens = response.tokens else {
|
||||
throw APIError.serverError("Malformed login response")
|
||||
}
|
||||
try keychain.saveTokens(tokens)
|
||||
return user
|
||||
case .mfaRequired:
|
||||
throw APIError.mfaRequired
|
||||
}
|
||||
}
|
||||
|
||||
func register(email: String, password: String) async throws -> UserResponse {
|
||||
@@ -42,19 +48,20 @@ actor AuthService {
|
||||
return response
|
||||
}
|
||||
|
||||
func verifyEmail(email: String, code: String) async throws -> UserResponse {
|
||||
let response: VerifyEmailResponse = try await client.request(.verifyEmail(email: email, code: code))
|
||||
return response.user
|
||||
func verifyEmail(email: String, code: String) async throws {
|
||||
let _: MessageResponse = try await client.request(.verifyEmail(email: email, code: code))
|
||||
}
|
||||
|
||||
func resendCode(email: String) async throws {
|
||||
let _: ResendCodeResponse = try await client.request(.resendCode(email: email))
|
||||
let _: MessageResponse = try await client.request(.resendCode(email: email))
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
// Always clear local tokens, regardless of whether the network call succeeds,
|
||||
// to avoid leaving a stale access token in Keychain.
|
||||
defer { keychain.clearTokens() }
|
||||
guard let refreshToken = keychain.loadRefreshToken() else { return }
|
||||
let _: EmptyResponse = try await client.request(.logout(refreshToken: refreshToken))
|
||||
keychain.clearTokens()
|
||||
}
|
||||
|
||||
func getMe() async throws -> UserResponse {
|
||||
@@ -62,8 +69,4 @@ actor AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
struct ResendCodeResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
struct EmptyResponse: Decodable, Sendable {}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
/// Wire-format identifiers for the `platform` field on the notification
|
||||
/// service's `/devices` endpoint. Kept in sync with the backend's
|
||||
/// `model.DevicePlatform` constants.
|
||||
enum DevicePlatform {
|
||||
static let ios = "ios"
|
||||
static let iosLiveActivityStart = "ios-liveactivity"
|
||||
|
||||
/// Builds a per-activity update token platform tag. The suffix is the
|
||||
/// `alertId` the Activity was started for, used by the backend to target
|
||||
/// update/end pushes at a specific running Live Activity.
|
||||
static func iosLiveActivityUpdate(alertId: String) -> String {
|
||||
"ios-liveactivity-\(alertId)"
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceTokenFormatter {
|
||||
/// APNs returns the device token as raw bytes; the HTTP/2 endpoint expects
|
||||
/// a lowercase hex string. Used uniformly for APNs, Push-to-Start and
|
||||
/// per-activity update tokens.
|
||||
static func hex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Foundation
|
||||
enum APIError: Error, LocalizedError {
|
||||
case invalidURL
|
||||
case unauthorized
|
||||
case mfaRequired
|
||||
case validationError([String: [String]])
|
||||
case serverError(String)
|
||||
case networkError(Error)
|
||||
@@ -10,8 +11,9 @@ enum APIError: Error, LocalizedError {
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid URL"
|
||||
case .unauthorized: return "Неверный email или пароль"
|
||||
case .invalidURL: return String(localized: "error_invalid_url")
|
||||
case .unauthorized: return String(localized: "error_invalid_credentials")
|
||||
case .mfaRequired: return String(localized: "error_mfa_required")
|
||||
case .validationError(let errors):
|
||||
return errors.values.flatMap { $0 }.joined(separator: ", ")
|
||||
case .serverError(let message): return message
|
||||
@@ -25,11 +27,33 @@ struct APIResponse<T: Decodable>: Decodable {
|
||||
let data: T
|
||||
}
|
||||
|
||||
struct Pagination: Decodable, Sendable {
|
||||
let total: Int
|
||||
let limit: Int
|
||||
let offset: Int
|
||||
let hasMore: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total, limit, offset
|
||||
case hasMore = "has_more"
|
||||
}
|
||||
}
|
||||
|
||||
struct PaginatedResponse<T: Decodable>: Decodable {
|
||||
let data: T
|
||||
let pagination: Pagination
|
||||
}
|
||||
|
||||
struct APIErrorResponse: Decodable {
|
||||
let message: String
|
||||
let errors: [String: [String]]?
|
||||
}
|
||||
|
||||
enum APIService {
|
||||
case sso
|
||||
case notification
|
||||
}
|
||||
|
||||
enum Endpoint {
|
||||
// Auth
|
||||
case login(email: String, password: String)
|
||||
@@ -45,11 +69,29 @@ enum Endpoint {
|
||||
case logoutAll
|
||||
case changePassword(current: String, new: String)
|
||||
// Notifications
|
||||
case getNotifications(page: Int, perPage: Int)
|
||||
case getNotifications(limit: Int, offset: Int, unreadOnly: Bool, scope: String?)
|
||||
case markAsRead(id: UUID)
|
||||
case markAllAsRead(scope: String?)
|
||||
// Devices
|
||||
case registerDevice(token: String)
|
||||
case unregisterDevice(token: String)
|
||||
case listDevices
|
||||
case registerDevice(token: String, platform: String)
|
||||
case unregisterDevice(id: UUID)
|
||||
case unregisterDeviceByToken(token: String)
|
||||
// Preferences
|
||||
case getPreferences
|
||||
case upsertPreference(channel: String, enabled: Bool, config: [String: String]?)
|
||||
|
||||
var service: APIService {
|
||||
switch self {
|
||||
case .login, .register, .verifyEmail, .resendCode, .refresh, .logout,
|
||||
.getMe, .getSessions, .deleteSession, .logoutAll, .changePassword:
|
||||
return .sso
|
||||
case .getNotifications, .markAsRead, .markAllAsRead,
|
||||
.listDevices, .registerDevice, .unregisterDevice, .unregisterDeviceByToken,
|
||||
.getPreferences, .upsertPreference:
|
||||
return .notification
|
||||
}
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
@@ -66,17 +108,26 @@ enum Endpoint {
|
||||
case .changePassword: return "/users/me/change-password"
|
||||
case .getNotifications: return "/notifications"
|
||||
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
|
||||
case .registerDevice: return "/devices/register"
|
||||
case .unregisterDevice: return "/devices/unregister"
|
||||
case .markAllAsRead: return "/notifications/read-all"
|
||||
case .listDevices: return "/devices"
|
||||
case .registerDevice: return "/devices"
|
||||
case .unregisterDevice(let id): return "/devices/\(id.uuidString)"
|
||||
case .unregisterDeviceByToken: return "/devices/by-token"
|
||||
case .getPreferences: return "/preferences"
|
||||
case .upsertPreference: return "/preferences"
|
||||
}
|
||||
}
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .getMe, .getSessions, .getNotifications: return "GET"
|
||||
case .deleteSession: return "DELETE"
|
||||
case .markAsRead: return "PATCH"
|
||||
default: return "POST"
|
||||
case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences:
|
||||
return "GET"
|
||||
case .deleteSession, .unregisterDevice, .unregisterDeviceByToken:
|
||||
return "DELETE"
|
||||
case .upsertPreference:
|
||||
return "PUT"
|
||||
default:
|
||||
return "POST"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,12 +156,22 @@ enum Endpoint {
|
||||
return ["refresh_token": token]
|
||||
case .changePassword(let current, let new):
|
||||
return ["current_password": current, "new_password": new]
|
||||
case .registerDevice(let token):
|
||||
return ["token": token, "platform": "ios"]
|
||||
case .unregisterDevice(let token):
|
||||
case .registerDevice(let token, let platform):
|
||||
return ["token": token, "platform": platform]
|
||||
case .getNotifications(let limit, let offset, let unreadOnly, let scope):
|
||||
var params: [String: Any] = ["limit": limit, "offset": offset]
|
||||
if unreadOnly { params["unread_only"] = true }
|
||||
if let scope { params["scope"] = scope }
|
||||
return params
|
||||
case .markAllAsRead(let scope):
|
||||
if let scope { return ["scope": scope] }
|
||||
return nil
|
||||
case .upsertPreference(let channel, let enabled, let config):
|
||||
var params: [String: Any] = ["channel": channel, "enabled": enabled]
|
||||
if let config { params["config"] = config }
|
||||
return params
|
||||
case .unregisterDeviceByToken(let token):
|
||||
return ["token": token]
|
||||
case .getNotifications(let page, let perPage):
|
||||
return ["page": page, "per_page": perPage]
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -120,25 +181,81 @@ enum Endpoint {
|
||||
actor HTTPClient {
|
||||
static let shared = HTTPClient()
|
||||
|
||||
private let baseURL: String
|
||||
private let ssoBaseURL: String
|
||||
private let notificationBaseURL: String
|
||||
private let keychain = KeychainService.shared
|
||||
private var isRefreshing = false
|
||||
// Single in-flight refresh task; concurrent 401s await this rather than racing.
|
||||
private var refreshTask: Task<Void, Error>?
|
||||
|
||||
private init() {
|
||||
#if DEBUG
|
||||
baseURL = "http://localhost:8081"
|
||||
#else
|
||||
baseURL = "https://api.chemodan.example/sso"
|
||||
#endif
|
||||
ssoBaseURL = "https://id.robonen.ru"
|
||||
notificationBaseURL = "https://notify.robonen.ru"
|
||||
}
|
||||
|
||||
private func baseURL(for service: APIService) -> String {
|
||||
switch service {
|
||||
case .sso: return ssoBaseURL
|
||||
case .notification: return notificationBaseURL
|
||||
}
|
||||
}
|
||||
|
||||
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
let response: T = try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||
return response
|
||||
let data = try await executeRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||
|
||||
// 204 No Content — return empty decodable if possible
|
||||
if data.isEmpty {
|
||||
if let empty = EmptyResponse() as? T {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let wrapped = try Self.jsonDecoder.decode(APIResponse<T>.self, from: data)
|
||||
return wrapped.data
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func performRequest<T: Decodable>(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T {
|
||||
guard let url = URL(string: baseURL + endpoint.path) else {
|
||||
func requestPaginated<T: Decodable>(_ endpoint: Endpoint) async throws -> (T, Pagination) {
|
||||
let data = try await executeRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||
do {
|
||||
let wrapped = try Self.jsonDecoder.decode(PaginatedResponse<T>.self, from: data)
|
||||
return (wrapped.data, wrapped.pagination)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Go's default time.Time marshaling uses RFC3339Nano (fractional seconds),
|
||||
// which the built-in .iso8601 strategy rejects. Two pre-built formatters
|
||||
// cover both forms; ISO8601DateFormatter is documented thread-safe for read.
|
||||
nonisolated(unsafe) private static let isoWithFractional: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
nonisolated(unsafe) private static let isoWithoutFractional: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let jsonDecoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { d in
|
||||
let container = try d.singleValueContainer()
|
||||
let s = try container.decode(String.self)
|
||||
if let date = isoWithFractional.date(from: s) ?? isoWithoutFractional.date(from: s) {
|
||||
return date
|
||||
}
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date: \(s)")
|
||||
}
|
||||
return decoder
|
||||
}()
|
||||
|
||||
private func executeRequest(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> Data {
|
||||
guard let url = URL(string: baseURL(for: endpoint.service) + endpoint.path) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
@@ -151,8 +268,9 @@ actor HTTPClient {
|
||||
}
|
||||
|
||||
if let body = endpoint.body {
|
||||
if endpoint.method == "GET" {
|
||||
// Append query parameters to URL for GET requests
|
||||
// DELETE/GET don't carry a JSON body; encode params on the URL instead
|
||||
// so endpoints like /devices/by-token?token=... work.
|
||||
if endpoint.method == "GET" || endpoint.method == "DELETE" {
|
||||
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
||||
components.queryItems = body.map { key, value in
|
||||
URLQueryItem(name: key, value: "\(value)")
|
||||
@@ -160,7 +278,11 @@ actor HTTPClient {
|
||||
urlRequest.url = components.url
|
||||
}
|
||||
} else {
|
||||
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
do {
|
||||
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,11 +297,13 @@ actor HTTPClient {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 && retryOnUnauthorized && !isRefreshing {
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
try await refreshTokens()
|
||||
return try await performRequest(endpoint, retryOnUnauthorized: false)
|
||||
if httpResponse.statusCode == 401 && retryOnUnauthorized {
|
||||
do {
|
||||
try await ensureTokenRefreshed()
|
||||
} catch {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
return try await executeRequest(endpoint, retryOnUnauthorized: false)
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 {
|
||||
@@ -200,25 +324,39 @@ actor HTTPClient {
|
||||
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
do {
|
||||
let wrapped = try decoder.decode(APIResponse<T>.self, from: data)
|
||||
return wrapped.data
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
if httpResponse.statusCode == 204 {
|
||||
return Data()
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
private func refreshTokens() async throws {
|
||||
/// Ensures tokens are refreshed exactly once even when multiple requests receive 401
|
||||
/// concurrently. All callers await the same Task; only one network request is made.
|
||||
private func ensureTokenRefreshed() async throws {
|
||||
if let existing = refreshTask {
|
||||
try await existing.value
|
||||
return
|
||||
}
|
||||
|
||||
guard let refreshToken = keychain.loadRefreshToken() else {
|
||||
keychain.clearTokens()
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
let response: TokenRefreshResponse = try await performRequest(
|
||||
.refresh(refreshToken: refreshToken),
|
||||
retryOnUnauthorized: false
|
||||
)
|
||||
try keychain.saveTokens(response.tokens)
|
||||
|
||||
let task = Task<Void, Error> {
|
||||
let response: TokenRefreshResponse = try await self.request(.refresh(refreshToken: refreshToken))
|
||||
try self.keychain.saveTokens(response.tokens)
|
||||
}
|
||||
refreshTask = task
|
||||
do {
|
||||
try await task.value
|
||||
refreshTask = nil
|
||||
} catch {
|
||||
refreshTask = nil
|
||||
keychain.clearTokens()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
/// Observes Push-to-Start tokens for `AlertAttributes` activities and registers
|
||||
/// them with the backend so the server can start, update and end Live Activities
|
||||
/// via APNs. Also cleans up tokens on the server when activities end and when
|
||||
/// the user logs out.
|
||||
@MainActor
|
||||
final class LiveActivityRegistrationService {
|
||||
static let shared = LiveActivityRegistrationService()
|
||||
|
||||
private var startTokenObserver: Task<Void, Never>?
|
||||
private var activityObserver: Task<Void, Never>?
|
||||
private var perActivityObservers: [String: Task<Void, Never>] = [:]
|
||||
private var perActivityTokens: [String: String] = [:]
|
||||
private var lastRegisteredStartToken: String?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Start observing tokens. Safe to call multiple times.
|
||||
func start() {
|
||||
guard startTokenObserver == nil else { return }
|
||||
|
||||
if #available(iOS 17.2, *) {
|
||||
startTokenObserver = Task { [weak self] in
|
||||
for await tokenData in Activity<AlertAttributes>.pushToStartTokenUpdates {
|
||||
let token = DeviceTokenFormatter.hex(tokenData)
|
||||
await self?.registerStartToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activityObserver = Task { [weak self] in
|
||||
for await activity in Activity<AlertAttributes>.activityUpdates {
|
||||
self?.trackActivity(activity)
|
||||
}
|
||||
}
|
||||
|
||||
// Pick up tokens for activities already running (e.g. after relaunch).
|
||||
for activity in Activity<AlertAttributes>.activities {
|
||||
trackActivity(activity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all observers and best-effort delete server-side push tokens so
|
||||
/// the next user on this device doesn't inherit Live Activity routing.
|
||||
/// Per-activity tokens are owned by the OS (they die when the activity
|
||||
/// ends), so only the push-to-start token needs an explicit DELETE.
|
||||
func stop() {
|
||||
startTokenObserver?.cancel()
|
||||
startTokenObserver = nil
|
||||
activityObserver?.cancel()
|
||||
activityObserver = nil
|
||||
perActivityObservers.values.forEach { $0.cancel() }
|
||||
perActivityObservers.removeAll()
|
||||
perActivityTokens.removeAll()
|
||||
|
||||
if let token = lastRegisteredStartToken {
|
||||
lastRegisteredStartToken = nil
|
||||
Task { await Self.unregister(token: token) }
|
||||
}
|
||||
}
|
||||
|
||||
private func trackActivity(_ activity: Activity<AlertAttributes>) {
|
||||
let alertId = activity.attributes.alertId
|
||||
guard perActivityObservers[alertId] == nil else { return }
|
||||
|
||||
perActivityObservers[alertId] = Task { [weak self] in
|
||||
// Spawn a child task to drain pushTokenUpdates concurrently with
|
||||
// the activityStateUpdates loop below. When the activity reaches a
|
||||
// terminal state we cancel the token loop and clean up.
|
||||
let tokenTask = Task { [weak self] in
|
||||
for await tokenData in activity.pushTokenUpdates {
|
||||
let token = DeviceTokenFormatter.hex(tokenData)
|
||||
await self?.registerActivityToken(token, alertId: alertId)
|
||||
}
|
||||
}
|
||||
|
||||
for await state in activity.activityStateUpdates {
|
||||
if state == .ended || state == .dismissed {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tokenTask.cancel()
|
||||
await self?.cleanupEndedActivity(alertId: alertId)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupEndedActivity(alertId: String) async {
|
||||
let token = perActivityTokens.removeValue(forKey: alertId)
|
||||
perActivityObservers[alertId] = nil
|
||||
if let token {
|
||||
await Self.unregister(token: token)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerStartToken(_ token: String) async {
|
||||
guard token != lastRegisteredStartToken else { return }
|
||||
do {
|
||||
let _: DeviceToken = try await HTTPClient.shared.request(
|
||||
.registerDevice(token: token, platform: DevicePlatform.iosLiveActivityStart)
|
||||
)
|
||||
lastRegisteredStartToken = token
|
||||
} catch {
|
||||
// Transient — next token update will retry.
|
||||
}
|
||||
}
|
||||
|
||||
private func registerActivityToken(_ token: String, alertId: String) async {
|
||||
do {
|
||||
let _: DeviceToken = try await HTTPClient.shared.request(
|
||||
.registerDevice(token: token, platform: DevicePlatform.iosLiveActivityUpdate(alertId: alertId))
|
||||
)
|
||||
perActivityTokens[alertId] = token
|
||||
} catch {
|
||||
// Transient — next token update will retry.
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-forget DELETE so tokens are reclaimed on the server.
|
||||
/// 404s and network errors are silently ignored — the worst case is one
|
||||
/// stale row that will eventually be purged via APNs 410 dead-lettering.
|
||||
private static func unregister(token: String) async {
|
||||
let _: EmptyResponse? = try? await HTTPClient.shared.request(.unregisterDeviceByToken(token: token))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
actor NotificationsAPIService {
|
||||
static let shared = NotificationsAPIService()
|
||||
@@ -7,14 +6,54 @@ actor NotificationsAPIService {
|
||||
|
||||
private init() {}
|
||||
|
||||
func getNotifications(page: Int = 1, perPage: Int = 20) async throws -> NotificationsPage {
|
||||
try await client.request(.getNotifications(page: page, perPage: perPage))
|
||||
// MARK: - Notifications
|
||||
|
||||
func getNotifications(limit: Int = 50, offset: Int = 0, unreadOnly: Bool = false, scope: String? = nil) async throws -> NotificationsPage {
|
||||
let (list, pagination): (NotificationsList, Pagination) = try await client.requestPaginated(
|
||||
.getNotifications(limit: limit, offset: offset, unreadOnly: unreadOnly, scope: scope)
|
||||
)
|
||||
return NotificationsPage(
|
||||
notifications: list.notifications,
|
||||
unreadCount: list.unreadCount,
|
||||
total: pagination.total,
|
||||
hasMore: pagination.hasMore
|
||||
)
|
||||
}
|
||||
|
||||
func markAsRead(id: UUID) async throws {
|
||||
let _: AppNotification = try await client.request(.markAsRead(id: id))
|
||||
let _: EmptyResponse = try await client.request(.markAsRead(id: id))
|
||||
}
|
||||
|
||||
func markAllAsRead(scope: String? = nil) async throws {
|
||||
let _: EmptyResponse = try await client.request(.markAllAsRead(scope: scope))
|
||||
}
|
||||
|
||||
// MARK: - Devices
|
||||
|
||||
func listDevices() async throws -> [DeviceToken] {
|
||||
try await client.request(.listDevices)
|
||||
}
|
||||
|
||||
func registerDevice(token: String, platform: String = "ios") async throws -> DeviceToken {
|
||||
try await client.request(.registerDevice(token: token, platform: platform))
|
||||
}
|
||||
|
||||
func unregisterDevice(id: UUID) async throws {
|
||||
let _: EmptyResponse = try await client.request(.unregisterDevice(id: id))
|
||||
}
|
||||
|
||||
// MARK: - Preferences
|
||||
|
||||
func getPreferences() async throws -> [NotificationPreference] {
|
||||
try await client.request(.getPreferences)
|
||||
}
|
||||
|
||||
func upsertPreference(channel: String, enabled: Bool, config: [String: String]? = nil) async throws {
|
||||
let _: EmptyResponse = try await client.request(.upsertPreference(channel: channel, enabled: enabled, config: config))
|
||||
}
|
||||
|
||||
// MARK: - SSO (User Management)
|
||||
|
||||
func getSessions() async throws -> [SessionResponse] {
|
||||
try await client.request(.getSessions)
|
||||
}
|
||||
@@ -31,10 +70,6 @@ actor NotificationsAPIService {
|
||||
func changePassword(current: String, new: String) async throws -> UserResponse {
|
||||
try await client.request(.changePassword(current: current, new: new))
|
||||
}
|
||||
|
||||
func updateAppBadge(_ count: Int) async {
|
||||
await UIApplication.shared.setApplicationIconBadgeNumber(count)
|
||||
}
|
||||
}
|
||||
|
||||
struct LogoutAllResponse: Decodable {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
import ActivityKit
|
||||
|
||||
@MainActor
|
||||
class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
|
||||
final class PushNotificationService: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = PushNotificationService()
|
||||
|
||||
@Published var deviceToken: String?
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
@@ -29,95 +26,27 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
||||
}
|
||||
|
||||
func handleDeviceToken(_ tokenData: Data) {
|
||||
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
deviceToken = token
|
||||
let token = DeviceTokenFormatter.hex(tokenData)
|
||||
Task {
|
||||
try? await HTTPClient.shared.request(.registerDevice(token: token)) as EmptyResponse
|
||||
try? await HTTPClient.shared.request(.registerDevice(token: token, platform: DevicePlatform.ios)) as DeviceToken
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async {
|
||||
guard let aps = userInfo["aps"] as? [String: Any] else { return }
|
||||
|
||||
// Handle Live Activity push
|
||||
if let event = aps["event"] as? String {
|
||||
await handleLiveActivityPush(event: event, userInfo: userInfo, aps: aps)
|
||||
return
|
||||
}
|
||||
|
||||
// Update badge
|
||||
if let badge = aps["badge"] as? Int {
|
||||
await UIApplication.shared.setApplicationIconBadgeNumber(badge)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLiveActivityPush(event: String, userInfo: [AnyHashable: Any], aps: [String: Any]) async {
|
||||
guard let contentStateData = aps["content-state"] as? [String: Any],
|
||||
let contentStateJSON = try? JSONSerialization.data(withJSONObject: contentStateData),
|
||||
let contentState = try? JSONDecoder.iso8601.decode(AlertAttributes.ContentState.self, from: contentStateJSON)
|
||||
else { return }
|
||||
|
||||
switch event {
|
||||
case "start":
|
||||
await startLiveActivity(userInfo: userInfo, contentState: contentState)
|
||||
case "update":
|
||||
await updateLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
|
||||
case "end":
|
||||
await endLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func startLiveActivity(userInfo: [AnyHashable: Any], contentState: AlertAttributes.ContentState) async {
|
||||
guard let attributes = userInfo["attributes"] as? [String: Any],
|
||||
let topic = attributes["topic"] as? String,
|
||||
let alertId = attributes["alertId"] as? String,
|
||||
let severityStr = attributes["severity"] as? String,
|
||||
let severity = Severity(rawValue: severityStr) else { return }
|
||||
|
||||
// Info-level alerts don't warrant a persistent Live Activity — they are low-priority
|
||||
// and should only appear as a standard banner notification.
|
||||
guard severity != .info else { return }
|
||||
|
||||
// Limit to 3 concurrent activities
|
||||
let currentActivities = Activity<AlertAttributes>.activities
|
||||
if currentActivities.count >= 3 {
|
||||
// End the oldest
|
||||
if let oldest = currentActivities.min(by: {
|
||||
$0.contentState.startedAt < $1.contentState.startedAt
|
||||
}) {
|
||||
await oldest.end(ActivityContent(state: oldest.contentState, staleDate: nil), dismissalPolicy: .immediate)
|
||||
}
|
||||
try? await UNUserNotificationCenter.current().setBadgeCount(badge)
|
||||
}
|
||||
|
||||
let attrs = AlertAttributes(topic: topic, alertId: alertId, severity: severity)
|
||||
_ = try? Activity<AlertAttributes>.request(
|
||||
attributes: attrs,
|
||||
content: ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600))
|
||||
)
|
||||
}
|
||||
|
||||
private func updateLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async {
|
||||
guard let alertId else { return }
|
||||
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == alertId {
|
||||
await activity.update(ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600)))
|
||||
}
|
||||
}
|
||||
|
||||
private func endLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async {
|
||||
guard let alertId else { return }
|
||||
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == alertId {
|
||||
let dismissDate = Date().addingTimeInterval(5 * 60)
|
||||
await activity.end(
|
||||
ActivityContent(state: contentState, staleDate: dismissDate),
|
||||
dismissalPolicy: .after(dismissDate)
|
||||
)
|
||||
}
|
||||
// Live Activity start/update/end pushes (apns-push-type: liveactivity)
|
||||
// are handled entirely by the OS via Push-to-Start tokens and per-activity
|
||||
// pushTokens — they never land in this delegate. Only regular alert/badge/sound
|
||||
// pushes reach here, and the only thing we owe them is the badge update above.
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
func userNotificationCenter(
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
@@ -125,7 +54,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
||||
completionHandler([.banner, .badge, .sound])
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
@@ -134,10 +63,3 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONDecoder {
|
||||
static let iso8601: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
class AuthViewModel: ObservableObject {
|
||||
@Published var isAuthenticated = false
|
||||
@Published var currentUser: UserResponse?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
final class AuthViewModel {
|
||||
var isAuthenticated = false
|
||||
var isCheckingAuth = true
|
||||
var currentUser: UserResponse?
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
|
||||
private let auth = AuthService.shared
|
||||
private let keychain = KeychainService.shared
|
||||
@@ -14,18 +16,23 @@ class AuthViewModel: ObservableObject {
|
||||
func checkAuthStatus() async {
|
||||
guard keychain.loadAccessToken() != nil else {
|
||||
isAuthenticated = false
|
||||
isCheckingAuth = false
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
currentUser = try await auth.getMe()
|
||||
isAuthenticated = true
|
||||
isCheckingAuth = false
|
||||
await requestPushIfNeeded()
|
||||
} catch APIError.unauthorized {
|
||||
isAuthenticated = false
|
||||
isCheckingAuth = false
|
||||
} catch {
|
||||
isAuthenticated = false
|
||||
// Network/transient errors — keep authenticated if we already were
|
||||
if !isAuthenticated {
|
||||
isAuthenticated = false
|
||||
}
|
||||
isCheckingAuth = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +83,7 @@ class AuthViewModel: ObservableObject {
|
||||
// Clear anyway
|
||||
keychain.clearTokens()
|
||||
}
|
||||
LiveActivityRegistrationService.shared.stop()
|
||||
isAuthenticated = false
|
||||
currentUser = nil
|
||||
}
|
||||
@@ -85,5 +93,6 @@ class AuthViewModel: ObservableObject {
|
||||
if granted {
|
||||
PushNotificationService.shared.registerForRemoteNotifications()
|
||||
}
|
||||
LiveActivityRegistrationService.shared.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
class NotificationsViewModel: ObservableObject {
|
||||
@Published var notifications: [AppNotification] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var error: String?
|
||||
@Published var hasMore = true
|
||||
final class NotificationsViewModel {
|
||||
var notifications: [AppNotification] = []
|
||||
var unreadCount = 0
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var error: String?
|
||||
var hasMore = true
|
||||
private var hasLoadedOnce = false
|
||||
|
||||
var unreadNotifications: [AppNotification] {
|
||||
notifications.filter { !$0.isRead }
|
||||
}
|
||||
|
||||
var readNotifications: [AppNotification] {
|
||||
notifications.filter { $0.isRead }
|
||||
}
|
||||
|
||||
private let service = NotificationsAPIService.shared
|
||||
private var currentPage = 1
|
||||
private let perPage = 20
|
||||
private let limit = 50
|
||||
private var currentOffset = 0
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
isLoading = !hasLoadedOnce
|
||||
error = nil
|
||||
currentPage = 1
|
||||
defer { isLoading = false }
|
||||
currentOffset = 0
|
||||
defer {
|
||||
isLoading = false
|
||||
hasLoadedOnce = true
|
||||
}
|
||||
do {
|
||||
let page = try await service.getNotifications(page: 1, perPage: perPage)
|
||||
notifications = page.items
|
||||
hasMore = page.items.count == perPage
|
||||
let page = try await service.getNotifications(limit: limit, offset: 0)
|
||||
notifications = page.notifications
|
||||
unreadCount = page.unreadCount
|
||||
hasMore = page.hasMore
|
||||
updateBadge()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
@@ -34,11 +50,12 @@ class NotificationsViewModel: ObservableObject {
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
do {
|
||||
let nextPage = currentPage + 1
|
||||
let page = try await service.getNotifications(page: nextPage, perPage: perPage)
|
||||
notifications.append(contentsOf: page.items)
|
||||
currentPage = nextPage
|
||||
hasMore = page.items.count == perPage
|
||||
let nextOffset = notifications.count
|
||||
let page = try await service.getNotifications(limit: limit, offset: nextOffset)
|
||||
notifications.append(contentsOf: page.notifications)
|
||||
unreadCount = page.unreadCount
|
||||
currentOffset = nextOffset
|
||||
hasMore = page.hasMore
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -46,32 +63,43 @@ class NotificationsViewModel: ObservableObject {
|
||||
|
||||
func markAsRead(_ notification: AppNotification) async {
|
||||
guard !notification.isRead else { return }
|
||||
|
||||
// Optimistic update — reflect read state immediately so the list
|
||||
// shows the correct card style even if the user navigates back
|
||||
// before the API call completes.
|
||||
if let index = notifications.firstIndex(where: { $0.id == notification.id }) {
|
||||
notifications[index] = notification.withReadAt(Date())
|
||||
unreadCount = max(0, unreadCount - 1)
|
||||
updateBadge()
|
||||
}
|
||||
|
||||
do {
|
||||
try await service.markAsRead(id: notification.id)
|
||||
} catch is CancellationError {
|
||||
// View disappeared before the request finished — keep
|
||||
// optimistic state; polling will reconcile if needed.
|
||||
} catch {
|
||||
// Rollback on real failure
|
||||
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
|
||||
notifications[index] = notification
|
||||
unreadCount += 1
|
||||
updateBadge()
|
||||
}
|
||||
updateBadge()
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func markAllAsRead() async {
|
||||
do {
|
||||
try await service.markAllAsRead()
|
||||
await load()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func startPolling() {
|
||||
// Polling always reloads page 1 to pick up new notifications.
|
||||
// Users who have scrolled to older pages will have the list reset on each interval.
|
||||
guard pollingTask == nil else { return }
|
||||
pollingTask = Task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
@@ -87,9 +115,6 @@ class NotificationsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func updateBadge() {
|
||||
let unreadCount = notifications.filter { !$0.isRead }.count
|
||||
Task {
|
||||
await service.updateAppBadge(unreadCount)
|
||||
}
|
||||
UNUserNotificationCenter.current().setBadgeCount(unreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
class SettingsViewModel: ObservableObject {
|
||||
@Published var sessions: [SessionResponse] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
@Published var successMessage: String?
|
||||
final class SettingsViewModel {
|
||||
var sessions: [SessionResponse] = []
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var successMessage: String?
|
||||
|
||||
private let service = NotificationsAPIService.shared
|
||||
|
||||
@@ -35,7 +36,7 @@ class SettingsViewModel: ObservableObject {
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
_ = try await service.changePassword(current: current, new: new)
|
||||
successMessage = "Пароль успешно изменён"
|
||||
successMessage = String(localized: "password_changed_success")
|
||||
return true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
@@ -1,71 +1,114 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@EnvironmentObject var authViewModel: AuthViewModel
|
||||
@Environment(AuthViewModel.self) private var authViewModel
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@State private var showRegister = false
|
||||
|
||||
private var isFormInvalid: Bool {
|
||||
email.isEmpty || password.isEmpty || authViewModel.isLoading
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "bell.badge.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.red)
|
||||
Text("Mayday")
|
||||
.font(.largeTitle.bold())
|
||||
Text("Мониторинг и уведомления")
|
||||
.font(.subheadline)
|
||||
ZStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Spacer(minLength: 24)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Image("Logo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 84, height: 84)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.shadow(color: .brand.opacity(0.25), radius: 12, y: 6)
|
||||
|
||||
Text("Mayday")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("login_subtitle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
AppTextField(
|
||||
title: "Email",
|
||||
icon: "envelope.fill",
|
||||
text: $email
|
||||
)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
AppSecureField(
|
||||
title: "password",
|
||||
icon: "lock.fill",
|
||||
text: $password
|
||||
)
|
||||
.textContentType(.password)
|
||||
}
|
||||
|
||||
if let error = authViewModel.error {
|
||||
Text(error)
|
||||
.foregroundStyle(.brand)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await authViewModel.login(email: email, password: password) }
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.82)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(height: 52)
|
||||
|
||||
if authViewModel.isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("login_button")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isFormInvalid)
|
||||
.opacity(isFormInvalid ? 0.6 : 1)
|
||||
|
||||
Button("login_no_account") {
|
||||
showRegister = true
|
||||
}
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 16) {
|
||||
TextField("Email", text: $email)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
SecureField("Пароль", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.password)
|
||||
}
|
||||
|
||||
if let error = authViewModel.error {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await authViewModel.login(email: email, password: password) }
|
||||
} label: {
|
||||
if authViewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("Войти")
|
||||
.frame(maxWidth: .infinity)
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.cardContainer()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(email.isEmpty || password.isEmpty || authViewModel.isLoading)
|
||||
|
||||
Button("Нет аккаунта? Зарегистрироваться") {
|
||||
showRegister = true
|
||||
}
|
||||
.font(.footnote)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.appBackground()
|
||||
.navigationDestination(isPresented: $showRegister) {
|
||||
RegisterView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LoginView()
|
||||
.environment(AuthViewModel())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RegisterView: View {
|
||||
@EnvironmentObject var authViewModel: AuthViewModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(AuthViewModel.self) private var authViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@@ -10,77 +10,111 @@ struct RegisterView: View {
|
||||
@State private var showVerify = false
|
||||
@State private var registeredEmail = ""
|
||||
|
||||
private var isFormInvalid: Bool {
|
||||
!isFormValid || authViewModel.isLoading
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Spacer(minLength: 24)
|
||||
|
||||
Text("Регистрация")
|
||||
.font(.largeTitle.bold())
|
||||
VStack(spacing: 10) {
|
||||
Image("Logo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.shadow(color: .brand.opacity(0.22), radius: 12, y: 6)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
TextField("Email", text: $email)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
SecureField("Пароль", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.newPassword)
|
||||
|
||||
SecureField("Подтвердите пароль", text: $confirmPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.newPassword)
|
||||
}
|
||||
|
||||
if password.count > 0 && password.count < 8 {
|
||||
Text("Пароль должен содержать не менее 8 символов")
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
if confirmPassword.count > 0 && password != confirmPassword {
|
||||
Text("Пароли не совпадают")
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
if let error = authViewModel.error {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
let success = await authViewModel.register(email: email, password: password)
|
||||
if success {
|
||||
registeredEmail = email
|
||||
showVerify = true
|
||||
Text("register_title")
|
||||
.font(.largeTitle.bold())
|
||||
}
|
||||
|
||||
VStack(spacing: 14) {
|
||||
AppTextField(title: "Email", icon: "envelope.fill", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
AppSecureField(title: "password", icon: "lock.fill", text: $password)
|
||||
.textContentType(.newPassword)
|
||||
|
||||
AppSecureField(title: "confirm_password", icon: "lock.rotation", text: $confirmPassword)
|
||||
.textContentType(.newPassword)
|
||||
}
|
||||
|
||||
if password.count > 0 && password.count < 8 {
|
||||
Text("password_min_length")
|
||||
.foregroundStyle(.brand)
|
||||
.font(.footnote)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if confirmPassword.count > 0 && password != confirmPassword {
|
||||
Text("passwords_mismatch")
|
||||
.foregroundStyle(.brand)
|
||||
.font(.footnote)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let error = authViewModel.error {
|
||||
Text(error)
|
||||
.foregroundStyle(.brand)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
let success = await authViewModel.register(email: email, password: password)
|
||||
if success {
|
||||
registeredEmail = email
|
||||
showVerify = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.82)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(height: 52)
|
||||
|
||||
if authViewModel.isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("register_button")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isFormInvalid)
|
||||
.opacity(isFormInvalid ? 0.6 : 1)
|
||||
|
||||
Button("register_has_account") { dismiss() }
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
} label: {
|
||||
if authViewModel.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("Создать аккаунт").frame(maxWidth: .infinity)
|
||||
}
|
||||
.cardContainer()
|
||||
}
|
||||
.navigationDestination(isPresented: $showVerify) {
|
||||
VerifyEmailView(email: registeredEmail, password: password)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!isFormValid || authViewModel.isLoading)
|
||||
|
||||
Button("Уже есть аккаунт?") { dismiss() }
|
||||
.font(.footnote)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationDestination(isPresented: $showVerify) {
|
||||
VerifyEmailView(email: registeredEmail)
|
||||
}
|
||||
.navigationTitle("Регистрация")
|
||||
.appBackground()
|
||||
.navigationTitle("register_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@@ -88,3 +122,10 @@ struct RegisterView: View {
|
||||
!email.isEmpty && password.count >= 8 && password == confirmPassword
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
RegisterView()
|
||||
}
|
||||
.environment(AuthViewModel())
|
||||
}
|
||||
|
||||
@@ -2,118 +2,196 @@ import SwiftUI
|
||||
|
||||
struct VerifyEmailView: View {
|
||||
let email: String
|
||||
let password: String
|
||||
|
||||
@EnvironmentObject var authViewModel: AuthViewModel
|
||||
@Environment(AuthViewModel.self) private var authViewModel
|
||||
@State private var codeDigits: [String] = Array(repeating: "", count: 6)
|
||||
@State private var resendCooldown = 0
|
||||
@FocusState private var focusedIndex: Int?
|
||||
@State private var resendTimer: Timer?
|
||||
@State private var focusedIndex: Int?
|
||||
@State private var cooldownTask: Task<Void, Never>?
|
||||
|
||||
private var code: String {
|
||||
codeDigits.joined()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Подтвердите email")
|
||||
.font(.largeTitle.bold())
|
||||
Text("Код отправлен на")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(email)
|
||||
.fontWeight(.semibold)
|
||||
ScrollView {
|
||||
contentCard
|
||||
}
|
||||
.appBackground()
|
||||
.navigationTitle("verify_nav_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { focusedIndex = 0 }
|
||||
.onDisappear { cooldownTask?.cancel() }
|
||||
.onChange(of: code) { _, newValue in
|
||||
if newValue.count == 6 {
|
||||
Task { await submitCode(newValue) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(0..<6, id: \.self) { index in
|
||||
TextField("", text: $codeDigits[index])
|
||||
.frame(width: 44, height: 52)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.title2.bold())
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(focusedIndex == index ? Color.accentColor : Color.secondary, lineWidth: 2)
|
||||
)
|
||||
.focused($focusedIndex, equals: index)
|
||||
.onChange(of: codeDigits[index]) { _, newValue in
|
||||
handleDigitChange(index: index, value: newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var contentCard: some View {
|
||||
VStack(spacing: 28) {
|
||||
Spacer(minLength: 24)
|
||||
|
||||
headerView
|
||||
otpFieldsView
|
||||
|
||||
if let error = authViewModel.error {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(.brand)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await resendCode() }
|
||||
} label: {
|
||||
if resendCooldown > 0 {
|
||||
Text("Отправить повторно (\(resendCooldown) сек)")
|
||||
} else {
|
||||
Text("Отправить повторно")
|
||||
}
|
||||
if code.count == 6 {
|
||||
ProgressView()
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.disabled(resendCooldown > 0)
|
||||
|
||||
Spacer()
|
||||
resendButton
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Подтверждение")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { focusedIndex = 0 }
|
||||
.cardContainer()
|
||||
}
|
||||
|
||||
private func handleDigitChange(index: Int, value: String) {
|
||||
let filtered = value.filter { $0.isNumber }
|
||||
if filtered.count > 1 {
|
||||
// Paste handling
|
||||
let digits = Array(filtered.prefix(6))
|
||||
for (i, d) in digits.enumerated() where i < 6 {
|
||||
codeDigits[i] = String(d)
|
||||
}
|
||||
focusedIndex = min(digits.count, 5)
|
||||
} else {
|
||||
codeDigits[index] = filtered.isEmpty ? "" : String(filtered.last!)
|
||||
if !filtered.isEmpty && index < 5 {
|
||||
focusedIndex = index + 1
|
||||
}
|
||||
}
|
||||
private var headerView: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image("Logo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.shadow(color: .brand.opacity(0.22), radius: 12, y: 6)
|
||||
|
||||
let code = codeDigits.joined()
|
||||
if code.count == 6 {
|
||||
Task { await submitCode(code) }
|
||||
Text("verify_email_title")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("verify_code_sent_to")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(email)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
}
|
||||
|
||||
private var otpFieldsView: some View {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(0..<6, id: \.self) { index in
|
||||
otpField(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resendButton: some View {
|
||||
Button {
|
||||
Task { await resendCode() }
|
||||
} label: {
|
||||
Group {
|
||||
if resendCooldown > 0 {
|
||||
Text("verify_resend_cooldown \(resendCooldown)")
|
||||
} else {
|
||||
Text("verify_resend")
|
||||
}
|
||||
}
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(resendCooldown > 0 ? Color.secondary : Color.brand)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.tertiarySystemFill))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.disabled(resendCooldown > 0)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func otpField(at index: Int) -> some View {
|
||||
OTPDigitField(
|
||||
text: $codeDigits[index],
|
||||
isFocused: focusedIndex == index,
|
||||
onFocus: { focusedIndex = index },
|
||||
onInsert: {
|
||||
if index < 5 {
|
||||
focusedIndex = index + 1
|
||||
}
|
||||
},
|
||||
onDeleteWhenEmpty: {
|
||||
handleDeleteOnEmpty(at: index)
|
||||
},
|
||||
onPaste: { digits in
|
||||
handlePaste(digits, startingAt: index)
|
||||
}
|
||||
)
|
||||
.frame(width: 46, height: 56)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(
|
||||
focusedIndex == index
|
||||
? Color.brand.opacity(0.9)
|
||||
: Color.primary.opacity(0.10),
|
||||
lineWidth: focusedIndex == index ? 2 : 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func handleDeleteOnEmpty(at index: Int) {
|
||||
guard index > 0 else { return }
|
||||
codeDigits[index - 1] = ""
|
||||
focusedIndex = index - 1
|
||||
}
|
||||
|
||||
private func handlePaste(_ digits: [String], startingAt startIndex: Int) {
|
||||
guard !digits.isEmpty else { return }
|
||||
for (offset, digit) in digits.enumerated() {
|
||||
let target = startIndex + offset
|
||||
guard target < codeDigits.count else { break }
|
||||
codeDigits[target] = String(digit.prefix(1))
|
||||
}
|
||||
focusedIndex = min(startIndex + digits.count, codeDigits.count - 1)
|
||||
}
|
||||
|
||||
private func submitCode(_ code: String) async {
|
||||
await authViewModel.verifyEmail(email: email, code: code)
|
||||
if authViewModel.error == nil {
|
||||
// Auto-login after verification - in a real flow we'd re-login here
|
||||
// since verify doesn't return tokens
|
||||
await authViewModel.login(email: email, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
private func resendCode() async {
|
||||
do {
|
||||
try await AuthService.shared.resendCode(email: email)
|
||||
resendCooldown = 60
|
||||
startCooldownTimer()
|
||||
startCooldown()
|
||||
} catch {
|
||||
authViewModel.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func startCooldownTimer() {
|
||||
resendTimer?.invalidate()
|
||||
resendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
||||
if resendCooldown > 0 {
|
||||
resendCooldown -= 1
|
||||
} else {
|
||||
resendTimer?.invalidate()
|
||||
private func startCooldown() {
|
||||
cooldownTask?.cancel()
|
||||
cooldownTask = Task {
|
||||
for remaining in stride(from: 60, through: 1, by: -1) {
|
||||
guard !Task.isCancelled else { return }
|
||||
resendCooldown = remaining
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
resendCooldown = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
VerifyEmailView(email: "user@example.com", password: "password123")
|
||||
}
|
||||
.environment(AuthViewModel())
|
||||
}
|
||||
|
||||
@@ -1,68 +1,256 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NotificationDetailView: View {
|
||||
let notification: AppNotification
|
||||
let viewModel: NotificationsViewModel
|
||||
let notificationId: UUID
|
||||
var viewModel: NotificationsViewModel
|
||||
|
||||
private var notification: AppNotification? {
|
||||
viewModel.notifications.first { $0.id == notificationId }
|
||||
}
|
||||
|
||||
init(notification: AppNotification, viewModel: NotificationsViewModel) {
|
||||
self.notificationId = notification.id
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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("Метаданные:")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
Group {
|
||||
if let notification {
|
||||
scrollContent(notification)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Уведомление")
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("details_section")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.markAsRead(notification)
|
||||
if let notification, !notification.isRead {
|
||||
await viewModel.markAsRead(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollContent(_ notification: AppNotification) -> some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Hero header
|
||||
headerSection(notification)
|
||||
|
||||
// Info cards
|
||||
VStack(spacing: 16) {
|
||||
detailsCard(notification)
|
||||
|
||||
if let metadata = notification.metadata, !metadata.isEmpty {
|
||||
metadataCard(metadata)
|
||||
}
|
||||
|
||||
statusCard(notification)
|
||||
}
|
||||
.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("mark_as_read")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.brand)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.brand.opacity(0.1))
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero Header
|
||||
|
||||
private func headerSection(_ notification: AppNotification) -> some View {
|
||||
let severity = NotificationSeverity(from: notification.metadata)
|
||||
return VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.secondarySystemGroupedBackground))
|
||||
.frame(width: 88, height: 88)
|
||||
.shadow(color: severity.color.opacity(0.3), radius: 12, y: 4)
|
||||
Circle()
|
||||
.fill(severity.color.opacity(0.15))
|
||||
.frame(width: 80, height: 80)
|
||||
Image(systemName: severity.icon)
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(severity.color)
|
||||
}
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(notification.subject ?? "")
|
||||
.font(.title3.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(notification.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
statusBadge(for: notification)
|
||||
}
|
||||
.padding(.vertical, 28)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Status Badge
|
||||
|
||||
private func statusBadge(for notification: AppNotification) -> some View {
|
||||
let (text, color): (String, Color) = notification.isRead
|
||||
? (String(localized: "status_read"), .success)
|
||||
: (String(localized: "status_new"), .brand)
|
||||
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 func detailsCard(_ notification: AppNotification) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("details_section", 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(Color(.secondarySystemGroupedBackground))
|
||||
.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("info_section", 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(Color(.secondarySystemGroupedBackground))
|
||||
.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 func statusCard(_ notification: AppNotification) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("status_section", systemImage: "clock.fill")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
infoRow(icon: "paperplane.fill", label: String(localized: "channel_label"), value: channelLabel(for: notification))
|
||||
Divider()
|
||||
infoRow(icon: "clock", label: String(localized: "received_label"), value: notification.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
if let readAt = notification.readAt {
|
||||
Divider()
|
||||
infoRow(icon: "checkmark.circle.fill", label: String(localized: "read_at_label"), value: readAt.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.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 func channelLabel(for notification: AppNotification) -> String {
|
||||
switch notification.channel {
|
||||
case .inApp: return String(localized: "channel_in_app")
|
||||
case .apns: return "Push"
|
||||
case .email: return "Email"
|
||||
case .telegram: return "Telegram"
|
||||
case .webhook: return "Webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let notification = AppNotification(
|
||||
id: UUID(), userId: UUID(), scopeId: nil, channel: .inApp,
|
||||
contentType: .plain, templateId: nil, subject: "CPU Usage Critical",
|
||||
body: "Server load has exceeded 95% for the last 5 minutes. Immediate action is required to prevent service degradation.",
|
||||
source: "monitoring", metadata: ["severity": "critical", "host": "prod-01", "region": "eu-west-1"],
|
||||
status: .sent, error: nil, attempts: 1, maxAttempts: 3,
|
||||
nextRetryAt: nil, sentAt: Date(), readAt: nil, createdAt: Date()
|
||||
)
|
||||
let vm = NotificationsViewModel()
|
||||
NavigationStack {
|
||||
NotificationDetailView(notification: notification, viewModel: vm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NotificationsView: View {
|
||||
@EnvironmentObject var authViewModel: AuthViewModel
|
||||
@StateObject private var viewModel = NotificationsViewModel()
|
||||
@Environment(AuthViewModel.self) private var authViewModel
|
||||
@State private var viewModel = NotificationsViewModel()
|
||||
@State private var showSettings = false
|
||||
|
||||
var body: some View {
|
||||
@@ -12,33 +12,36 @@ struct NotificationsView: View {
|
||||
ProgressView()
|
||||
} else if let error = viewModel.error, viewModel.notifications.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Ошибка загрузки",
|
||||
"loading_error",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(error)
|
||||
)
|
||||
} else if viewModel.notifications.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Нет уведомлений",
|
||||
"no_notifications",
|
||||
systemImage: "bell.slash",
|
||||
description: Text("Новые уведомления появятся здесь")
|
||||
description: Text("no_notifications_description")
|
||||
)
|
||||
} else {
|
||||
notificationsList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Уведомления")
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("notifications_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gear")
|
||||
Image(systemName: "gearshape.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
.environmentObject(authViewModel)
|
||||
.environment(authViewModel)
|
||||
}
|
||||
.task {
|
||||
await viewModel.load()
|
||||
@@ -53,72 +56,220 @@ 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")
|
||||
private var notificationsList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if !viewModel.unreadNotifications.isEmpty {
|
||||
sectionHeader(String(localized: "notifications_active"))
|
||||
ForEach(viewModel.unreadNotifications) { notification in
|
||||
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
|
||||
ActiveNotificationCard(notification: notification)
|
||||
}
|
||||
.id("\(notification.id)-\(notification.isRead)")
|
||||
.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 !viewModel.readNotifications.isEmpty {
|
||||
sectionHeader(String(localized: "notifications_completed"))
|
||||
ForEach(viewModel.readNotifications) { notification in
|
||||
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
|
||||
ResolvedNotificationCard(notification: notification)
|
||||
}
|
||||
.id("\(notification.id)-\(notification.isRead)")
|
||||
.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)
|
||||
}
|
||||
|
||||
private 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: - Previews
|
||||
|
||||
private extension AppNotification {
|
||||
static let previewUnread = AppNotification(
|
||||
id: UUID(), userId: UUID(), scopeId: nil, channel: .inApp,
|
||||
contentType: .plain, templateId: nil, subject: "CPU Usage Critical",
|
||||
body: "Server load has exceeded 95% for the last 5 minutes.",
|
||||
source: "monitoring", metadata: ["severity": "critical"],
|
||||
status: .sent, error: nil, attempts: 1, maxAttempts: 3,
|
||||
nextRetryAt: nil, sentAt: Date(), readAt: nil, createdAt: Date()
|
||||
)
|
||||
|
||||
static let previewRead = AppNotification(
|
||||
id: UUID(), userId: UUID(), scopeId: nil, channel: .inApp,
|
||||
contentType: .plain, templateId: nil, subject: "Deployment Successful",
|
||||
body: "Version 2.1.0 has been deployed to production.",
|
||||
source: "ci/cd", metadata: ["severity": "success"],
|
||||
status: .read, error: nil, attempts: 1, maxAttempts: 3,
|
||||
nextRetryAt: nil, sentAt: Date(), readAt: Date(), createdAt: Date().addingTimeInterval(-3600)
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Active Card") {
|
||||
ActiveNotificationCard(notification: .previewUnread)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("Resolved Card") {
|
||||
ResolvedNotificationCard(notification: .previewRead)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("Notifications List") {
|
||||
NotificationsView()
|
||||
.environment(AuthViewModel())
|
||||
}
|
||||
|
||||
// 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(severity: NotificationSeverity(from: notification.metadata), isActive: true)
|
||||
|
||||
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.relativeFormatted)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(notification.subject ?? "")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
if let source = notification.source {
|
||||
Text(source)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
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("open_button")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(Color.brand)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(16)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.85)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.shadow(color: .brand.opacity(0.3), radius: 8, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var relativeFormatted: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.locale = Locale(identifier: "ru_RU")
|
||||
return formatter.localizedString(for: self, relativeTo: Date())
|
||||
// MARK: - Resolved (Read) Card
|
||||
|
||||
struct ResolvedNotificationCard: View {
|
||||
let notification: AppNotification
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top) {
|
||||
NotificationIconView(severity: NotificationSeverity(from: notification.metadata), isActive: false)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(notification.subject ?? "")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
if let source = notification.source {
|
||||
Text(source)
|
||||
.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(.success)
|
||||
Text("notification_read_at \(readAt.formatted(date: .abbreviated, time: .shortened))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,111 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChangePasswordView: View {
|
||||
@StateObject private var viewModel = SettingsViewModel()
|
||||
@Environment(\.dismiss) var dismiss
|
||||
var viewModel: SettingsViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var currentPassword = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var confirmPassword = ""
|
||||
|
||||
private var isFormInvalid: Bool {
|
||||
!isFormValid || viewModel.isLoading
|
||||
}
|
||||
|
||||
private var isFormValid: Bool {
|
||||
!currentPassword.isEmpty && newPassword.count >= 8 && newPassword == confirmPassword
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
SecureField("Текущий пароль", text: $currentPassword)
|
||||
VStack(spacing: 12) {
|
||||
AppSecureField(
|
||||
title: "current_password",
|
||||
icon: "lock.fill",
|
||||
text: $currentPassword
|
||||
)
|
||||
.textContentType(.password)
|
||||
SecureField("Новый пароль", text: $newPassword)
|
||||
|
||||
AppSecureField(
|
||||
title: "new_password",
|
||||
icon: "key.fill",
|
||||
text: $newPassword
|
||||
)
|
||||
.textContentType(.newPassword)
|
||||
SecureField("Подтвердите новый пароль", text: $confirmPassword)
|
||||
|
||||
AppSecureField(
|
||||
title: "confirm_new_password",
|
||||
icon: "lock.rotation",
|
||||
text: $confirmPassword
|
||||
)
|
||||
.textContentType(.newPassword)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
if newPassword.count > 0 && newPassword.count < 8 {
|
||||
Section {
|
||||
Text("password_min_length")
|
||||
.foregroundStyle(.brand)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
if confirmPassword.count > 0 && newPassword != confirmPassword {
|
||||
Section {
|
||||
Text("passwords_mismatch")
|
||||
.foregroundStyle(.brand)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.error {
|
||||
Section {
|
||||
Text(error).foregroundStyle(.red)
|
||||
Text(error)
|
||||
.foregroundStyle(.brand)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
if let success = viewModel.successMessage {
|
||||
Section {
|
||||
Text(success).foregroundStyle(.green)
|
||||
Text(success)
|
||||
.foregroundStyle(.success)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Сохранить") {
|
||||
Button {
|
||||
Task {
|
||||
let success = await viewModel.changePassword(current: currentPassword, new: newPassword)
|
||||
if success { dismiss() }
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("save_button")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.disabled(!isFormValid || viewModel.isLoading)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(isFormInvalid)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Сменить пароль")
|
||||
.navigationTitle("change_password_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Отмена") { dismiss() }
|
||||
Button("cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isFormValid: Bool {
|
||||
!currentPassword.isEmpty && newPassword.count >= 8 && newPassword == confirmPassword
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChangePasswordView(viewModel: SettingsViewModel())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionsView: View {
|
||||
@EnvironmentObject var viewModel: SettingsViewModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
var viewModel: SettingsViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -14,19 +14,19 @@ struct SessionsView: View {
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
if session.isCurrent {
|
||||
Text("Текущая")
|
||||
Text("current_session")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.green.opacity(0.2))
|
||||
.foregroundStyle(.green)
|
||||
.background(Color.success.opacity(0.2))
|
||||
.foregroundStyle(.success)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
Text(session.ipAddress)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Создана: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))")
|
||||
Text("session_created \(session.createdAt.formatted(date: .abbreviated, time: .shortened))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -35,19 +35,23 @@ struct SessionsView: View {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.deleteSession(session) }
|
||||
} label: {
|
||||
Label("Удалить", systemImage: "trash")
|
||||
Label("delete_button", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Активные сессии")
|
||||
.navigationTitle("active_sessions_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Готово") { dismiss() }
|
||||
Button("done_button") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SessionsView(viewModel: SettingsViewModel())
|
||||
}
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var authViewModel: AuthViewModel
|
||||
@StateObject private var viewModel = SettingsViewModel()
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(AuthViewModel.self) private var authViewModel
|
||||
@State private var viewModel = SettingsViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showChangePassword = false
|
||||
@State private var showSessions = false
|
||||
@State private var showLogoutAllConfirm = false
|
||||
@State private var logoutAllError: String?
|
||||
@State private var showLogoutAllError = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Аккаунт") {
|
||||
Section("account_section") {
|
||||
if let user = authViewModel.currentUser {
|
||||
LabeledContent("Email", value: user.email)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Сменить пароль") {
|
||||
Button {
|
||||
showChangePassword = true
|
||||
} label: {
|
||||
Text("change_password")
|
||||
}
|
||||
.tint(.primary)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Push-уведомления", systemImage: "bell.badge")
|
||||
.foregroundStyle(.primary)
|
||||
Label("push_notifications", systemImage: "bell.badge")
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -37,7 +42,7 @@ struct SettingsView: View {
|
||||
showSessions = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Активные сессии")
|
||||
Text("active_sessions")
|
||||
Spacer()
|
||||
if !viewModel.sessions.isEmpty {
|
||||
Text("(\(viewModel.sessions.count))")
|
||||
@@ -47,45 +52,54 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.tint(.primary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Выйти из аккаунта", role: .destructive) {
|
||||
Button("logout_button", role: .destructive) {
|
||||
Task { await authViewModel.logout() }
|
||||
}
|
||||
|
||||
Button("Выйти на всех устройствах", role: .destructive) {
|
||||
Button("logout_all_button", role: .destructive) {
|
||||
showLogoutAllConfirm = true
|
||||
}
|
||||
.confirmationDialog(
|
||||
"logout_all_confirm",
|
||||
isPresented: $showLogoutAllConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("logout_all_action", role: .destructive) {
|
||||
Task {
|
||||
do {
|
||||
_ = try await NotificationsAPIService.shared.logoutAll()
|
||||
await authViewModel.logout()
|
||||
} catch {
|
||||
logoutAllError = error.localizedDescription
|
||||
showLogoutAllError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Настройки")
|
||||
.navigationTitle("settings_title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Готово") { dismiss() }
|
||||
Button("done_button") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
ChangePasswordView(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: $showSessions) {
|
||||
SessionsView()
|
||||
.environmentObject(viewModel)
|
||||
SessionsView(viewModel: viewModel)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Выйти на всех устройствах?",
|
||||
isPresented: $showLogoutAllConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Выйти везде", role: .destructive) {
|
||||
Task {
|
||||
_ = try? await NotificationsAPIService.shared.logoutAll()
|
||||
await authViewModel.logout()
|
||||
}
|
||||
}
|
||||
Button("Отмена", role: .cancel) {}
|
||||
.alert("error_title", isPresented: $showLogoutAllError) {
|
||||
Button("OK") { logoutAllError = nil }
|
||||
} message: {
|
||||
Text(logoutAllError ?? "")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadSessions()
|
||||
@@ -93,3 +107,8 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
.environment(AuthViewModel())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppBackgroundModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content.background(
|
||||
LinearGradient(
|
||||
colors: [Color(.systemGroupedBackground), Color.brand.opacity(0.08)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appBackground() -> some View {
|
||||
modifier(AppBackgroundModifier())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
static let brand = Color("Brand")
|
||||
static let success = Color("Success")
|
||||
static let warning = Color("Warning")
|
||||
static let info = Color("Info")
|
||||
}
|
||||
|
||||
extension ShapeStyle where Self == Color {
|
||||
static var brand: Color { .brand }
|
||||
static var success: Color { .success }
|
||||
static var warning: Color { .warning }
|
||||
static var info: Color { .info }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppSecureField: View {
|
||||
let title: LocalizedStringKey
|
||||
let icon: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 18)
|
||||
|
||||
SecureField(title, text: $text)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AppSecureField(title: "Password", icon: "lock.fill", text: .constant(""))
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppTextField: View {
|
||||
let title: LocalizedStringKey
|
||||
let icon: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 18)
|
||||
|
||||
TextField(title, text: $text)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AppTextField(title: "Email", icon: "envelope.fill", text: .constant("user@example.com"))
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CardContainerModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 24)
|
||||
.background(Color(.systemBackground).opacity(0.8))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
|
||||
)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cardContainer() -> some View {
|
||||
modifier(CardContainerModifier())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
enum NotificationSeverity: String {
|
||||
case critical
|
||||
case warning
|
||||
case info
|
||||
case success
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .critical: return "exclamationmark.triangle.fill"
|
||||
case .warning: return "exclamationmark.circle.fill"
|
||||
case .info: return "info.circle.fill"
|
||||
case .success: return "checkmark.seal.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .critical: return .brand
|
||||
case .warning: return .warning
|
||||
case .info: return .info
|
||||
case .success: return .success
|
||||
}
|
||||
}
|
||||
|
||||
init(from metadata: [String: String]?) {
|
||||
let raw = metadata?["severity"]?.lowercased() ?? ""
|
||||
self = NotificationSeverity(rawValue: raw) ?? .info
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationIconView: View {
|
||||
let severity: NotificationSeverity
|
||||
let isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isActive ? .white.opacity(0.25) : severity.color.opacity(0.12))
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: severity.icon)
|
||||
.font(.body)
|
||||
.foregroundStyle(isActive ? .white : severity.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HStack(spacing: 16) {
|
||||
NotificationIconView(severity: .critical, isActive: true)
|
||||
NotificationIconView(severity: .warning, isActive: false)
|
||||
NotificationIconView(severity: .info, isActive: false)
|
||||
NotificationIconView(severity: .success, isActive: false)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct OTPDigitField: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
let isFocused: Bool
|
||||
let onFocus: () -> Void
|
||||
let onInsert: () -> Void
|
||||
let onDeleteWhenEmpty: () -> Void
|
||||
let onPaste: ([String]) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> BackspaceAwareTextField {
|
||||
let textField = BackspaceAwareTextField()
|
||||
textField.delegate = context.coordinator
|
||||
textField.keyboardType = .numberPad
|
||||
textField.textAlignment = .center
|
||||
textField.font = UIFont.systemFont(ofSize: 24, weight: .bold)
|
||||
textField.textContentType = .oneTimeCode
|
||||
textField.onDeleteWhenEmpty = {
|
||||
onDeleteWhenEmpty()
|
||||
}
|
||||
textField.addTarget(context.coordinator, action: #selector(Coordinator.editingChanged(_:)), for: .editingChanged)
|
||||
return textField
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: BackspaceAwareTextField, context: Context) {
|
||||
if uiView.text != text {
|
||||
uiView.text = text
|
||||
}
|
||||
|
||||
if isFocused && !uiView.isFirstResponder {
|
||||
DispatchQueue.main.async {
|
||||
uiView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(parent: self)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UITextFieldDelegate {
|
||||
var parent: OTPDigitField
|
||||
|
||||
init(parent: OTPDigitField) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
|
||||
parent.onFocus()
|
||||
return true
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
parent.onFocus()
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
if string.isEmpty {
|
||||
return true
|
||||
}
|
||||
|
||||
let digits = string.filter { $0.isNumber }
|
||||
guard !digits.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
if digits.count > 1 {
|
||||
parent.onPaste(digits.map(String.init))
|
||||
return false
|
||||
}
|
||||
|
||||
parent.text = String(digits.prefix(1))
|
||||
parent.onInsert()
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
func editingChanged(_ textField: UITextField) {
|
||||
let digitsOnly = (textField.text ?? "").filter { $0.isNumber }
|
||||
let single = String(digitsOnly.prefix(1))
|
||||
if textField.text != single {
|
||||
textField.text = single
|
||||
}
|
||||
parent.text = single
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class BackspaceAwareTextField: UITextField {
|
||||
var onDeleteWhenEmpty: (() -> Void)?
|
||||
|
||||
override func deleteBackward() {
|
||||
let wasEmpty = (text ?? "").isEmpty
|
||||
super.deleteBackward()
|
||||
if wasEmpty {
|
||||
onDeleteWhenEmpty?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +1,218 @@
|
||||
import ActivityKit
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
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))
|
||||
.activitySystemActionForegroundColor(.primary)
|
||||
// MARK: - Lock Screen / Banner / StandBy
|
||||
|
||||
HStack(spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(severityColor(context.attributes.severity))
|
||||
.frame(width: 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(context.attributes.topic)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(context.state.startedAt, style: .relative)
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.tertiary)
|
||||
.contentTransition(.numericText(countsDown: false))
|
||||
}
|
||||
|
||||
Text(context.state.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
if let value = context.state.value {
|
||||
Text(value)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
statusLabel(context.state.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.activityBackgroundTint(severityColor(context.attributes.severity).opacity(0.12))
|
||||
.activitySystemActionForegroundColor(severityColor(context.attributes.severity))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(context.attributes.severity.rawValue) alert: \(context.state.title)")
|
||||
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
// MARK: - Expanded
|
||||
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label(context.attributes.topic, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let value = context.state.value {
|
||||
Text(value)
|
||||
.font(.caption.bold())
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: severityIcon(context.attributes.severity))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
.fixedSize()
|
||||
|
||||
Text(context.attributes.topic)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: 90, alignment: .leading)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
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("Длит.: \(duration(from: context.state.startedAt))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(context.state.startedAt, style: .relative)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.contentTransition(.numericText(countsDown: false))
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.bottom, priority: 1) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(context.state.title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack {
|
||||
if let value = context.state.value {
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
severityColor(context.attributes.severity).opacity(0.2),
|
||||
in: ContainerRelativeShape()
|
||||
)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
statusLabel(context.state.status)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
} compactLeading: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
// MARK: - Compact
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: severityIcon(context.attributes.severity))
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
.fixedSize()
|
||||
|
||||
Text(context.attributes.topic)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: 70, alignment: .leading)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
|
||||
} compactTrailing: {
|
||||
let shortTopic = context.attributes.topic.components(separatedBy: "/").last ?? context.attributes.topic
|
||||
let valueText = context.state.value.map { " · \($0)" } ?? ""
|
||||
Text("\(shortTopic)\(valueText)")
|
||||
Text(context.state.startedAt, style: .timer)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.fontWeight(.medium)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.contentTransition(.numericText(countsDown: false))
|
||||
.frame(maxWidth: 40, alignment: .trailing)
|
||||
.padding(.trailing, 4)
|
||||
|
||||
} minimal: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
Image(systemName: severityIcon(context.attributes.severity))
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
}
|
||||
.keylineTint(severityColor(context.attributes.severity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func lockScreenView(context: ActivityViewContext<AlertAttributes>) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
// MARK: - Helpers
|
||||
|
||||
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)
|
||||
.foregroundStyle(severityColor(context.attributes.severity))
|
||||
}
|
||||
Text(context.state.startedAt.relativeFormatted)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
statusBadge(context.state.status)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func statusBadge(_ status: AlertStatus) -> some View {
|
||||
let (text, color): (String, Color) = status == .active
|
||||
? ("active", .red)
|
||||
: ("resolved", .green)
|
||||
Text(text)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.2))
|
||||
.foregroundStyle(color)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
func severityColor(_ severity: Severity) -> Color {
|
||||
switch severity {
|
||||
case .critical: return .red
|
||||
case .warning: return .yellow
|
||||
case .info: return .blue
|
||||
}
|
||||
}
|
||||
|
||||
func duration(from startDate: Date) -> String {
|
||||
let interval = Date().timeIntervalSince(startDate)
|
||||
let minutes = Int(interval / 60)
|
||||
let hours = minutes / 60
|
||||
if hours > 0 {
|
||||
return "\(hours)ч \(minutes % 60)м"
|
||||
}
|
||||
return "\(minutes)м"
|
||||
private func severityColor(_ severity: Severity) -> Color {
|
||||
switch severity {
|
||||
case .critical: .red
|
||||
case .warning: .orange
|
||||
case .info: .cyan
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var relativeFormatted: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.locale = Locale(identifier: "ru_RU")
|
||||
return formatter.localizedString(for: self, relativeTo: Date())
|
||||
private func severityIcon(_ severity: Severity) -> String {
|
||||
switch severity {
|
||||
case .critical: "exclamationmark.triangle.fill"
|
||||
case .warning: "exclamationmark.circle.fill"
|
||||
case .info: "info.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusLabel(_ status: AlertStatus) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(status == .active ? Color.red : Color.green)
|
||||
.frame(width: 5, height: 5)
|
||||
|
||||
Text(status == .active ? "Active" : "Resolved")
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(status == .active ? .red : .green)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Live Activity", as: .content, using: AlertAttributes(
|
||||
topic: "server-health",
|
||||
alertId: "alert-001",
|
||||
severity: .critical
|
||||
)) {
|
||||
MaydayLiveActivityLiveActivity()
|
||||
} contentStates: {
|
||||
AlertAttributes.ContentState(
|
||||
title: "CPU Usage Exceeded 95%",
|
||||
value: "Current: 97.3%",
|
||||
status: .active,
|
||||
startedAt: .now.addingTimeInterval(-300),
|
||||
updatedAt: .now
|
||||
)
|
||||
AlertAttributes.ContentState(
|
||||
title: "CPU Usage Normalized",
|
||||
value: "Current: 42.1%",
|
||||
status: .resolved,
|
||||
startedAt: .now.addingTimeInterval(-600),
|
||||
updatedAt: .now
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,46 @@
|
||||
# mayday
|
||||
The app you hope you never get a notification from
|
||||
<p align="center">
|
||||
<img src="Mayday/Assets.xcassets/Logo.imageset/logo.png" alt="Mayday logo" width="96" height="96" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Mayday</h1>
|
||||
|
||||
<p align="center">
|
||||
The app you hope you never get a notification from.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
iOS app for emergency and critical alerts with clear severity states,
|
||||
quick triage, and secure session-aware access.
|
||||
</p>
|
||||
|
||||
## Highlights
|
||||
|
||||
- Fast notification feed with unread/read sections
|
||||
- Auto-mark as read on open with consistent UI state
|
||||
- Severity-driven icon and color mapping from `metadata.severity`
|
||||
- Auth flow with login, register, and email verification
|
||||
- Account settings: sessions and password management
|
||||
|
||||
## Tech
|
||||
|
||||
- SwiftUI
|
||||
- MVVM
|
||||
- Async/await networking
|
||||
- Keychain-backed auth token storage
|
||||
|
||||
## Run
|
||||
|
||||
1. Open `Mayday.xcodeproj` in Xcode
|
||||
2. Select the `Mayday` scheme
|
||||
3. Build and run on simulator or device
|
||||
|
||||
## Project
|
||||
|
||||
- App source: `Mayday/`
|
||||
- Live Activity target: `MaydayLiveActivity/`
|
||||
|
||||
## Icon Attribution
|
||||
|
||||
Icon from [lucide.dev](https://lucide.dev), released under the ISC License.
|
||||
|
||||
Copyright (c) 2026 Lucide Contributors
|
||||
|
||||