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
|
*.ipa
|
||||||
*.dSYM.zip
|
*.dSYM.zip
|
||||||
*.dSYM
|
*.dSYM
|
||||||
|
# Swift Package Manager
|
||||||
.build/
|
.build/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
AA000001000011 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000011 /* AuthService.swift */; };
|
AA000001000011 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000011 /* AuthService.swift */; };
|
||||||
AA000001000012 /* NotificationsAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000012 /* NotificationsAPIService.swift */; };
|
AA000001000012 /* NotificationsAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000012 /* NotificationsAPIService.swift */; };
|
||||||
AA000001000013 /* PushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000013 /* PushNotificationService.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 */; };
|
AA000001000014 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000014 /* AuthViewModel.swift */; };
|
||||||
AA000001000015 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000015 /* NotificationsViewModel.swift */; };
|
AA000001000015 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000015 /* NotificationsViewModel.swift */; };
|
||||||
AA000001000016 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000016 /* SettingsViewModel.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 */; };
|
AA000001000023 /* ChangePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000023 /* ChangePasswordView.swift */; };
|
||||||
AA000001000024 /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000024 /* SessionsView.swift */; };
|
AA000001000024 /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000024 /* SessionsView.swift */; };
|
||||||
AA000001000025 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000025 /* Assets.xcassets */; };
|
AA000001000025 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000025 /* Assets.xcassets */; };
|
||||||
|
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 */; };
|
AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000030 /* MaydayLiveActivityBundle.swift */; };
|
||||||
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000031 /* MaydayLiveActivityLiveActivity.swift */; };
|
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000031 /* MaydayLiveActivityLiveActivity.swift */; };
|
||||||
AA000001000032 /* AlertAttributes.swift in Sources (Extension) */ = {isa = PBXBuildFile; fileRef = AA000002000008 /* AlertAttributes.swift */; };
|
AA000001000032 /* AlertAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000008 /* AlertAttributes.swift */; };
|
||||||
|
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, ); }; };
|
AA000007000001 /* MaydayLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA000008000001 /* MaydayLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -76,6 +88,8 @@
|
|||||||
AA000002000011 /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
AA000002000024 /* SessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = "<group>"; };
|
||||||
AA000002000025 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
AA000002000025 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
AA000002000026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
AA000002000026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
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>"; };
|
AA000002000030 /* MaydayLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityBundle.swift; sourceTree = "<group>"; };
|
||||||
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityLiveActivity.swift; sourceTree = "<group>"; };
|
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityLiveActivity.swift; sourceTree = "<group>"; };
|
||||||
AA000002000033 /* Info.plist (Extension) */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
AA000002000033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
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; };
|
AA000008000001 /* MaydayLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MaydayLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AA000009000001 /* Mayday.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mayday.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA000009000001 /* Mayday.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mayday.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
AA000010000001 /* Frameworks (App) */ = {
|
AA000010000001 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
AA000010000002 /* Frameworks (Extension) */ = {
|
AA000010000002 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -114,7 +138,7 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
AA000011000001 /* Root */ = {
|
AA000011000001 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AA000011000002 /* Mayday */,
|
AA000011000002 /* Mayday */,
|
||||||
@@ -123,15 +147,6 @@
|
|||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
AA000011000099 /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
AA000009000001 /* Mayday.app */,
|
|
||||||
AA000008000001 /* MaydayLiveActivity.appex */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
AA000011000002 /* Mayday */ = {
|
AA000011000002 /* Mayday */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -140,6 +155,10 @@
|
|||||||
AA000002000003 /* AppDelegate.swift */,
|
AA000002000003 /* AppDelegate.swift */,
|
||||||
AA000002000025 /* Assets.xcassets */,
|
AA000002000025 /* Assets.xcassets */,
|
||||||
AA000002000026 /* Info.plist */,
|
AA000002000026 /* Info.plist */,
|
||||||
|
AA000002000027 /* Localizable.xcstrings */,
|
||||||
|
AA000002000028 /* InfoPlist.xcstrings */,
|
||||||
|
AA000002000029 /* Mayday.entitlements */,
|
||||||
|
AA000002000040 /* LaunchScreen.storyboard */,
|
||||||
AA000011000003 /* Models */,
|
AA000011000003 /* Models */,
|
||||||
AA000011000004 /* Services */,
|
AA000011000004 /* Services */,
|
||||||
AA000011000005 /* ViewModels */,
|
AA000011000005 /* ViewModels */,
|
||||||
@@ -168,6 +187,8 @@
|
|||||||
AA000002000011 /* AuthService.swift */,
|
AA000002000011 /* AuthService.swift */,
|
||||||
AA000002000012 /* NotificationsAPIService.swift */,
|
AA000002000012 /* NotificationsAPIService.swift */,
|
||||||
AA000002000013 /* PushNotificationService.swift */,
|
AA000002000013 /* PushNotificationService.swift */,
|
||||||
|
AA000002000099 /* LiveActivityRegistrationService.swift */,
|
||||||
|
AA000002000100 /* DevicePlatform.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -188,6 +209,7 @@
|
|||||||
AA000011000007 /* Auth */,
|
AA000011000007 /* Auth */,
|
||||||
AA000011000008 /* Notifications */,
|
AA000011000008 /* Notifications */,
|
||||||
AA000011000009 /* Settings */,
|
AA000011000009 /* Settings */,
|
||||||
|
AA000011000011 /* UIKit */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -226,11 +248,33 @@
|
|||||||
children = (
|
children = (
|
||||||
AA000002000030 /* MaydayLiveActivityBundle.swift */,
|
AA000002000030 /* MaydayLiveActivityBundle.swift */,
|
||||||
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */,
|
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */,
|
||||||
AA000002000033 /* Info.plist (Extension) */,
|
AA000002000033 /* Info.plist */,
|
||||||
);
|
);
|
||||||
path = MaydayLiveActivity;
|
path = MaydayLiveActivity;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -297,7 +341,7 @@
|
|||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = AA000011000001 /* Root */;
|
mainGroup = AA000011000001;
|
||||||
productRefGroup = AA000011000099 /* Products */;
|
productRefGroup = AA000011000099 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -309,25 +353,29 @@
|
|||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
AA000014000001 /* Resources (App) */ = {
|
AA000014000001 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
AA000001000025 /* Assets.xcassets in Resources */,
|
AA000001000025 /* Assets.xcassets in Resources */,
|
||||||
|
AA000001000027 /* Localizable.xcstrings in Resources */,
|
||||||
|
AA000001000028 /* InfoPlist.xcstrings in Resources */,
|
||||||
|
AA000001000040 /* LaunchScreen.storyboard in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
AA000014000002 /* Resources (Extension) */ = {
|
AA000014000002 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
AA000001000033 /* Localizable.xcstrings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
AA000013000001 /* Sources (App) */ = {
|
AA000013000001 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -344,6 +392,8 @@
|
|||||||
AA000001000011 /* AuthService.swift in Sources */,
|
AA000001000011 /* AuthService.swift in Sources */,
|
||||||
AA000001000012 /* NotificationsAPIService.swift in Sources */,
|
AA000001000012 /* NotificationsAPIService.swift in Sources */,
|
||||||
AA000001000013 /* PushNotificationService.swift in Sources */,
|
AA000001000013 /* PushNotificationService.swift in Sources */,
|
||||||
|
AA000001000099 /* LiveActivityRegistrationService.swift in Sources */,
|
||||||
|
AA000001000100 /* DevicePlatform.swift in Sources */,
|
||||||
AA000001000014 /* AuthViewModel.swift in Sources */,
|
AA000001000014 /* AuthViewModel.swift in Sources */,
|
||||||
AA000001000015 /* NotificationsViewModel.swift in Sources */,
|
AA000001000015 /* NotificationsViewModel.swift in Sources */,
|
||||||
AA000001000016 /* SettingsViewModel.swift in Sources */,
|
AA000001000016 /* SettingsViewModel.swift in Sources */,
|
||||||
@@ -355,16 +405,22 @@
|
|||||||
AA000001000022 /* SettingsView.swift in Sources */,
|
AA000001000022 /* SettingsView.swift in Sources */,
|
||||||
AA000001000023 /* ChangePasswordView.swift in Sources */,
|
AA000001000023 /* ChangePasswordView.swift in Sources */,
|
||||||
AA000001000024 /* SessionsView.swift in Sources */,
|
AA000001000024 /* SessionsView.swift in Sources */,
|
||||||
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
AA000013000002 /* Sources (Extension) */ = {
|
AA000013000002 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */,
|
AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */,
|
||||||
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */,
|
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */,
|
||||||
AA000001000032 /* AlertAttributes.swift in Sources (Extension) */,
|
AA000001000032 /* AlertAttributes.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -379,60 +435,78 @@
|
|||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
AA000016000001 /* Debug (App) */ = {
|
AA000016000001 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Mayday/Info.plist;
|
INFOPLIST_FILE = Mayday/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Mayday;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday";
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 6.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
AA000016000002 /* Release (App) */ = {
|
AA000016000002 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Mayday/Info.plist;
|
INFOPLIST_FILE = Mayday/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Mayday;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday";
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 6.0;
|
SWIFT_VERSION = 6.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
AA000016000003 /* Debug (Extension) */ = {
|
AA000016000003 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = MaydayLiveActivity/Info.plist;
|
INFOPLIST_FILE = MaydayLiveActivity/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -442,7 +516,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday.liveactivity";
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday.liveactivity;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -451,12 +525,12 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
AA000016000004 /* Release (Extension) */ = {
|
AA000016000004 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = MaydayLiveActivity/Info.plist;
|
INFOPLIST_FILE = MaydayLiveActivity/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
@@ -466,7 +540,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday.liveactivity";
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday.liveactivity;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -475,7 +549,7 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
AA000016000005 /* Debug (Project) */ = {
|
AA000016000005 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
@@ -537,7 +611,7 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
AA000016000006 /* Release (Project) */ = {
|
AA000016000006 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
@@ -623,7 +697,6 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
};
|
};
|
||||||
rootObject = AA000004000001 /* Project object */;
|
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" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
"filename" : "AppIcon-20@2x.png",
|
||||||
"platform" : "ios",
|
"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"
|
"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
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject var authViewModel: AuthViewModel
|
@Environment(AuthViewModel.self) private var authViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if authViewModel.isAuthenticated {
|
if authViewModel.isCheckingAuth {
|
||||||
|
Color(.systemBackground)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
} else if authViewModel.isAuthenticated {
|
||||||
NotificationsView()
|
NotificationsView()
|
||||||
} else {
|
} else {
|
||||||
LoginView()
|
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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<dict>
|
<true/>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||||
<false/>
|
<true/>
|
||||||
</dict>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<key>UIBackgroundModes</key>
|
<dict>
|
||||||
<array>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
<string>remote-notification</string>
|
<false/>
|
||||||
</array>
|
</dict>
|
||||||
<key>UILaunchScreen</key>
|
<key>UIBackgroundModes</key>
|
||||||
<dict/>
|
<array>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<string>remote-notification</string>
|
||||||
<array>
|
</array>
|
||||||
<string>armv7</string>
|
<key>UILaunchStoryboardName</key>
|
||||||
</array>
|
<string>LaunchScreen</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>armv7</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
</array>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
</array>
|
<array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<array>
|
</array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>NSUserNotificationsUsageDescription</key>
|
|
||||||
<string>Mayday использует уведомления для оповещения о критических событиях.</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</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
|
@main
|
||||||
struct MaydayApp: App {
|
struct MaydayApp: App {
|
||||||
@StateObject private var authViewModel = AuthViewModel()
|
@State private var authViewModel = AuthViewModel()
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(authViewModel)
|
.environment(authViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,61 @@ struct AlertAttributes: ActivityAttributes {
|
|||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case title, value, status
|
case title, value, status, startedAt, updatedAt
|
||||||
case startedAt = "startedAt"
|
}
|
||||||
case updatedAt = "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
|
import Foundation
|
||||||
|
|
||||||
struct AppNotification: Codable, Identifiable {
|
struct AppNotification: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let topic: String
|
let userId: UUID
|
||||||
let subject: String
|
let scopeId: UUID?
|
||||||
|
let channel: NotificationChannel
|
||||||
|
let contentType: ContentType
|
||||||
|
let templateId: UUID?
|
||||||
|
let subject: String?
|
||||||
let body: String
|
let body: String
|
||||||
|
let source: String?
|
||||||
let metadata: [String: String]?
|
let metadata: [String: String]?
|
||||||
let status: NotificationStatus
|
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 readAt: Date?
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
let updatedAt: Date
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
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 readAt = "read_at"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_at"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isRead: Bool { readAt != nil }
|
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 sent
|
||||||
case delivered
|
case failed
|
||||||
case read
|
case read
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationChannel: String, Codable {
|
enum NotificationChannel: String, Codable, Sendable {
|
||||||
case inApp = "in_app"
|
|
||||||
case push
|
|
||||||
case email
|
case email
|
||||||
|
case telegram
|
||||||
|
case inApp = "in_app"
|
||||||
|
case webhook
|
||||||
|
case apns
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NotificationsPage: Codable {
|
enum ContentType: String, Codable, Sendable {
|
||||||
let items: [AppNotification]
|
case plain
|
||||||
let total: Int
|
case html
|
||||||
let page: Int
|
case markdown
|
||||||
let perPage: Int
|
}
|
||||||
|
|
||||||
|
struct NotificationsList: Decodable, Sendable {
|
||||||
|
let notifications: [AppNotification]
|
||||||
|
let unreadCount: Int
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case items, total, page
|
case notifications
|
||||||
case perPage = "per_page"
|
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
|
import Foundation
|
||||||
|
|
||||||
struct SessionResponse: Codable, Identifiable {
|
struct SessionResponse: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let userAgent: String
|
let userAgent: String
|
||||||
let ipAddress: String
|
let ipAddress: String
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct TokenPair: Codable {
|
struct TokenPair: Codable, Sendable {
|
||||||
let accessToken: String
|
let accessToken: String
|
||||||
let refreshToken: String
|
let refreshToken: String
|
||||||
let expiresAt: Date
|
let expiresAt: Date
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct UserResponse: Codable, Identifiable {
|
struct UserResponse: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let email: String
|
let email: String
|
||||||
let status: UserStatus
|
let status: UserStatus
|
||||||
@@ -16,9 +16,32 @@ struct UserResponse: Codable, Identifiable {
|
|||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_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 pending
|
||||||
case active
|
case active
|
||||||
case suspended
|
case suspended
|
||||||
@@ -26,7 +49,7 @@ enum UserStatus: String, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper for Any JSON values
|
// Helper for Any JSON values
|
||||||
struct AnyCodable: Codable {
|
struct AnyCodable: Codable, @unchecked Sendable {
|
||||||
let value: Any
|
let value: Any
|
||||||
|
|
||||||
init(_ value: Any) {
|
init(_ value: Any) {
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct LoginResponse: Decodable {
|
enum LoginStatus: String, Decodable, Sendable {
|
||||||
let user: UserResponse
|
case authenticated
|
||||||
let tokens: TokenPair
|
case mfaRequired = "mfa_required"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RegisterResponse: Decodable {
|
struct LoginResponse: Decodable, Sendable {
|
||||||
let user: UserResponse
|
let status: LoginStatus
|
||||||
|
let user: UserResponse?
|
||||||
private enum CodingKeys: String, CodingKey {
|
let tokens: TokenPair?
|
||||||
case id, email, status, metadata, roles
|
let mfaToken: String?
|
||||||
case emailVerifiedAt = "email_verified_at"
|
let methods: [String]?
|
||||||
case createdAt = "created_at"
|
|
||||||
case updatedAt = "updated_at"
|
enum CodingKeys: String, CodingKey {
|
||||||
}
|
case status, user, tokens, methods
|
||||||
|
case mfaToken = "mfa_token"
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
user = try UserResponse(from: decoder)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VerifyEmailResponse: Decodable {
|
struct MessageResponse: Decodable, Sendable {
|
||||||
let user: UserResponse
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
actor AuthService {
|
actor AuthService {
|
||||||
@@ -33,8 +31,16 @@ actor AuthService {
|
|||||||
|
|
||||||
func login(email: String, password: String) async throws -> UserResponse {
|
func login(email: String, password: String) async throws -> UserResponse {
|
||||||
let response: LoginResponse = try await client.request(.login(email: email, password: password))
|
let response: LoginResponse = try await client.request(.login(email: email, password: password))
|
||||||
try keychain.saveTokens(response.tokens)
|
switch response.status {
|
||||||
return response.user
|
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 {
|
func register(email: String, password: String) async throws -> UserResponse {
|
||||||
@@ -42,19 +48,20 @@ actor AuthService {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyEmail(email: String, code: String) async throws -> UserResponse {
|
func verifyEmail(email: String, code: String) async throws {
|
||||||
let response: VerifyEmailResponse = try await client.request(.verifyEmail(email: email, code: code))
|
let _: MessageResponse = try await client.request(.verifyEmail(email: email, code: code))
|
||||||
return response.user
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resendCode(email: String) async throws {
|
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 {
|
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 }
|
guard let refreshToken = keychain.loadRefreshToken() else { return }
|
||||||
let _: EmptyResponse = try await client.request(.logout(refreshToken: refreshToken))
|
let _: EmptyResponse = try await client.request(.logout(refreshToken: refreshToken))
|
||||||
keychain.clearTokens()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMe() async throws -> UserResponse {
|
func getMe() async throws -> UserResponse {
|
||||||
@@ -62,8 +69,4 @@ actor AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ResendCodeResponse: Decodable {
|
struct EmptyResponse: Decodable, Sendable {}
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EmptyResponse: Decodable {}
|
|
||||||
|
|||||||
@@ -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 {
|
enum APIError: Error, LocalizedError {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
case unauthorized
|
case unauthorized
|
||||||
|
case mfaRequired
|
||||||
case validationError([String: [String]])
|
case validationError([String: [String]])
|
||||||
case serverError(String)
|
case serverError(String)
|
||||||
case networkError(Error)
|
case networkError(Error)
|
||||||
@@ -10,8 +11,9 @@ enum APIError: Error, LocalizedError {
|
|||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .invalidURL: return "Invalid URL"
|
case .invalidURL: return String(localized: "error_invalid_url")
|
||||||
case .unauthorized: return "Неверный email или пароль"
|
case .unauthorized: return String(localized: "error_invalid_credentials")
|
||||||
|
case .mfaRequired: return String(localized: "error_mfa_required")
|
||||||
case .validationError(let errors):
|
case .validationError(let errors):
|
||||||
return errors.values.flatMap { $0 }.joined(separator: ", ")
|
return errors.values.flatMap { $0 }.joined(separator: ", ")
|
||||||
case .serverError(let message): return message
|
case .serverError(let message): return message
|
||||||
@@ -25,11 +27,33 @@ struct APIResponse<T: Decodable>: Decodable {
|
|||||||
let data: T
|
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 {
|
struct APIErrorResponse: Decodable {
|
||||||
let message: String
|
let message: String
|
||||||
let errors: [String: [String]]?
|
let errors: [String: [String]]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum APIService {
|
||||||
|
case sso
|
||||||
|
case notification
|
||||||
|
}
|
||||||
|
|
||||||
enum Endpoint {
|
enum Endpoint {
|
||||||
// Auth
|
// Auth
|
||||||
case login(email: String, password: String)
|
case login(email: String, password: String)
|
||||||
@@ -45,11 +69,29 @@ enum Endpoint {
|
|||||||
case logoutAll
|
case logoutAll
|
||||||
case changePassword(current: String, new: String)
|
case changePassword(current: String, new: String)
|
||||||
// Notifications
|
// Notifications
|
||||||
case getNotifications(page: Int, perPage: Int)
|
case getNotifications(limit: Int, offset: Int, unreadOnly: Bool, scope: String?)
|
||||||
case markAsRead(id: UUID)
|
case markAsRead(id: UUID)
|
||||||
|
case markAllAsRead(scope: String?)
|
||||||
// Devices
|
// Devices
|
||||||
case registerDevice(token: String)
|
case listDevices
|
||||||
case unregisterDevice(token: String)
|
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 {
|
var path: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -66,17 +108,26 @@ enum Endpoint {
|
|||||||
case .changePassword: return "/users/me/change-password"
|
case .changePassword: return "/users/me/change-password"
|
||||||
case .getNotifications: return "/notifications"
|
case .getNotifications: return "/notifications"
|
||||||
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
|
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
|
||||||
case .registerDevice: return "/devices/register"
|
case .markAllAsRead: return "/notifications/read-all"
|
||||||
case .unregisterDevice: return "/devices/unregister"
|
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 {
|
var method: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .getMe, .getSessions, .getNotifications: return "GET"
|
case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences:
|
||||||
case .deleteSession: return "DELETE"
|
return "GET"
|
||||||
case .markAsRead: return "PATCH"
|
case .deleteSession, .unregisterDevice, .unregisterDeviceByToken:
|
||||||
default: return "POST"
|
return "DELETE"
|
||||||
|
case .upsertPreference:
|
||||||
|
return "PUT"
|
||||||
|
default:
|
||||||
|
return "POST"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +156,22 @@ enum Endpoint {
|
|||||||
return ["refresh_token": token]
|
return ["refresh_token": token]
|
||||||
case .changePassword(let current, let new):
|
case .changePassword(let current, let new):
|
||||||
return ["current_password": current, "new_password": new]
|
return ["current_password": current, "new_password": new]
|
||||||
case .registerDevice(let token):
|
case .registerDevice(let token, let platform):
|
||||||
return ["token": token, "platform": "ios"]
|
return ["token": token, "platform": platform]
|
||||||
case .unregisterDevice(let token):
|
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]
|
return ["token": token]
|
||||||
case .getNotifications(let page, let perPage):
|
|
||||||
return ["page": page, "per_page": perPage]
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -120,25 +181,81 @@ enum Endpoint {
|
|||||||
actor HTTPClient {
|
actor HTTPClient {
|
||||||
static let shared = HTTPClient()
|
static let shared = HTTPClient()
|
||||||
|
|
||||||
private let baseURL: String
|
private let ssoBaseURL: String
|
||||||
|
private let notificationBaseURL: String
|
||||||
private let keychain = KeychainService.shared
|
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() {
|
private init() {
|
||||||
#if DEBUG
|
ssoBaseURL = "https://id.robonen.ru"
|
||||||
baseURL = "http://localhost:8081"
|
notificationBaseURL = "https://notify.robonen.ru"
|
||||||
#else
|
}
|
||||||
baseURL = "https://api.chemodan.example/sso"
|
|
||||||
#endif
|
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 {
|
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||||
let response: T = try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
let data = try await executeRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||||
return response
|
|
||||||
|
// 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 {
|
func requestPaginated<T: Decodable>(_ endpoint: Endpoint) async throws -> (T, Pagination) {
|
||||||
guard let url = URL(string: baseURL + endpoint.path) else {
|
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
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,8 +268,9 @@ actor HTTPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let body = endpoint.body {
|
if let body = endpoint.body {
|
||||||
if endpoint.method == "GET" {
|
// DELETE/GET don't carry a JSON body; encode params on the URL instead
|
||||||
// Append query parameters to URL for GET requests
|
// so endpoints like /devices/by-token?token=... work.
|
||||||
|
if endpoint.method == "GET" || endpoint.method == "DELETE" {
|
||||||
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
||||||
components.queryItems = body.map { key, value in
|
components.queryItems = body.map { key, value in
|
||||||
URLQueryItem(name: key, value: "\(value)")
|
URLQueryItem(name: key, value: "\(value)")
|
||||||
@@ -160,7 +278,11 @@ actor HTTPClient {
|
|||||||
urlRequest.url = components.url
|
urlRequest.url = components.url
|
||||||
}
|
}
|
||||||
} else {
|
} 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))
|
throw APIError.networkError(URLError(.badServerResponse))
|
||||||
}
|
}
|
||||||
|
|
||||||
if httpResponse.statusCode == 401 && retryOnUnauthorized && !isRefreshing {
|
if httpResponse.statusCode == 401 && retryOnUnauthorized {
|
||||||
isRefreshing = true
|
do {
|
||||||
defer { isRefreshing = false }
|
try await ensureTokenRefreshed()
|
||||||
try await refreshTokens()
|
} catch {
|
||||||
return try await performRequest(endpoint, retryOnUnauthorized: false)
|
throw APIError.unauthorized
|
||||||
|
}
|
||||||
|
return try await executeRequest(endpoint, retryOnUnauthorized: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if httpResponse.statusCode == 401 {
|
if httpResponse.statusCode == 401 {
|
||||||
@@ -200,25 +324,39 @@ actor HTTPClient {
|
|||||||
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
if httpResponse.statusCode == 204 {
|
||||||
decoder.dateDecodingStrategy = .iso8601
|
return Data()
|
||||||
do {
|
|
||||||
let wrapped = try decoder.decode(APIResponse<T>.self, from: data)
|
|
||||||
return wrapped.data
|
|
||||||
} catch {
|
|
||||||
throw APIError.decodingError(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
guard let refreshToken = keychain.loadRefreshToken() else {
|
||||||
|
keychain.clearTokens()
|
||||||
throw APIError.unauthorized
|
throw APIError.unauthorized
|
||||||
}
|
}
|
||||||
let response: TokenRefreshResponse = try await performRequest(
|
|
||||||
.refresh(refreshToken: refreshToken),
|
let task = Task<Void, Error> {
|
||||||
retryOnUnauthorized: false
|
let response: TokenRefreshResponse = try await self.request(.refresh(refreshToken: refreshToken))
|
||||||
)
|
try self.keychain.saveTokens(response.tokens)
|
||||||
try 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 Foundation
|
||||||
import UIKit
|
|
||||||
|
|
||||||
actor NotificationsAPIService {
|
actor NotificationsAPIService {
|
||||||
static let shared = NotificationsAPIService()
|
static let shared = NotificationsAPIService()
|
||||||
@@ -7,14 +6,54 @@ actor NotificationsAPIService {
|
|||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func getNotifications(page: Int = 1, perPage: Int = 20) async throws -> NotificationsPage {
|
// MARK: - Notifications
|
||||||
try await client.request(.getNotifications(page: page, perPage: perPage))
|
|
||||||
|
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 {
|
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] {
|
func getSessions() async throws -> [SessionResponse] {
|
||||||
try await client.request(.getSessions)
|
try await client.request(.getSessions)
|
||||||
}
|
}
|
||||||
@@ -31,10 +70,6 @@ actor NotificationsAPIService {
|
|||||||
func changePassword(current: String, new: String) async throws -> UserResponse {
|
func changePassword(current: String, new: String) async throws -> UserResponse {
|
||||||
try await client.request(.changePassword(current: current, new: new))
|
try await client.request(.changePassword(current: current, new: new))
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAppBadge(_ count: Int) async {
|
|
||||||
await UIApplication.shared.setApplicationIconBadgeNumber(count)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LogoutAllResponse: Decodable {
|
struct LogoutAllResponse: Decodable {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
import UIKit
|
||||||
import ActivityKit
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
|
final class PushNotificationService: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = PushNotificationService()
|
static let shared = PushNotificationService()
|
||||||
|
|
||||||
@Published var deviceToken: String?
|
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
@@ -29,95 +26,27 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleDeviceToken(_ tokenData: Data) {
|
func handleDeviceToken(_ tokenData: Data) {
|
||||||
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
let token = DeviceTokenFormatter.hex(tokenData)
|
||||||
deviceToken = token
|
|
||||||
Task {
|
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 {
|
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async {
|
||||||
guard let aps = userInfo["aps"] as? [String: Any] else { return }
|
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 {
|
if let badge = aps["badge"] as? Int {
|
||||||
await UIApplication.shared.setApplicationIconBadgeNumber(badge)
|
try? await UNUserNotificationCenter.current().setBadgeCount(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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let attrs = AlertAttributes(topic: topic, alertId: alertId, severity: severity)
|
// Live Activity start/update/end pushes (apns-push-type: liveactivity)
|
||||||
_ = try? Activity<AlertAttributes>.request(
|
// are handled entirely by the OS via Push-to-Start tokens and per-activity
|
||||||
attributes: attrs,
|
// pushTokens — they never land in this delegate. Only regular alert/badge/sound
|
||||||
content: ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600))
|
// pushes reach here, and the only thing we owe them is the badge update above.
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UNUserNotificationCenterDelegate
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
func userNotificationCenter(
|
nonisolated func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
@@ -125,7 +54,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
|||||||
completionHandler([.banner, .badge, .sound])
|
completionHandler([.banner, .badge, .sound])
|
||||||
}
|
}
|
||||||
|
|
||||||
func userNotificationCenter(
|
nonisolated func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
@@ -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 Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
class AuthViewModel: ObservableObject {
|
final class AuthViewModel {
|
||||||
@Published var isAuthenticated = false
|
var isAuthenticated = false
|
||||||
@Published var currentUser: UserResponse?
|
var isCheckingAuth = true
|
||||||
@Published var isLoading = false
|
var currentUser: UserResponse?
|
||||||
@Published var error: String?
|
var isLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
private let auth = AuthService.shared
|
private let auth = AuthService.shared
|
||||||
private let keychain = KeychainService.shared
|
private let keychain = KeychainService.shared
|
||||||
@@ -14,18 +16,23 @@ class AuthViewModel: ObservableObject {
|
|||||||
func checkAuthStatus() async {
|
func checkAuthStatus() async {
|
||||||
guard keychain.loadAccessToken() != nil else {
|
guard keychain.loadAccessToken() != nil else {
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
|
isCheckingAuth = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isLoading = true
|
|
||||||
defer { isLoading = false }
|
|
||||||
do {
|
do {
|
||||||
currentUser = try await auth.getMe()
|
currentUser = try await auth.getMe()
|
||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
|
isCheckingAuth = false
|
||||||
await requestPushIfNeeded()
|
await requestPushIfNeeded()
|
||||||
} catch APIError.unauthorized {
|
} catch APIError.unauthorized {
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
|
isCheckingAuth = false
|
||||||
} catch {
|
} 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
|
// Clear anyway
|
||||||
keychain.clearTokens()
|
keychain.clearTokens()
|
||||||
}
|
}
|
||||||
|
LiveActivityRegistrationService.shared.stop()
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
currentUser = nil
|
currentUser = nil
|
||||||
}
|
}
|
||||||
@@ -85,5 +93,6 @@ class AuthViewModel: ObservableObject {
|
|||||||
if granted {
|
if granted {
|
||||||
PushNotificationService.shared.registerForRemoteNotifications()
|
PushNotificationService.shared.registerForRemoteNotifications()
|
||||||
}
|
}
|
||||||
|
LiveActivityRegistrationService.shared.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
class NotificationsViewModel: ObservableObject {
|
final class NotificationsViewModel {
|
||||||
@Published var notifications: [AppNotification] = []
|
var notifications: [AppNotification] = []
|
||||||
@Published var isLoading = false
|
var unreadCount = 0
|
||||||
@Published var isLoadingMore = false
|
var isLoading = false
|
||||||
@Published var error: String?
|
var isLoadingMore = false
|
||||||
@Published var hasMore = true
|
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 let service = NotificationsAPIService.shared
|
||||||
private var currentPage = 1
|
private let limit = 50
|
||||||
private let perPage = 20
|
private var currentOffset = 0
|
||||||
private var pollingTask: Task<Void, Never>?
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
isLoading = true
|
isLoading = !hasLoadedOnce
|
||||||
error = nil
|
error = nil
|
||||||
currentPage = 1
|
currentOffset = 0
|
||||||
defer { isLoading = false }
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
hasLoadedOnce = true
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let page = try await service.getNotifications(page: 1, perPage: perPage)
|
let page = try await service.getNotifications(limit: limit, offset: 0)
|
||||||
notifications = page.items
|
notifications = page.notifications
|
||||||
hasMore = page.items.count == perPage
|
unreadCount = page.unreadCount
|
||||||
|
hasMore = page.hasMore
|
||||||
updateBadge()
|
updateBadge()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
@@ -34,11 +50,12 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
defer { isLoadingMore = false }
|
defer { isLoadingMore = false }
|
||||||
do {
|
do {
|
||||||
let nextPage = currentPage + 1
|
let nextOffset = notifications.count
|
||||||
let page = try await service.getNotifications(page: nextPage, perPage: perPage)
|
let page = try await service.getNotifications(limit: limit, offset: nextOffset)
|
||||||
notifications.append(contentsOf: page.items)
|
notifications.append(contentsOf: page.notifications)
|
||||||
currentPage = nextPage
|
unreadCount = page.unreadCount
|
||||||
hasMore = page.items.count == perPage
|
currentOffset = nextOffset
|
||||||
|
hasMore = page.hasMore
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -46,32 +63,43 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
|
|
||||||
func markAsRead(_ notification: AppNotification) async {
|
func markAsRead(_ notification: AppNotification) async {
|
||||||
guard !notification.isRead else { return }
|
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 {
|
do {
|
||||||
try await service.markAsRead(id: notification.id)
|
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 }) {
|
if let index = notifications.firstIndex(where: { $0.id == notification.id }) {
|
||||||
let updated = AppNotification(
|
notifications[index] = notification
|
||||||
id: notification.id,
|
unreadCount += 1
|
||||||
topic: notification.topic,
|
updateBadge()
|
||||||
subject: notification.subject,
|
|
||||||
body: notification.body,
|
|
||||||
metadata: notification.metadata,
|
|
||||||
status: .read,
|
|
||||||
channel: notification.channel,
|
|
||||||
readAt: Date(),
|
|
||||||
createdAt: notification.createdAt,
|
|
||||||
updatedAt: Date()
|
|
||||||
)
|
|
||||||
notifications[index] = updated
|
|
||||||
}
|
}
|
||||||
updateBadge()
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAllAsRead() async {
|
||||||
|
do {
|
||||||
|
try await service.markAllAsRead()
|
||||||
|
await load()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startPolling() {
|
func startPolling() {
|
||||||
// Polling always reloads page 1 to pick up new notifications.
|
guard pollingTask == nil else { return }
|
||||||
// Users who have scrolled to older pages will have the list reset on each interval.
|
|
||||||
pollingTask = Task {
|
pollingTask = Task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .seconds(30))
|
try? await Task.sleep(for: .seconds(30))
|
||||||
@@ -87,9 +115,6 @@ class NotificationsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateBadge() {
|
private func updateBadge() {
|
||||||
let unreadCount = notifications.filter { !$0.isRead }.count
|
UNUserNotificationCenter.current().setBadgeCount(unreadCount)
|
||||||
Task {
|
|
||||||
await service.updateAppBadge(unreadCount)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
class SettingsViewModel: ObservableObject {
|
final class SettingsViewModel {
|
||||||
@Published var sessions: [SessionResponse] = []
|
var sessions: [SessionResponse] = []
|
||||||
@Published var isLoading = false
|
var isLoading = false
|
||||||
@Published var error: String?
|
var error: String?
|
||||||
@Published var successMessage: String?
|
var successMessage: String?
|
||||||
|
|
||||||
private let service = NotificationsAPIService.shared
|
private let service = NotificationsAPIService.shared
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ class SettingsViewModel: ObservableObject {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
_ = try await service.changePassword(current: current, new: new)
|
_ = try await service.changePassword(current: current, new: new)
|
||||||
successMessage = "Пароль успешно изменён"
|
successMessage = String(localized: "password_changed_success")
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|||||||
@@ -1,71 +1,114 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@EnvironmentObject var authViewModel: AuthViewModel
|
@Environment(AuthViewModel.self) private var authViewModel
|
||||||
@State private var email = ""
|
@State private var email = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@State private var showRegister = false
|
@State private var showRegister = false
|
||||||
|
|
||||||
|
private var isFormInvalid: Bool {
|
||||||
|
email.isEmpty || password.isEmpty || authViewModel.isLoading
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 24) {
|
ZStack {
|
||||||
Spacer()
|
ScrollView {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 24) {
|
||||||
Image(systemName: "bell.badge.fill")
|
Spacer(minLength: 24)
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundStyle(.red)
|
VStack(spacing: 10) {
|
||||||
Text("Mayday")
|
Image("Logo")
|
||||||
.font(.largeTitle.bold())
|
.resizable()
|
||||||
Text("Мониторинг и уведомления")
|
.scaledToFit()
|
||||||
.font(.subheadline)
|
.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)
|
.foregroundStyle(.secondary)
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
Spacer(minLength: 8)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
.cardContainer()
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(email.isEmpty || password.isEmpty || authViewModel.isLoading)
|
|
||||||
|
|
||||||
Button("Нет аккаунта? Зарегистрироваться") {
|
|
||||||
showRegister = true
|
|
||||||
}
|
|
||||||
.font(.footnote)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.padding()
|
.appBackground()
|
||||||
.navigationDestination(isPresented: $showRegister) {
|
.navigationDestination(isPresented: $showRegister) {
|
||||||
RegisterView()
|
RegisterView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LoginView()
|
||||||
|
.environment(AuthViewModel())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RegisterView: View {
|
struct RegisterView: View {
|
||||||
@EnvironmentObject var authViewModel: AuthViewModel
|
@Environment(AuthViewModel.self) private var authViewModel
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var email = ""
|
@State private var email = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@@ -10,77 +10,111 @@ struct RegisterView: View {
|
|||||||
@State private var showVerify = false
|
@State private var showVerify = false
|
||||||
@State private var registeredEmail = ""
|
@State private var registeredEmail = ""
|
||||||
|
|
||||||
|
private var isFormInvalid: Bool {
|
||||||
|
!isFormValid || authViewModel.isLoading
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 24) {
|
ZStack {
|
||||||
Spacer()
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer(minLength: 24)
|
||||||
|
|
||||||
Text("Регистрация")
|
VStack(spacing: 10) {
|
||||||
.font(.largeTitle.bold())
|
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) {
|
Text("register_title")
|
||||||
TextField("Email", text: $email)
|
.font(.largeTitle.bold())
|
||||||
.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: {
|
.cardContainer()
|
||||||
if authViewModel.isLoading {
|
}
|
||||||
ProgressView().frame(maxWidth: .infinity)
|
.navigationDestination(isPresented: $showVerify) {
|
||||||
} else {
|
VerifyEmailView(email: registeredEmail, password: password)
|
||||||
Text("Создать аккаунт").frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(!isFormValid || authViewModel.isLoading)
|
|
||||||
|
|
||||||
Button("Уже есть аккаунт?") { dismiss() }
|
|
||||||
.font(.footnote)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.padding()
|
.appBackground()
|
||||||
.navigationDestination(isPresented: $showVerify) {
|
.navigationTitle("register_title")
|
||||||
VerifyEmailView(email: registeredEmail)
|
|
||||||
}
|
|
||||||
.navigationTitle("Регистрация")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,3 +122,10 @@ struct RegisterView: View {
|
|||||||
!email.isEmpty && password.count >= 8 && password == confirmPassword
|
!email.isEmpty && password.count >= 8 && password == confirmPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
RegisterView()
|
||||||
|
}
|
||||||
|
.environment(AuthViewModel())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,118 +2,196 @@ import SwiftUI
|
|||||||
|
|
||||||
struct VerifyEmailView: View {
|
struct VerifyEmailView: View {
|
||||||
let email: String
|
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 codeDigits: [String] = Array(repeating: "", count: 6)
|
||||||
@State private var resendCooldown = 0
|
@State private var resendCooldown = 0
|
||||||
@FocusState private var focusedIndex: Int?
|
@State private var focusedIndex: Int?
|
||||||
@State private var resendTimer: Timer?
|
@State private var cooldownTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private var code: String {
|
||||||
|
codeDigits.joined()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 32) {
|
ScrollView {
|
||||||
Spacer()
|
contentCard
|
||||||
|
}
|
||||||
VStack(spacing: 8) {
|
.appBackground()
|
||||||
Text("Подтвердите email")
|
.navigationTitle("verify_nav_title")
|
||||||
.font(.largeTitle.bold())
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
Text("Код отправлен на")
|
.onAppear { focusedIndex = 0 }
|
||||||
.foregroundStyle(.secondary)
|
.onDisappear { cooldownTask?.cancel() }
|
||||||
Text(email)
|
.onChange(of: code) { _, newValue in
|
||||||
.fontWeight(.semibold)
|
if newValue.count == 6 {
|
||||||
|
Task { await submitCode(newValue) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
ForEach(0..<6, id: \.self) { index in
|
private var contentCard: some View {
|
||||||
TextField("", text: $codeDigits[index])
|
VStack(spacing: 28) {
|
||||||
.frame(width: 44, height: 52)
|
Spacer(minLength: 24)
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.font(.title2.bold())
|
headerView
|
||||||
.keyboardType(.numberPad)
|
otpFieldsView
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = authViewModel.error {
|
if let error = authViewModel.error {
|
||||||
Text(error)
|
Text(error)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.brand)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
if code.count == 6 {
|
||||||
Task { await resendCode() }
|
ProgressView()
|
||||||
} label: {
|
.padding(.top, 2)
|
||||||
if resendCooldown > 0 {
|
|
||||||
Text("Отправить повторно (\(resendCooldown) сек)")
|
|
||||||
} else {
|
|
||||||
Text("Отправить повторно")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.disabled(resendCooldown > 0)
|
|
||||||
|
|
||||||
Spacer()
|
resendButton
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
}
|
}
|
||||||
.padding()
|
.cardContainer()
|
||||||
.navigationTitle("Подтверждение")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.onAppear { focusedIndex = 0 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleDigitChange(index: Int, value: String) {
|
private var headerView: some View {
|
||||||
let filtered = value.filter { $0.isNumber }
|
VStack(spacing: 8) {
|
||||||
if filtered.count > 1 {
|
Image("Logo")
|
||||||
// Paste handling
|
.resizable()
|
||||||
let digits = Array(filtered.prefix(6))
|
.scaledToFit()
|
||||||
for (i, d) in digits.enumerated() where i < 6 {
|
.frame(width: 72, height: 72)
|
||||||
codeDigits[i] = String(d)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
}
|
.shadow(color: .brand.opacity(0.22), radius: 12, y: 6)
|
||||||
focusedIndex = min(digits.count, 5)
|
|
||||||
} else {
|
|
||||||
codeDigits[index] = filtered.isEmpty ? "" : String(filtered.last!)
|
|
||||||
if !filtered.isEmpty && index < 5 {
|
|
||||||
focusedIndex = index + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let code = codeDigits.joined()
|
Text("verify_email_title")
|
||||||
if code.count == 6 {
|
.font(.largeTitle.bold())
|
||||||
Task { await submitCode(code) }
|
|
||||||
|
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 {
|
private func submitCode(_ code: String) async {
|
||||||
await authViewModel.verifyEmail(email: email, code: code)
|
await authViewModel.verifyEmail(email: email, code: code)
|
||||||
if authViewModel.error == nil {
|
if authViewModel.error == nil {
|
||||||
// Auto-login after verification - in a real flow we'd re-login here
|
await authViewModel.login(email: email, password: password)
|
||||||
// since verify doesn't return tokens
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resendCode() async {
|
private func resendCode() async {
|
||||||
do {
|
do {
|
||||||
try await AuthService.shared.resendCode(email: email)
|
try await AuthService.shared.resendCode(email: email)
|
||||||
resendCooldown = 60
|
startCooldown()
|
||||||
startCooldownTimer()
|
|
||||||
} catch {
|
} catch {
|
||||||
authViewModel.error = error.localizedDescription
|
authViewModel.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startCooldownTimer() {
|
private func startCooldown() {
|
||||||
resendTimer?.invalidate()
|
cooldownTask?.cancel()
|
||||||
resendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
cooldownTask = Task {
|
||||||
if resendCooldown > 0 {
|
for remaining in stride(from: 60, through: 1, by: -1) {
|
||||||
resendCooldown -= 1
|
guard !Task.isCancelled else { return }
|
||||||
} else {
|
resendCooldown = remaining
|
||||||
resendTimer?.invalidate()
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct NotificationDetailView: View {
|
struct NotificationDetailView: View {
|
||||||
let notification: AppNotification
|
let notificationId: UUID
|
||||||
let viewModel: NotificationsViewModel
|
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 {
|
var body: some View {
|
||||||
ScrollView {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
if let notification {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
scrollContent(notification)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Уведомление")
|
.background(Color(.systemGroupedBackground))
|
||||||
|
.navigationTitle("details_section")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.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
|
import SwiftUI
|
||||||
|
|
||||||
struct NotificationsView: View {
|
struct NotificationsView: View {
|
||||||
@EnvironmentObject var authViewModel: AuthViewModel
|
@Environment(AuthViewModel.self) private var authViewModel
|
||||||
@StateObject private var viewModel = NotificationsViewModel()
|
@State private var viewModel = NotificationsViewModel()
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -12,33 +12,36 @@ struct NotificationsView: View {
|
|||||||
ProgressView()
|
ProgressView()
|
||||||
} else if let error = viewModel.error, viewModel.notifications.isEmpty {
|
} else if let error = viewModel.error, viewModel.notifications.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Ошибка загрузки",
|
"loading_error",
|
||||||
systemImage: "exclamationmark.triangle",
|
systemImage: "exclamationmark.triangle",
|
||||||
description: Text(error)
|
description: Text(error)
|
||||||
)
|
)
|
||||||
} else if viewModel.notifications.isEmpty {
|
} else if viewModel.notifications.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Нет уведомлений",
|
"no_notifications",
|
||||||
systemImage: "bell.slash",
|
systemImage: "bell.slash",
|
||||||
description: Text("Новые уведомления появятся здесь")
|
description: Text("no_notifications_description")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
notificationsList
|
notificationsList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Уведомления")
|
.background(Color(.systemGroupedBackground))
|
||||||
|
.navigationTitle("notifications_title")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showSettings = true
|
showSettings = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gear")
|
Image(systemName: "gearshape.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(authViewModel)
|
.environment(authViewModel)
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
@@ -53,72 +56,220 @@ struct NotificationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var notificationsList: some View {
|
private var notificationsList: some View {
|
||||||
List {
|
ScrollView {
|
||||||
ForEach(viewModel.notifications) { notification in
|
LazyVStack(spacing: 0) {
|
||||||
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
|
if !viewModel.unreadNotifications.isEmpty {
|
||||||
NotificationRowView(notification: notification)
|
sectionHeader(String(localized: "notifications_active"))
|
||||||
}
|
ForEach(viewModel.unreadNotifications) { notification in
|
||||||
.swipeActions(edge: .leading) {
|
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
|
||||||
if !notification.isRead {
|
ActiveNotificationCard(notification: notification)
|
||||||
Button {
|
}
|
||||||
Task { await viewModel.markAsRead(notification) }
|
.id("\(notification.id)-\(notification.isRead)")
|
||||||
} label: {
|
.buttonStyle(.plain)
|
||||||
Label("Прочитано", systemImage: "checkmark")
|
.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 {
|
if !viewModel.readNotifications.isEmpty {
|
||||||
HStack {
|
sectionHeader(String(localized: "notifications_completed"))
|
||||||
Spacer()
|
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()
|
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
|
let notification: AppNotification
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Circle()
|
HStack(alignment: .top) {
|
||||||
.fill(notification.isRead ? Color.clear : Color.blue)
|
NotificationIconView(severity: NotificationSeverity(from: notification.metadata), isActive: true)
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(notification.topic)
|
Text(notification.subject ?? "")
|
||||||
.font(.footnote)
|
.font(.headline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.white)
|
||||||
Text(notification.subject)
|
if let source = notification.source {
|
||||||
.font(.body)
|
Text(source)
|
||||||
.fontWeight(notification.isRead ? .regular : .semibold)
|
.font(.subheadline)
|
||||||
Text(notification.createdAt.relativeFormatted)
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(notification.createdAt, style: .relative)
|
||||||
.font(.caption)
|
.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 {
|
// MARK: - Resolved (Read) Card
|
||||||
var relativeFormatted: String {
|
|
||||||
let formatter = RelativeDateTimeFormatter()
|
struct ResolvedNotificationCard: View {
|
||||||
formatter.locale = Locale(identifier: "ru_RU")
|
let notification: AppNotification
|
||||||
return formatter.localizedString(for: self, relativeTo: Date())
|
|
||||||
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct ChangePasswordView: View {
|
struct ChangePasswordView: View {
|
||||||
@StateObject private var viewModel = SettingsViewModel()
|
var viewModel: SettingsViewModel
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var currentPassword = ""
|
@State private var currentPassword = ""
|
||||||
@State private var newPassword = ""
|
@State private var newPassword = ""
|
||||||
@State private var confirmPassword = ""
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
SecureField("Текущий пароль", text: $currentPassword)
|
VStack(spacing: 12) {
|
||||||
|
AppSecureField(
|
||||||
|
title: "current_password",
|
||||||
|
icon: "lock.fill",
|
||||||
|
text: $currentPassword
|
||||||
|
)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
SecureField("Новый пароль", text: $newPassword)
|
|
||||||
|
AppSecureField(
|
||||||
|
title: "new_password",
|
||||||
|
icon: "key.fill",
|
||||||
|
text: $newPassword
|
||||||
|
)
|
||||||
.textContentType(.newPassword)
|
.textContentType(.newPassword)
|
||||||
SecureField("Подтвердите новый пароль", text: $confirmPassword)
|
|
||||||
|
AppSecureField(
|
||||||
|
title: "confirm_new_password",
|
||||||
|
icon: "lock.rotation",
|
||||||
|
text: $confirmPassword
|
||||||
|
)
|
||||||
.textContentType(.newPassword)
|
.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 {
|
if let error = viewModel.error {
|
||||||
Section {
|
Section {
|
||||||
Text(error).foregroundStyle(.red)
|
Text(error)
|
||||||
|
.foregroundStyle(.brand)
|
||||||
|
.font(.footnote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let success = viewModel.successMessage {
|
if let success = viewModel.successMessage {
|
||||||
Section {
|
Section {
|
||||||
Text(success).foregroundStyle(.green)
|
Text(success)
|
||||||
|
.foregroundStyle(.success)
|
||||||
|
.font(.footnote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("Сохранить") {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
let success = await viewModel.changePassword(current: currentPassword, new: newPassword)
|
let success = await viewModel.changePassword(current: currentPassword, new: newPassword)
|
||||||
if success { dismiss() }
|
if success { dismiss() }
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text("save_button")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(!isFormValid || viewModel.isLoading)
|
.disabled(isFormInvalid)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Сменить пароль")
|
.navigationTitle("change_password_title")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct SessionsView: View {
|
struct SessionsView: View {
|
||||||
@EnvironmentObject var viewModel: SettingsViewModel
|
var viewModel: SettingsViewModel
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -14,19 +14,19 @@ struct SessionsView: View {
|
|||||||
.font(.body)
|
.font(.body)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if session.isCurrent {
|
if session.isCurrent {
|
||||||
Text("Текущая")
|
Text("current_session")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(Color.green.opacity(0.2))
|
.background(Color.success.opacity(0.2))
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.success)
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(session.ipAddress)
|
Text(session.ipAddress)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text("Создана: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))")
|
Text("session_created \(session.createdAt.formatted(date: .abbreviated, time: .shortened))")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -35,19 +35,23 @@ struct SessionsView: View {
|
|||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
Task { await viewModel.deleteSession(session) }
|
Task { await viewModel.deleteSession(session) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Удалить", systemImage: "trash")
|
Label("delete_button", systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Активные сессии")
|
.navigationTitle("active_sessions_title")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button("Готово") { dismiss() }
|
Button("done_button") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SessionsView(viewModel: SettingsViewModel())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,40 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var authViewModel: AuthViewModel
|
@Environment(AuthViewModel.self) private var authViewModel
|
||||||
@StateObject private var viewModel = SettingsViewModel()
|
@State private var viewModel = SettingsViewModel()
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showChangePassword = false
|
@State private var showChangePassword = false
|
||||||
@State private var showSessions = false
|
@State private var showSessions = false
|
||||||
@State private var showLogoutAllConfirm = false
|
@State private var showLogoutAllConfirm = false
|
||||||
|
@State private var logoutAllError: String?
|
||||||
|
@State private var showLogoutAllError = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section("Аккаунт") {
|
Section("account_section") {
|
||||||
if let user = authViewModel.currentUser {
|
if let user = authViewModel.currentUser {
|
||||||
LabeledContent("Email", value: user.email)
|
LabeledContent("Email", value: user.email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("Сменить пароль") {
|
Button {
|
||||||
showChangePassword = true
|
showChangePassword = true
|
||||||
|
} label: {
|
||||||
|
Text("change_password")
|
||||||
}
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
|
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Push-уведомления", systemImage: "bell.badge")
|
Label("push_notifications", systemImage: "bell.badge")
|
||||||
.foregroundStyle(.primary)
|
|
||||||
}
|
}
|
||||||
|
.tint(.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -37,7 +42,7 @@ struct SettingsView: View {
|
|||||||
showSessions = true
|
showSessions = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Активные сессии")
|
Text("active_sessions")
|
||||||
Spacer()
|
Spacer()
|
||||||
if !viewModel.sessions.isEmpty {
|
if !viewModel.sessions.isEmpty {
|
||||||
Text("(\(viewModel.sessions.count))")
|
Text("(\(viewModel.sessions.count))")
|
||||||
@@ -47,45 +52,54 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.primary)
|
.tint(.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("Выйти из аккаунта", role: .destructive) {
|
Button("logout_button", role: .destructive) {
|
||||||
Task { await authViewModel.logout() }
|
Task { await authViewModel.logout() }
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Выйти на всех устройствах", role: .destructive) {
|
Button("logout_all_button", role: .destructive) {
|
||||||
showLogoutAllConfirm = true
|
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)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button("Готово") { dismiss() }
|
Button("done_button") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showChangePassword) {
|
.sheet(isPresented: $showChangePassword) {
|
||||||
ChangePasswordView()
|
ChangePasswordView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSessions) {
|
.sheet(isPresented: $showSessions) {
|
||||||
SessionsView()
|
SessionsView(viewModel: viewModel)
|
||||||
.environmentObject(viewModel)
|
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.alert("error_title", isPresented: $showLogoutAllError) {
|
||||||
"Выйти на всех устройствах?",
|
Button("OK") { logoutAllError = nil }
|
||||||
isPresented: $showLogoutAllConfirm,
|
} message: {
|
||||||
titleVisibility: .visible
|
Text(logoutAllError ?? "")
|
||||||
) {
|
|
||||||
Button("Выйти везде", role: .destructive) {
|
|
||||||
Task {
|
|
||||||
_ = try? await NotificationsAPIService.shared.logoutAll()
|
|
||||||
await authViewModel.logout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button("Отмена", role: .cancel) {}
|
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadSessions()
|
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 ActivityKit
|
||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
struct MaydayLiveActivityLiveActivity: Widget {
|
struct MaydayLiveActivityLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: AlertAttributes.self) { context in
|
ActivityConfiguration(for: AlertAttributes.self) { context in
|
||||||
// Lock Screen / Notification Center
|
// MARK: - Lock Screen / Banner / StandBy
|
||||||
lockScreenView(context: context)
|
|
||||||
.activityBackgroundTint(severityColor(context.attributes.severity).opacity(0.15))
|
HStack(spacing: 12) {
|
||||||
.activitySystemActionForegroundColor(.primary)
|
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: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
|
// MARK: - Expanded
|
||||||
|
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
Label(context.attributes.topic, systemImage: "exclamationmark.triangle.fill")
|
HStack(spacing: 5) {
|
||||||
.font(.caption)
|
Image(systemName: severityIcon(context.attributes.severity))
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.font(.subheadline)
|
||||||
}
|
.fontWeight(.semibold)
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
|
||||||
if let value = context.state.value {
|
|
||||||
Text(value)
|
|
||||||
.font(.caption.bold())
|
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.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 {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text(context.state.startedAt, style: .relative)
|
||||||
Text(context.state.title)
|
.font(.caption)
|
||||||
.font(.subheadline.bold())
|
.fontWeight(.medium)
|
||||||
Text("Начало: \(context.state.startedAt.formatted(date: .omitted, time: .shortened))")
|
.monospacedDigit()
|
||||||
.font(.caption2)
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
.foregroundStyle(.secondary)
|
.contentTransition(.numericText(countsDown: false))
|
||||||
}
|
.lineLimit(1)
|
||||||
Spacer()
|
.multilineTextAlignment(.trailing)
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
.padding(.trailing, 4)
|
||||||
statusBadge(context.state.status)
|
}
|
||||||
Text("Длит.: \(duration(from: context.state.startedAt))")
|
|
||||||
.font(.caption2)
|
DynamicIslandExpandedRegion(.bottom, priority: 1) {
|
||||||
.foregroundStyle(.secondary)
|
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: {
|
} compactLeading: {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
// MARK: - Compact
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
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: {
|
} compactTrailing: {
|
||||||
let shortTopic = context.attributes.topic.components(separatedBy: "/").last ?? context.attributes.topic
|
Text(context.state.startedAt, style: .timer)
|
||||||
let valueText = context.state.value.map { " · \($0)" } ?? ""
|
|
||||||
Text("\(shortTopic)\(valueText)")
|
|
||||||
.font(.caption2)
|
.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: {
|
} minimal: {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: severityIcon(context.attributes.severity))
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.bold)
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
.foregroundStyle(severityColor(context.attributes.severity))
|
||||||
}
|
}
|
||||||
.keylineTint(severityColor(context.attributes.severity))
|
.keylineTint(severityColor(context.attributes.severity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Helpers
|
||||||
func lockScreenView(context: ActivityViewContext<AlertAttributes>) -> some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(severityColor(context.attributes.severity))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
private func severityColor(_ severity: Severity) -> Color {
|
||||||
Text(context.attributes.topic)
|
switch severity {
|
||||||
.font(.caption)
|
case .critical: .red
|
||||||
.foregroundStyle(.secondary)
|
case .warning: .orange
|
||||||
Text(context.state.title)
|
case .info: .cyan
|
||||||
.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)м"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Date {
|
private func severityIcon(_ severity: Severity) -> String {
|
||||||
var relativeFormatted: String {
|
switch severity {
|
||||||
let formatter = RelativeDateTimeFormatter()
|
case .critical: "exclamationmark.triangle.fill"
|
||||||
formatter.locale = Locale(identifier: "ru_RU")
|
case .warning: "exclamationmark.circle.fill"
|
||||||
return formatter.localizedString(for: self, relativeTo: Date())
|
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
|
<p align="center">
|
||||||
The app you hope you never get a notification from
|
<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
|
||||||
|
|||||||