Compare commits

..

10 Commits

Author SHA1 Message Date
robonen 802d32e9a0 feat: implement Live Activity registration service and enhance notifications handling 2026-05-24 23:53:40 +07:00
robonen d991d06f17 refactor: add color assets and update UI components 2026-03-16 16:36:21 +07:00
robonen 37b87ececd refactor: notifications and settings view models; enhance login and registration UI 2026-03-15 21:40:20 +07:00
robonen 0947c048c1 feat: enhance notification handling with improved data structure and API integration 2026-03-15 06:13:22 +07:00
robonen 7675f66488 refactor: update Notification and Live Activity Views for Improved UI Consistency 2026-03-14 21:03:16 +07:00
robonen 8a15572fb9 featL add localization for various UI strings and error messages 2026-03-14 17:46:00 +07:00
robonen 758f5ec05f feat: enhance models and services with Sendable conformance, add preview data for debugging 2026-03-14 07:18:35 +07:00
robonen a4b475b13f Merge pull request #1 from robonen/copilot/add-push-notifications-support
Add complete Mayday iOS app: auth, push notifications, and Live Activity
2026-03-14 06:33:40 +07:00
copilot-swe-agent[bot] 597787a6c9 fix: address all PR review comments
- HTTPClient: replace isRefreshing bool with shared Task to safely
  coalesce concurrent 401 refresh attempts; surface JSON serialization
  error instead of silently dropping request body
- AuthService.logout: always clear Keychain tokens via defer, even when
  refresh token is absent, preventing stale access token
- NotificationsAPIService: remove updateAppBadge (UIKit call moved to
  @MainActor NotificationsViewModel); drop unused UIKit import
- NotificationsViewModel: guard startPolling() against duplicate tasks;
  update badge directly on @MainActor instead of hopping to actor
- VerifyEmailView: replace Timer (never invalidated) with async Task
  cancelled in .onDisappear
- NotificationsView: use Text(date, style: .relative) — auto-updates
  without custom formatter; remove duplicate Date extension
- SettingsView: handle logoutAll errors explicitly with alert instead of
  silently proceeding with local logout
- MaydayLiveActivity/Info.plist: add NSExtensionPrincipalClass so the
  widget extension is discoverable by the system
- Live Activity widget: replace frozen duration(from:) with
  Text(date, style: .timer); replace frozen relativeFormatted with
  Text(date, style: .relative); localize status badge to Russian

Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-03-13 23:29:19 +00:00
copilot-swe-agent[bot] 9259a3693a perf: cache RelativeDateTimeFormatter; add .DS_Store to .gitignore
Co-authored-by: robonen <26167508+robonen@users.noreply.github.com>
2026-03-13 23:08:08 +00:00
65 changed files with 3896 additions and 799 deletions
+4
View File
@@ -8,5 +8,9 @@ DerivedData/
*.ipa *.ipa
*.dSYM.zip *.dSYM.zip
*.dSYM *.dSYM
# Swift Package Manager
.build/ .build/
.swiftpm/ .swiftpm/
# macOS
.DS_Store
+109 -36
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

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
}
}
+21
View File
@@ -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
}
}
Binary file not shown.

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
}
}
+10 -2
View File
@@ -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())
}
+41 -48
View File
@@ -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>
+35
View File
@@ -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"
}
+42
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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 -2
View File
@@ -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)
} }
} }
} }
+55 -3
View File
@@ -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)")
} }
} }
} }
+86 -19
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+26 -3
View File
@@ -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) {
+31 -28
View File
@@ -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?
let tokens: TokenPair?
let mfaToken: String?
let methods: [String]?
private enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, email, status, metadata, roles case status, user, tokens, methods
case emailVerifiedAt = "email_verified_at" case mfaToken = "mfa_token"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
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 {}
+25
View File
@@ -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()
}
}
+186 -48
View File
@@ -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))
}
}
+43 -8
View File
@@ -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 {
+10 -88
View File
@@ -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
}()
}
+17 -8
View File
@@ -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()
} }
} }
+64 -39
View File
@@ -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)
}
} }
} }
+7 -6
View File
@@ -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
+94 -51
View File
@@ -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())
}
+107 -66
View File
@@ -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())
}
+153 -75
View File
@@ -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)
} }
} }
+68 -16
View File
@@ -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())
} }
+13 -9
View File
@@ -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())
}
+47 -28
View File
@@ -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())
}
+20
View File
@@ -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())
}
}
+15
View File
@@ -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 }
}
+30
View File
@@ -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()
}
+30
View File
@@ -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()
}
+22
View File
@@ -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()
}
+100
View File
@@ -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
)
}
+46 -2
View File
@@ -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