feat: add complete Mayday iOS Xcode project

- Swift 6, SwiftUI, MVVM + async/await architecture
- iOS 17.0 minimum deployment target
- Two targets: Mayday app + MaydayLiveActivity widget extension
- Models: UserResponse, TokenPair, AppNotification, SessionResponse, AlertAttributes
- Services: HTTPClient (actor), AuthService, KeychainService, NotificationsAPIService, PushNotificationService
- ViewModels: AuthViewModel, NotificationsViewModel, SettingsViewModel
- Views: Login/Register/VerifyEmail, NotificationsList/Detail, Settings/ChangePassword/Sessions
- APNs push notifications with UIApplicationDelegate
- ActivityKit Live Activities for Dynamic Island + Lock Screen
- Keychain (Security framework) token storage
- 30-second polling with pagination for notifications
- Xcode project file (project.pbxproj) with correct build phases for both targets

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-13 23:04:35 +00:00
parent 0bb4d89a09
commit 1eb21c71ce
33 changed files with 2605 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# Xcode
*.xcuserstate
xcuserdata/
*.xccheckout
*.moved-aside
DerivedData/
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
.build/
.swiftpm/
+629
View File
@@ -0,0 +1,629 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
AA000001000001 /* MaydayApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000001 /* MaydayApp.swift */; };
AA000001000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000002 /* ContentView.swift */; };
AA000001000003 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000003 /* AppDelegate.swift */; };
AA000001000004 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000004 /* User.swift */; };
AA000001000005 /* TokenPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000005 /* TokenPair.swift */; };
AA000001000006 /* AppNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000006 /* AppNotification.swift */; };
AA000001000007 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000007 /* Session.swift */; };
AA000001000008 /* AlertAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000008 /* AlertAttributes.swift */; };
AA000001000009 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000009 /* KeychainService.swift */; };
AA000001000010 /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000010 /* HTTPClient.swift */; };
AA000001000011 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000011 /* AuthService.swift */; };
AA000001000012 /* NotificationsAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000012 /* NotificationsAPIService.swift */; };
AA000001000013 /* PushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000013 /* PushNotificationService.swift */; };
AA000001000014 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000014 /* AuthViewModel.swift */; };
AA000001000015 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000015 /* NotificationsViewModel.swift */; };
AA000001000016 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000016 /* SettingsViewModel.swift */; };
AA000001000017 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000017 /* LoginView.swift */; };
AA000001000018 /* RegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000018 /* RegisterView.swift */; };
AA000001000019 /* VerifyEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000019 /* VerifyEmailView.swift */; };
AA000001000020 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000020 /* NotificationsView.swift */; };
AA000001000021 /* NotificationDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000021 /* NotificationDetailView.swift */; };
AA000001000022 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000022 /* SettingsView.swift */; };
AA000001000023 /* ChangePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000023 /* ChangePasswordView.swift */; };
AA000001000024 /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000024 /* SessionsView.swift */; };
AA000001000025 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA000002000025 /* Assets.xcassets */; };
AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000030 /* MaydayLiveActivityBundle.swift */; };
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000031 /* MaydayLiveActivityLiveActivity.swift */; };
AA000001000032 /* AlertAttributes.swift in Sources (Extension) */ = {isa = PBXBuildFile; fileRef = AA000002000008 /* AlertAttributes.swift */; };
AA000007000001 /* MaydayLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA000008000001 /* MaydayLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
AA000003000001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA000004000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA000005000002;
remoteInfo = MaydayLiveActivity;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
AA000006000001 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
AA000007000001 /* MaydayLiveActivity.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
AA000002000001 /* MaydayApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayApp.swift; sourceTree = "<group>"; };
AA000002000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
AA000002000003 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AA000002000004 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
AA000002000005 /* TokenPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenPair.swift; sourceTree = "<group>"; };
AA000002000006 /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.swift; sourceTree = "<group>"; };
AA000002000007 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
AA000002000008 /* AlertAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertAttributes.swift; sourceTree = "<group>"; };
AA000002000009 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; };
AA000002000010 /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.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>"; };
AA000002000013 /* PushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationService.swift; sourceTree = "<group>"; };
AA000002000014 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = "<group>"; };
AA000002000015 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = "<group>"; };
AA000002000016 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
AA000002000017 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
AA000002000018 /* RegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterView.swift; sourceTree = "<group>"; };
AA000002000019 /* VerifyEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyEmailView.swift; sourceTree = "<group>"; };
AA000002000020 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = "<group>"; };
AA000002000021 /* NotificationDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDetailView.swift; sourceTree = "<group>"; };
AA000002000022 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
AA000002000023 /* ChangePasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePasswordView.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>"; };
AA000002000026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AA000002000030 /* MaydayLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityBundle.swift; sourceTree = "<group>"; };
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaydayLiveActivityLiveActivity.swift; sourceTree = "<group>"; };
AA000002000033 /* Info.plist (Extension) */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AA000008000001 /* MaydayLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MaydayLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
AA000009000001 /* Mayday.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mayday.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
AA000010000001 /* Frameworks (App) */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA000010000002 /* Frameworks (Extension) */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AA000011000001 /* Root */ = {
isa = PBXGroup;
children = (
AA000011000002 /* Mayday */,
AA000011000010 /* MaydayLiveActivity */,
AA000011000099 /* Products */,
);
sourceTree = "<group>";
};
AA000011000099 /* Products */ = {
isa = PBXGroup;
children = (
AA000009000001 /* Mayday.app */,
AA000008000001 /* MaydayLiveActivity.appex */,
);
name = Products;
sourceTree = "<group>";
};
AA000011000002 /* Mayday */ = {
isa = PBXGroup;
children = (
AA000002000001 /* MaydayApp.swift */,
AA000002000002 /* ContentView.swift */,
AA000002000003 /* AppDelegate.swift */,
AA000002000025 /* Assets.xcassets */,
AA000002000026 /* Info.plist */,
AA000011000003 /* Models */,
AA000011000004 /* Services */,
AA000011000005 /* ViewModels */,
AA000011000006 /* Views */,
);
path = Mayday;
sourceTree = "<group>";
};
AA000011000003 /* Models */ = {
isa = PBXGroup;
children = (
AA000002000004 /* User.swift */,
AA000002000005 /* TokenPair.swift */,
AA000002000006 /* AppNotification.swift */,
AA000002000007 /* Session.swift */,
AA000002000008 /* AlertAttributes.swift */,
);
path = Models;
sourceTree = "<group>";
};
AA000011000004 /* Services */ = {
isa = PBXGroup;
children = (
AA000002000009 /* KeychainService.swift */,
AA000002000010 /* HTTPClient.swift */,
AA000002000011 /* AuthService.swift */,
AA000002000012 /* NotificationsAPIService.swift */,
AA000002000013 /* PushNotificationService.swift */,
);
path = Services;
sourceTree = "<group>";
};
AA000011000005 /* ViewModels */ = {
isa = PBXGroup;
children = (
AA000002000014 /* AuthViewModel.swift */,
AA000002000015 /* NotificationsViewModel.swift */,
AA000002000016 /* SettingsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
AA000011000006 /* Views */ = {
isa = PBXGroup;
children = (
AA000011000007 /* Auth */,
AA000011000008 /* Notifications */,
AA000011000009 /* Settings */,
);
path = Views;
sourceTree = "<group>";
};
AA000011000007 /* Auth */ = {
isa = PBXGroup;
children = (
AA000002000017 /* LoginView.swift */,
AA000002000018 /* RegisterView.swift */,
AA000002000019 /* VerifyEmailView.swift */,
);
path = Auth;
sourceTree = "<group>";
};
AA000011000008 /* Notifications */ = {
isa = PBXGroup;
children = (
AA000002000020 /* NotificationsView.swift */,
AA000002000021 /* NotificationDetailView.swift */,
);
path = Notifications;
sourceTree = "<group>";
};
AA000011000009 /* Settings */ = {
isa = PBXGroup;
children = (
AA000002000022 /* SettingsView.swift */,
AA000002000023 /* ChangePasswordView.swift */,
AA000002000024 /* SessionsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
AA000011000010 /* MaydayLiveActivity */ = {
isa = PBXGroup;
children = (
AA000002000030 /* MaydayLiveActivityBundle.swift */,
AA000002000031 /* MaydayLiveActivityLiveActivity.swift */,
AA000002000033 /* Info.plist (Extension) */,
);
path = MaydayLiveActivity;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AA000005000001 /* Mayday */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA000012000001 /* Build configuration list for PBXNativeTarget "Mayday" */;
buildPhases = (
AA000013000001 /* Sources */,
AA000010000001 /* Frameworks */,
AA000014000001 /* Resources */,
AA000006000001 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
AA000015000001 /* PBXTargetDependency */,
);
name = Mayday;
productName = Mayday;
productReference = AA000009000001 /* Mayday.app */;
productType = "com.apple.product-type.application";
};
AA000005000002 /* MaydayLiveActivity */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA000012000002 /* Build configuration list for PBXNativeTarget "MaydayLiveActivity" */;
buildPhases = (
AA000013000002 /* Sources */,
AA000010000002 /* Frameworks */,
AA000014000002 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = MaydayLiveActivity;
productName = MaydayLiveActivity;
productReference = AA000008000001 /* MaydayLiveActivity.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AA000004000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1540;
TargetAttributes = {
AA000005000001 = {
CreatedOnToolsVersion = 15.4;
};
AA000005000002 = {
CreatedOnToolsVersion = 15.4;
};
};
};
buildConfigurationList = AA000012000003 /* Build configuration list for PBXProject "Mayday" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = ru;
hasScannedForEncodings = 0;
knownRegions = (
ru,
en,
Base,
);
mainGroup = AA000011000001 /* Root */;
productRefGroup = AA000011000099 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
AA000005000001 /* Mayday */,
AA000005000002 /* MaydayLiveActivity */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AA000014000001 /* Resources (App) */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA000001000025 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
AA000014000002 /* Resources (Extension) */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
AA000013000001 /* Sources (App) */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA000001000001 /* MaydayApp.swift in Sources */,
AA000001000002 /* ContentView.swift in Sources */,
AA000001000003 /* AppDelegate.swift in Sources */,
AA000001000004 /* User.swift in Sources */,
AA000001000005 /* TokenPair.swift in Sources */,
AA000001000006 /* AppNotification.swift in Sources */,
AA000001000007 /* Session.swift in Sources */,
AA000001000008 /* AlertAttributes.swift in Sources */,
AA000001000009 /* KeychainService.swift in Sources */,
AA000001000010 /* HTTPClient.swift in Sources */,
AA000001000011 /* AuthService.swift in Sources */,
AA000001000012 /* NotificationsAPIService.swift in Sources */,
AA000001000013 /* PushNotificationService.swift in Sources */,
AA000001000014 /* AuthViewModel.swift in Sources */,
AA000001000015 /* NotificationsViewModel.swift in Sources */,
AA000001000016 /* SettingsViewModel.swift in Sources */,
AA000001000017 /* LoginView.swift in Sources */,
AA000001000018 /* RegisterView.swift in Sources */,
AA000001000019 /* VerifyEmailView.swift in Sources */,
AA000001000020 /* NotificationsView.swift in Sources */,
AA000001000021 /* NotificationDetailView.swift in Sources */,
AA000001000022 /* SettingsView.swift in Sources */,
AA000001000023 /* ChangePasswordView.swift in Sources */,
AA000001000024 /* SessionsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
AA000013000002 /* Sources (Extension) */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA000001000030 /* MaydayLiveActivityBundle.swift in Sources */,
AA000001000031 /* MaydayLiveActivityLiveActivity.swift in Sources */,
AA000001000032 /* AlertAttributes.swift in Sources (Extension) */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
AA000015000001 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA000005000002 /* MaydayLiveActivity */;
targetProxy = AA000003000001 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
AA000016000001 /* Debug (App) */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Mayday/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA000016000002 /* Release (App) */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Mayday/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
AA000016000003 /* Debug (Extension) */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MaydayLiveActivity/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday.liveactivity";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA000016000004 /* Release (Extension) */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MaydayLiveActivity/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.robonen.mayday.liveactivity";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
AA000016000005 /* Debug (Project) */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_CYCLES = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
AA000016000006 /* Release (Project) */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_CYCLES = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
AA000012000001 /* Build configuration list for PBXNativeTarget "Mayday" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA000016000001 /* Debug */,
AA000016000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA000012000002 /* Build configuration list for PBXNativeTarget "MaydayLiveActivity" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA000016000003 /* Debug */,
AA000016000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA000012000003 /* Build configuration list for PBXProject "Mayday" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA000016000005 /* Debug */,
AA000016000006 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA000004000001 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
+38
View File
@@ -0,0 +1,38 @@
import UIKit
import UserNotifications
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { @MainActor in
PushNotificationService.shared.handleDeviceToken(deviceToken)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register for remote notifications: \(error)")
}
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
Task { @MainActor in
await PushNotificationService.shared.handleRemoteNotification(userInfo)
completionHandler(.newData)
}
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+18
View File
@@ -0,0 +1,18 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var authViewModel: AuthViewModel
var body: some View {
Group {
if authViewModel.isAuthenticated {
NotificationsView()
} else {
LoginView()
}
}
.task {
await authViewModel.checkAuthStatus()
}
}
}
+54
View File
@@ -0,0 +1,54 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSUserNotificationsUsageDescription</key>
<string>Mayday использует уведомления для оповещения о критических событиях.</string>
</dict>
</plist>
+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct MaydayApp: App {
@StateObject private var authViewModel = AuthViewModel()
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authViewModel)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import ActivityKit
import Foundation
struct AlertAttributes: ActivityAttributes {
let topic: String
let alertId: String
let severity: Severity
struct ContentState: Codable, Hashable {
let title: String
let value: String?
let status: AlertStatus
let startedAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case title, value, status
case startedAt = "startedAt"
case updatedAt = "updatedAt"
}
}
}
enum Severity: String, Codable, Hashable {
case critical
case warning
case info
var color: String {
switch self {
case .critical: return "red"
case .warning: return "yellow"
case .info: return "blue"
}
}
var emoji: String {
switch self {
case .critical: return "🔴"
case .warning: return "🟡"
case .info: return "🔵"
}
}
}
enum AlertStatus: String, Codable, Hashable {
case active
case resolved
}
+47
View File
@@ -0,0 +1,47 @@
import Foundation
struct AppNotification: Codable, Identifiable {
let id: UUID
let topic: String
let subject: String
let body: String
let metadata: [String: String]?
let status: NotificationStatus
let channel: NotificationChannel
let readAt: Date?
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, topic, subject, body, metadata, status, channel
case readAt = "read_at"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
var isRead: Bool { readAt != nil }
}
enum NotificationStatus: String, Codable {
case sent
case delivered
case read
}
enum NotificationChannel: String, Codable {
case inApp = "in_app"
case push
case email
}
struct NotificationsPage: Codable {
let items: [AppNotification]
let total: Int
let page: Int
let perPage: Int
enum CodingKeys: String, CodingKey {
case items, total, page
case perPage = "per_page"
}
}
+19
View File
@@ -0,0 +1,19 @@
import Foundation
struct SessionResponse: Codable, Identifiable {
let id: UUID
let userAgent: String
let ipAddress: String
let isCurrent: Bool
let createdAt: Date
let expiresAt: Date
enum CodingKeys: String, CodingKey {
case id
case userAgent = "user_agent"
case ipAddress = "ip_address"
case isCurrent = "is_current"
case createdAt = "created_at"
case expiresAt = "expires_at"
}
}
+15
View File
@@ -0,0 +1,15 @@
import Foundation
struct TokenPair: Codable {
let accessToken: String
let refreshToken: String
let expiresAt: Date
let tokenType: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresAt = "expires_at"
case tokenType = "token_type"
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
struct UserResponse: Codable, Identifiable {
let id: UUID
let email: String
let status: UserStatus
let metadata: [String: AnyCodable]?
let emailVerifiedAt: Date?
let roles: [String]
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, email, status, metadata, roles
case emailVerifiedAt = "email_verified_at"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
enum UserStatus: String, Codable {
case pending
case active
case suspended
case deleted
}
// Helper for Any JSON values
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let string = try? container.decode(String.self) {
value = string
} else {
value = ""
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case let int as Int: try container.encode(int)
case let double as Double: try container.encode(double)
case let bool as Bool: try container.encode(bool)
case let string as String: try container.encode(string)
default: try container.encode("")
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import Foundation
struct LoginResponse: Decodable {
let user: UserResponse
let tokens: TokenPair
}
struct RegisterResponse: Decodable {
let user: UserResponse
private enum CodingKeys: String, CodingKey {
case id, email, status, metadata, roles
case emailVerifiedAt = "email_verified_at"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
init(from decoder: Decoder) throws {
user = try UserResponse(from: decoder)
}
}
struct VerifyEmailResponse: Decodable {
let user: UserResponse
}
actor AuthService {
static let shared = AuthService()
private let client = HTTPClient.shared
private let keychain = KeychainService.shared
private init() {}
func login(email: String, password: String) async throws -> UserResponse {
let response: LoginResponse = try await client.request(.login(email: email, password: password))
try keychain.saveTokens(response.tokens)
return response.user
}
func register(email: String, password: String) async throws -> UserResponse {
let response: UserResponse = try await client.request(.register(email: email, password: password))
return response
}
func verifyEmail(email: String, code: String) async throws -> UserResponse {
let response: VerifyEmailResponse = try await client.request(.verifyEmail(email: email, code: code))
return response.user
}
func resendCode(email: String) async throws {
let _: ResendCodeResponse = try await client.request(.resendCode(email: email))
}
func logout() async throws {
guard let refreshToken = keychain.loadRefreshToken() else { return }
let _: EmptyResponse = try await client.request(.logout(refreshToken: refreshToken))
keychain.clearTokens()
}
func getMe() async throws -> UserResponse {
try await client.request(.getMe)
}
}
struct ResendCodeResponse: Decodable {
let message: String
}
struct EmptyResponse: Decodable {}
+217
View File
@@ -0,0 +1,217 @@
import Foundation
enum APIError: Error, LocalizedError {
case invalidURL
case unauthorized
case validationError([String: [String]])
case serverError(String)
case networkError(Error)
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .unauthorized: return "Неверный email или пароль"
case .validationError(let errors):
return errors.values.flatMap { $0 }.joined(separator: ", ")
case .serverError(let message): return message
case .networkError(let error): return error.localizedDescription
case .decodingError(let error): return error.localizedDescription
}
}
}
struct APIResponse<T: Decodable>: Decodable {
let data: T
}
struct APIErrorResponse: Decodable {
let message: String
let errors: [String: [String]]?
}
enum Endpoint {
// Auth
case login(email: String, password: String)
case register(email: String, password: String)
case verifyEmail(email: String, code: String)
case resendCode(email: String)
case refresh(refreshToken: String)
case logout(refreshToken: String)
// Users
case getMe
case getSessions
case deleteSession(id: UUID)
case logoutAll
case changePassword(current: String, new: String)
// Notifications
case getNotifications(page: Int, perPage: Int)
case markAsRead(id: UUID)
// Devices
case registerDevice(token: String)
case unregisterDevice(token: String)
var path: String {
switch self {
case .login: return "/auth/login"
case .register: return "/auth/register"
case .verifyEmail: return "/auth/verify-email"
case .resendCode: return "/auth/resend-code"
case .refresh: return "/auth/refresh"
case .logout: return "/auth/logout"
case .getMe: return "/users/me"
case .getSessions: return "/users/me/sessions"
case .deleteSession(let id): return "/users/me/sessions/\(id.uuidString)"
case .logoutAll: return "/users/me/logout-all"
case .changePassword: return "/users/me/change-password"
case .getNotifications: return "/notifications"
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
case .registerDevice: return "/devices/register"
case .unregisterDevice: return "/devices/unregister"
}
}
var method: String {
switch self {
case .getMe, .getSessions, .getNotifications: return "GET"
case .deleteSession: return "DELETE"
case .markAsRead: return "PATCH"
default: return "POST"
}
}
var requiresAuth: Bool {
switch self {
case .login, .register, .verifyEmail, .resendCode, .refresh, .logout:
return false
default:
return true
}
}
var body: [String: Any]? {
switch self {
case .login(let email, let password):
return ["email": email, "password": password]
case .register(let email, let password):
return ["email": email, "password": password]
case .verifyEmail(let email, let code):
return ["email": email, "code": code]
case .resendCode(let email):
return ["email": email]
case .refresh(let token):
return ["refresh_token": token]
case .logout(let token):
return ["refresh_token": token]
case .changePassword(let current, let new):
return ["current_password": current, "new_password": new]
case .registerDevice(let token):
return ["token": token, "platform": "ios"]
case .unregisterDevice(let token):
return ["token": token]
case .getNotifications(let page, let perPage):
return ["page": page, "per_page": perPage]
default:
return nil
}
}
}
actor HTTPClient {
static let shared = HTTPClient()
private let baseURL: String
private let keychain = KeychainService.shared
private var isRefreshing = false
private init() {
#if DEBUG
baseURL = "http://localhost:8081"
#else
baseURL = "https://api.chemodan.example/sso"
#endif
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let response: T = try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
return response
}
private func performRequest<T: Decodable>(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T {
guard let url = URL(string: baseURL + endpoint.path) else {
throw APIError.invalidURL
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = endpoint.method
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
if endpoint.requiresAuth, let token = keychain.loadAccessToken() {
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = endpoint.body, endpoint.method != "GET" {
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: urlRequest)
} catch {
throw APIError.networkError(error)
}
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError(URLError(.badServerResponse))
}
if httpResponse.statusCode == 401 && retryOnUnauthorized && !isRefreshing {
isRefreshing = true
defer { isRefreshing = false }
try await refreshTokens()
return try await performRequest(endpoint, retryOnUnauthorized: false)
}
if httpResponse.statusCode == 401 {
keychain.clearTokens()
throw APIError.unauthorized
}
if httpResponse.statusCode == 422 {
if let errorResponse = try? JSONDecoder().decode(APIErrorResponse.self, from: data) {
throw APIError.validationError(errorResponse.errors ?? [:])
}
}
if !(200..<300).contains(httpResponse.statusCode) {
if let errorResponse = try? JSONDecoder().decode(APIErrorResponse.self, from: data) {
throw APIError.serverError(errorResponse.message)
}
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
do {
let wrapped = try decoder.decode(APIResponse<T>.self, from: data)
return wrapped.data
} catch {
throw APIError.decodingError(error)
}
}
private func refreshTokens() async throws {
guard let refreshToken = keychain.loadRefreshToken() else {
throw APIError.unauthorized
}
let response: TokenRefreshResponse = try await performRequest(
.refresh(refreshToken: refreshToken),
retryOnUnauthorized: false
)
try keychain.saveTokens(response.tokens)
}
}
struct TokenRefreshResponse: Decodable {
let tokens: TokenPair
}
+74
View File
@@ -0,0 +1,74 @@
import Foundation
import Security
final class KeychainService: Sendable {
static let shared = KeychainService()
private let accessTokenKey = "mayday.access_token"
private let refreshTokenKey = "mayday.refresh_token"
private let expiresAtKey = "mayday.expires_at"
private init() {}
func saveTokens(_ tokens: TokenPair) throws {
try save(tokens.accessToken, forKey: accessTokenKey)
try save(tokens.refreshToken, forKey: refreshTokenKey)
let expiresAtString = ISO8601DateFormatter().string(from: tokens.expiresAt)
try save(expiresAtString, forKey: expiresAtKey)
}
func loadAccessToken() -> String? {
load(forKey: accessTokenKey)
}
func loadRefreshToken() -> String? {
load(forKey: refreshTokenKey)
}
func clearTokens() {
delete(forKey: accessTokenKey)
delete(forKey: refreshTokenKey)
delete(forKey: expiresAtKey)
}
private func save(_ value: String, forKey key: String) throws {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
private func load(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else { return nil }
return string
}
private func delete(forKey key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
enum KeychainError: Error {
case saveFailed(OSStatus)
}
@@ -0,0 +1,46 @@
import Foundation
import UIKit
actor NotificationsAPIService {
static let shared = NotificationsAPIService()
private let client = HTTPClient.shared
private init() {}
func getNotifications(page: Int = 1, perPage: Int = 20) async throws -> NotificationsPage {
try await client.request(.getNotifications(page: page, perPage: perPage))
}
func markAsRead(id: UUID) async throws {
let _: AppNotification = try await client.request(.markAsRead(id: id))
}
func getSessions() async throws -> [SessionResponse] {
try await client.request(.getSessions)
}
func deleteSession(id: UUID) async throws {
let _: EmptyResponse = try await client.request(.deleteSession(id: id))
}
func logoutAll() async throws -> Int {
let response: LogoutAllResponse = try await client.request(.logoutAll)
return response.revokedSessions
}
func changePassword(current: String, new: String) async throws -> UserResponse {
try await client.request(.changePassword(current: current, new: new))
}
func updateAppBadge(_ count: Int) async {
await UIApplication.shared.setApplicationIconBadgeNumber(count)
}
}
struct LogoutAllResponse: Decodable {
let revokedSessions: Int
enum CodingKeys: String, CodingKey {
case revokedSessions = "revoked_sessions"
}
}
@@ -0,0 +1,141 @@
import Foundation
import UserNotifications
import UIKit
import ActivityKit
@MainActor
class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
static let shared = PushNotificationService()
@Published var deviceToken: String?
override private init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
func requestPermission() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
return granted
} catch {
return false
}
}
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
func handleDeviceToken(_ tokenData: Data) {
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
deviceToken = token
Task {
try? await HTTPClient.shared.request(.registerDevice(token: token)) as EmptyResponse
}
}
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async {
guard let aps = userInfo["aps"] as? [String: Any] else { return }
// Handle Live Activity push
if let event = aps["event"] as? String {
await handleLiveActivityPush(event: event, userInfo: userInfo, aps: aps)
return
}
// Update badge
if let badge = aps["badge"] as? Int {
await UIApplication.shared.setApplicationIconBadgeNumber(badge)
}
}
private func handleLiveActivityPush(event: String, userInfo: [AnyHashable: Any], aps: [String: Any]) async {
guard let contentStateData = aps["content-state"] as? [String: Any],
let contentStateJSON = try? JSONSerialization.data(withJSONObject: contentStateData),
let contentState = try? JSONDecoder.iso8601.decode(AlertAttributes.ContentState.self, from: contentStateJSON)
else { return }
switch event {
case "start":
await startLiveActivity(userInfo: userInfo, contentState: contentState)
case "update":
await updateLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
case "end":
await endLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
default:
break
}
}
private func startLiveActivity(userInfo: [AnyHashable: Any], contentState: AlertAttributes.ContentState) async {
guard let attributes = userInfo["attributes"] as? [String: Any],
let topic = attributes["topic"] as? String,
let alertId = attributes["alertId"] as? String,
let severityStr = attributes["severity"] as? String,
let severity = Severity(rawValue: severityStr) else { return }
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)
_ = try? Activity<AlertAttributes>.request(
attributes: attrs,
content: ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600))
)
}
private func updateLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async {
guard let alertId else { return }
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == alertId {
await activity.update(ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600)))
}
}
private func endLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async {
guard let alertId else { return }
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == alertId {
let dismissDate = Date().addingTimeInterval(5 * 60)
await activity.end(
ActivityContent(state: contentState, staleDate: dismissDate),
dismissalPolicy: .after(dismissDate)
)
}
}
// MARK: - UNUserNotificationCenterDelegate
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .badge, .sound])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
completionHandler()
}
}
extension JSONDecoder {
static let iso8601: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}
+89
View File
@@ -0,0 +1,89 @@
import Foundation
import SwiftUI
@MainActor
class AuthViewModel: ObservableObject {
@Published var isAuthenticated = false
@Published var currentUser: UserResponse?
@Published var isLoading = false
@Published var error: String?
private let auth = AuthService.shared
private let keychain = KeychainService.shared
func checkAuthStatus() async {
guard keychain.loadAccessToken() != nil else {
isAuthenticated = false
return
}
isLoading = true
defer { isLoading = false }
do {
currentUser = try await auth.getMe()
isAuthenticated = true
await requestPushIfNeeded()
} catch APIError.unauthorized {
isAuthenticated = false
} catch {
isAuthenticated = false
}
}
func login(email: String, password: String) async {
isLoading = true
error = nil
defer { isLoading = false }
do {
currentUser = try await auth.login(email: email, password: password)
isAuthenticated = true
await requestPushIfNeeded()
} catch {
self.error = error.localizedDescription
}
}
func register(email: String, password: String) async -> Bool {
isLoading = true
error = nil
defer { isLoading = false }
do {
_ = try await auth.register(email: email, password: password)
return true
} catch {
self.error = error.localizedDescription
return false
}
}
func verifyEmail(email: String, code: String) async {
isLoading = true
error = nil
defer { isLoading = false }
do {
_ = try await auth.verifyEmail(email: email, code: code)
// Auto-login after verification is handled by calling login from view
} catch {
self.error = error.localizedDescription
}
}
func logout() async {
isLoading = true
defer { isLoading = false }
do {
try await auth.logout()
} catch {
// Clear anyway
keychain.clearTokens()
}
isAuthenticated = false
currentUser = nil
}
private func requestPushIfNeeded() async {
let granted = await PushNotificationService.shared.requestPermission()
if granted {
PushNotificationService.shared.registerForRemoteNotifications()
}
}
}
@@ -0,0 +1,93 @@
import Foundation
import SwiftUI
@MainActor
class NotificationsViewModel: ObservableObject {
@Published var notifications: [AppNotification] = []
@Published var isLoading = false
@Published var isLoadingMore = false
@Published var error: String?
@Published var hasMore = true
private let service = NotificationsAPIService.shared
private var currentPage = 1
private let perPage = 20
private var pollingTask: Task<Void, Never>?
func load() async {
isLoading = true
error = nil
currentPage = 1
defer { isLoading = false }
do {
let page = try await service.getNotifications(page: 1, perPage: perPage)
notifications = page.items
hasMore = page.items.count == perPage
updateBadge()
} catch {
self.error = error.localizedDescription
}
}
func loadMore() async {
guard !isLoadingMore && hasMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let nextPage = currentPage + 1
let page = try await service.getNotifications(page: nextPage, perPage: perPage)
notifications.append(contentsOf: page.items)
currentPage = nextPage
hasMore = page.items.count == perPage
} catch {
self.error = error.localizedDescription
}
}
func markAsRead(_ notification: AppNotification) async {
guard !notification.isRead else { return }
do {
try await service.markAsRead(id: notification.id)
if let index = notifications.firstIndex(where: { $0.id == notification.id }) {
let updated = AppNotification(
id: notification.id,
topic: notification.topic,
subject: notification.subject,
body: notification.body,
metadata: notification.metadata,
status: .read,
channel: notification.channel,
readAt: Date(),
createdAt: notification.createdAt,
updatedAt: Date()
)
notifications[index] = updated
}
updateBadge()
} catch {
self.error = error.localizedDescription
}
}
func startPolling() {
pollingTask = Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(30))
guard !Task.isCancelled else { break }
await load()
}
}
}
func stopPolling() {
pollingTask?.cancel()
pollingTask = nil
}
private func updateBadge() {
let unreadCount = notifications.filter { !$0.isRead }.count
Task {
await service.updateAppBadge(unreadCount)
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import Foundation
import SwiftUI
@MainActor
class SettingsViewModel: ObservableObject {
@Published var sessions: [SessionResponse] = []
@Published var isLoading = false
@Published var error: String?
@Published var successMessage: String?
private let service = NotificationsAPIService.shared
func loadSessions() async {
isLoading = true
defer { isLoading = false }
do {
sessions = try await service.getSessions()
} catch {
self.error = error.localizedDescription
}
}
func deleteSession(_ session: SessionResponse) async {
do {
try await service.deleteSession(id: session.id)
sessions.removeAll { $0.id == session.id }
} catch {
self.error = error.localizedDescription
}
}
func changePassword(current: String, new: String) async -> Bool {
isLoading = true
error = nil
defer { isLoading = false }
do {
_ = try await service.changePassword(current: current, new: new)
successMessage = "Пароль успешно изменён"
return true
} catch {
self.error = error.localizedDescription
return false
}
}
}
+71
View File
@@ -0,0 +1,71 @@
import SwiftUI
struct LoginView: View {
@EnvironmentObject var authViewModel: AuthViewModel
@State private var email = ""
@State private var password = ""
@State private var showRegister = false
var body: some View {
NavigationStack {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 8) {
Image(systemName: "bell.badge.fill")
.font(.system(size: 60))
.foregroundStyle(.red)
Text("Mayday")
.font(.largeTitle.bold())
Text("Мониторинг и уведомления")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(spacing: 16) {
TextField("Email", text: $email)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Пароль", text: $password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
}
if let error = authViewModel.error {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
.multilineTextAlignment(.center)
}
Button {
Task { await authViewModel.login(email: email, password: password) }
} label: {
if authViewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Войти")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(email.isEmpty || password.isEmpty || authViewModel.isLoading)
Button("Нет аккаунта? Зарегистрироваться") {
showRegister = true
}
.font(.footnote)
Spacer()
}
.padding()
.navigationDestination(isPresented: $showRegister) {
RegisterView()
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import SwiftUI
struct RegisterView: View {
@EnvironmentObject var authViewModel: AuthViewModel
@Environment(\.dismiss) var dismiss
@State private var email = ""
@State private var password = ""
@State private var confirmPassword = ""
@State private var showVerify = false
@State private var registeredEmail = ""
var body: some View {
VStack(spacing: 24) {
Spacer()
Text("Регистрация")
.font(.largeTitle.bold())
VStack(spacing: 16) {
TextField("Email", text: $email)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Пароль", text: $password)
.textFieldStyle(.roundedBorder)
.textContentType(.newPassword)
SecureField("Подтвердите пароль", text: $confirmPassword)
.textFieldStyle(.roundedBorder)
.textContentType(.newPassword)
}
if password.count > 0 && password.count < 8 {
Text("Пароль должен содержать не менее 8 символов")
.foregroundStyle(.red)
.font(.footnote)
}
if confirmPassword.count > 0 && password != confirmPassword {
Text("Пароли не совпадают")
.foregroundStyle(.red)
.font(.footnote)
}
if let error = authViewModel.error {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
.multilineTextAlignment(.center)
}
Button {
Task {
let success = await authViewModel.register(email: email, password: password)
if success {
registeredEmail = email
showVerify = true
}
}
} label: {
if authViewModel.isLoading {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("Создать аккаунт").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(!isFormValid || authViewModel.isLoading)
Button("Уже есть аккаунт?") { dismiss() }
.font(.footnote)
Spacer()
}
.padding()
.navigationDestination(isPresented: $showVerify) {
VerifyEmailView(email: registeredEmail)
}
.navigationTitle("Регистрация")
.navigationBarTitleDisplayMode(.inline)
}
var isFormValid: Bool {
!email.isEmpty && password.count >= 8 && password == confirmPassword
}
}
+119
View File
@@ -0,0 +1,119 @@
import SwiftUI
struct VerifyEmailView: View {
let email: String
@EnvironmentObject var authViewModel: AuthViewModel
@State private var codeDigits: [String] = Array(repeating: "", count: 6)
@State private var resendCooldown = 0
@FocusState private var focusedIndex: Int?
@State private var resendTimer: Timer?
var body: some View {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 8) {
Text("Подтвердите email")
.font(.largeTitle.bold())
Text("Код отправлен на")
.foregroundStyle(.secondary)
Text(email)
.fontWeight(.semibold)
}
HStack(spacing: 12) {
ForEach(0..<6, id: \.self) { index in
TextField("", text: $codeDigits[index])
.frame(width: 44, height: 52)
.multilineTextAlignment(.center)
.font(.title2.bold())
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(focusedIndex == index ? Color.accentColor : Color.secondary, lineWidth: 2)
)
.focused($focusedIndex, equals: index)
.onChange(of: codeDigits[index]) { _, newValue in
handleDigitChange(index: index, value: newValue)
}
}
}
if let error = authViewModel.error {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
}
Button {
Task { await resendCode() }
} label: {
if resendCooldown > 0 {
Text("Отправить повторно (\(resendCooldown) сек)")
} else {
Text("Отправить повторно")
}
}
.disabled(resendCooldown > 0)
Spacer()
}
.padding()
.navigationTitle("Подтверждение")
.navigationBarTitleDisplayMode(.inline)
.onAppear { focusedIndex = 0 }
}
private func handleDigitChange(index: Int, value: String) {
let filtered = value.filter { $0.isNumber }
if filtered.count > 1 {
// Paste handling
let digits = Array(filtered.prefix(6))
for (i, d) in digits.enumerated() where i < 6 {
codeDigits[i] = String(d)
}
focusedIndex = min(digits.count, 5)
} else {
codeDigits[index] = filtered.isEmpty ? "" : String(filtered.last!)
if !filtered.isEmpty && index < 5 {
focusedIndex = index + 1
}
}
let code = codeDigits.joined()
if code.count == 6 {
Task { await submitCode(code) }
}
}
private func submitCode(_ code: String) async {
await authViewModel.verifyEmail(email: email, code: code)
if authViewModel.error == nil {
// Auto-login after verification - in a real flow we'd re-login here
// since verify doesn't return tokens
}
}
private func resendCode() async {
do {
try await AuthService.shared.resendCode(email: email)
resendCooldown = 60
startCooldownTimer()
} catch {
authViewModel.error = error.localizedDescription
}
}
private func startCooldownTimer() {
resendTimer?.invalidate()
resendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
if resendCooldown > 0 {
resendCooldown -= 1
} else {
resendTimer?.invalidate()
}
}
}
}
@@ -0,0 +1,68 @@
import SwiftUI
struct NotificationDetailView: View {
let notification: AppNotification
let viewModel: NotificationsViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 8) {
Text(notification.topic)
.font(.caption)
.foregroundStyle(.secondary)
Text(notification.subject)
.font(.title2.bold())
}
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Подробности:")
.font(.headline)
Text(notification.body)
.font(.body)
}
if let metadata = notification.metadata, !metadata.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Метаданные:")
.font(.headline)
ForEach(metadata.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
HStack {
Text(key).foregroundStyle(.secondary)
Spacer()
Text(value)
}
.font(.footnote)
}
}
}
Divider()
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Получено") {
Text(notification.createdAt.formatted(date: .abbreviated, time: .shortened))
}
LabeledContent("Статус") {
Text(notification.status.rawValue)
}
LabeledContent("Канал") {
Text(notification.channel.rawValue)
}
}
.font(.footnote)
Spacer()
}
.padding()
}
.navigationTitle("Уведомление")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.markAsRead(notification)
}
}
}
@@ -0,0 +1,124 @@
import SwiftUI
struct NotificationsView: View {
@EnvironmentObject var authViewModel: AuthViewModel
@StateObject private var viewModel = NotificationsViewModel()
@State private var showSettings = false
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.notifications.isEmpty {
ProgressView()
} else if let error = viewModel.error, viewModel.notifications.isEmpty {
ContentUnavailableView(
"Ошибка загрузки",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else if viewModel.notifications.isEmpty {
ContentUnavailableView(
"Нет уведомлений",
systemImage: "bell.slash",
description: Text("Новые уведомления появятся здесь")
)
} else {
notificationsList
}
}
.navigationTitle("Уведомления")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showSettings = true
} label: {
Image(systemName: "gear")
}
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
.environmentObject(authViewModel)
}
.task {
await viewModel.load()
viewModel.startPolling()
}
.onDisappear {
viewModel.stopPolling()
}
.refreshable {
await viewModel.load()
}
}
}
var notificationsList: some View {
List {
ForEach(viewModel.notifications) { notification in
NavigationLink(destination: NotificationDetailView(notification: notification, viewModel: viewModel)) {
NotificationRowView(notification: notification)
}
.swipeActions(edge: .leading) {
if !notification.isRead {
Button {
Task { await viewModel.markAsRead(notification) }
} label: {
Label("Прочитано", systemImage: "checkmark")
}
.tint(.blue)
}
}
.onAppear {
if notification.id == viewModel.notifications.last?.id {
Task { await viewModel.loadMore() }
}
}
}
if viewModel.isLoadingMore {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
.listStyle(.plain)
}
}
struct NotificationRowView: View {
let notification: AppNotification
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(notification.isRead ? Color.clear : Color.blue)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 4) {
Text(notification.topic)
.font(.footnote)
.foregroundStyle(.secondary)
Text(notification.subject)
.font(.body)
.fontWeight(notification.isRead ? .regular : .semibold)
Text(notification.createdAt.relativeFormatted)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
extension Date {
var relativeFormatted: String {
let formatter = RelativeDateTimeFormatter()
formatter.locale = Locale(identifier: "ru_RU")
return formatter.localizedString(for: self, relativeTo: Date())
}
}
@@ -0,0 +1,59 @@
import SwiftUI
struct ChangePasswordView: View {
@StateObject private var viewModel = SettingsViewModel()
@Environment(\.dismiss) var dismiss
@State private var currentPassword = ""
@State private var newPassword = ""
@State private var confirmPassword = ""
var body: some View {
NavigationStack {
Form {
Section {
SecureField("Текущий пароль", text: $currentPassword)
.textContentType(.password)
SecureField("Новый пароль", text: $newPassword)
.textContentType(.newPassword)
SecureField("Подтвердите новый пароль", text: $confirmPassword)
.textContentType(.newPassword)
}
if let error = viewModel.error {
Section {
Text(error).foregroundStyle(.red)
}
}
if let success = viewModel.successMessage {
Section {
Text(success).foregroundStyle(.green)
}
}
Section {
Button("Сохранить") {
Task {
let success = await viewModel.changePassword(current: currentPassword, new: newPassword)
if success { dismiss() }
}
}
.disabled(!isFormValid || viewModel.isLoading)
.frame(maxWidth: .infinity)
}
}
.navigationTitle("Сменить пароль")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Отмена") { dismiss() }
}
}
}
}
var isFormValid: Bool {
!currentPassword.isEmpty && newPassword.count >= 8 && newPassword == confirmPassword
}
}
+53
View File
@@ -0,0 +1,53 @@
import SwiftUI
struct SessionsView: View {
@EnvironmentObject var viewModel: SettingsViewModel
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
List {
ForEach(viewModel.sessions) { session in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(session.userAgent)
.font(.body)
.lineLimit(1)
if session.isCurrent {
Text("Текущая")
.font(.caption)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.green.opacity(0.2))
.foregroundStyle(.green)
.cornerRadius(4)
}
}
Text(session.ipAddress)
.font(.caption)
.foregroundStyle(.secondary)
Text("Создана: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))")
.font(.caption2)
.foregroundStyle(.secondary)
}
.swipeActions(edge: .trailing) {
if !session.isCurrent {
Button(role: .destructive) {
Task { await viewModel.deleteSession(session) }
} label: {
Label("Удалить", systemImage: "trash")
}
}
}
}
}
.navigationTitle("Активные сессии")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Готово") { dismiss() }
}
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var authViewModel: AuthViewModel
@StateObject private var viewModel = SettingsViewModel()
@Environment(\.dismiss) var dismiss
@State private var showChangePassword = false
@State private var showSessions = false
@State private var showLogoutAllConfirm = false
var body: some View {
NavigationStack {
Form {
Section("Аккаунт") {
if let user = authViewModel.currentUser {
LabeledContent("Email", value: user.email)
}
}
Section {
Button("Сменить пароль") {
showChangePassword = true
}
Toggle(isOn: .constant(true)) {
Label("Push-уведомления", systemImage: "bell.badge")
}
.onChange(of: true) { _, _ in
// Open system settings
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
Section {
Button {
showSessions = true
} label: {
HStack {
Text("Активные сессии")
Spacer()
if !viewModel.sessions.isEmpty {
Text("(\(viewModel.sessions.count))")
.foregroundStyle(.secondary)
}
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
}
.foregroundStyle(.primary)
}
Section {
Button("Выйти из аккаунта", role: .destructive) {
Task { await authViewModel.logout() }
}
Button("Выйти на всех устройствах", role: .destructive) {
showLogoutAllConfirm = true
}
}
}
.navigationTitle("Настройки")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Готово") { dismiss() }
}
}
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.sheet(isPresented: $showSessions) {
SessionsView()
.environmentObject(viewModel)
}
.confirmationDialog(
"Выйти на всех устройствах?",
isPresented: $showLogoutAllConfirm,
titleVisibility: .visible
) {
Button("Выйти везде", role: .destructive) {
Task {
_ = try? await NotificationsAPIService.shared.logoutAll()
await authViewModel.logout()
}
}
Button("Отмена", role: .cancel) {}
}
.task {
await viewModel.loadSessions()
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>MaydayLiveActivity</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct MaydayLiveActivityBundle: WidgetBundle {
var body: some Widget {
MaydayLiveActivityLiveActivity()
}
}
@@ -0,0 +1,131 @@
import ActivityKit
import WidgetKit
import SwiftUI
struct MaydayLiveActivityLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: AlertAttributes.self) { context in
// Lock Screen / Notification Center
lockScreenView(context: context)
.activityBackgroundTint(severityColor(context.attributes.severity).opacity(0.15))
.activitySystemActionForegroundColor(.primary)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label(context.attributes.topic, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(severityColor(context.attributes.severity))
}
DynamicIslandExpandedRegion(.trailing) {
if let value = context.state.value {
Text(value)
.font(.caption.bold())
.foregroundStyle(severityColor(context.attributes.severity))
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(context.state.title)
.font(.subheadline.bold())
Text("Начало: \(context.state.startedAt.formatted(date: .omitted, time: .shortened))")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
statusBadge(context.state.status)
Text("Длит.: \(duration(from: context.state.startedAt))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
}
} compactLeading: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(severityColor(context.attributes.severity))
} compactTrailing: {
let shortTopic = context.attributes.topic.components(separatedBy: "/").last ?? context.attributes.topic
let valueText = context.state.value.map { " · \($0)" } ?? ""
Text("\(shortTopic)\(valueText)")
.font(.caption2)
.lineLimit(1)
} minimal: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(severityColor(context.attributes.severity))
}
.keylineTint(severityColor(context.attributes.severity))
}
}
@ViewBuilder
func lockScreenView(context: ActivityViewContext<AlertAttributes>) -> some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title2)
.foregroundStyle(severityColor(context.attributes.severity))
VStack(alignment: .leading, spacing: 4) {
Text(context.attributes.topic)
.font(.caption)
.foregroundStyle(.secondary)
Text(context.state.title)
.font(.subheadline.bold())
if let value = context.state.value {
Text(value)
.font(.caption)
.foregroundStyle(severityColor(context.attributes.severity))
}
Text(context.state.startedAt.relativeFormatted)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
statusBadge(context.state.status)
}
.padding()
}
@ViewBuilder
func statusBadge(_ status: AlertStatus) -> some View {
let (text, color): (String, Color) = status == .active
? ("active", .red)
: ("resolved", .green)
Text(text)
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.2))
.foregroundStyle(color)
.cornerRadius(4)
}
func severityColor(_ severity: Severity) -> Color {
switch severity {
case .critical: return .red
case .warning: return .yellow
case .info: return .blue
}
}
func duration(from startDate: Date) -> String {
let interval = Date().timeIntervalSince(startDate)
let minutes = Int(interval / 60)
let hours = minutes / 60
if hours > 0 {
return "\(hours)ч \(minutes % 60)м"
}
return "\(minutes)м"
}
}
extension Date {
var relativeFormatted: String {
let formatter = RelativeDateTimeFormatter()
formatter.locale = Locale(identifier: "ru_RU")
return formatter.localizedString(for: self, relativeTo: Date())
}
}