Merge pull request #1 from robonen/copilot/add-push-notifications-support
Add complete Mayday iOS app: auth, push notifications, and Live Activity
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
# Xcode
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
DerivedData/
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
.swiftpm/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 {
|
||||
// Always clear local tokens, regardless of whether the network call succeeds,
|
||||
// to avoid leaving a stale access token in Keychain.
|
||||
defer { keychain.clearTokens() }
|
||||
guard let refreshToken = keychain.loadRefreshToken() else { return }
|
||||
let _: EmptyResponse = try await client.request(.logout(refreshToken: refreshToken))
|
||||
}
|
||||
|
||||
func getMe() async throws -> UserResponse {
|
||||
try await client.request(.getMe)
|
||||
}
|
||||
}
|
||||
|
||||
struct ResendCodeResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
@@ -0,0 +1,253 @@
|
||||
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
|
||||
// Single in-flight refresh task; concurrent 401s await this rather than racing.
|
||||
private var refreshTask: Task<Void, Error>?
|
||||
|
||||
private init() {
|
||||
#if DEBUG
|
||||
baseURL = "http://localhost:8081"
|
||||
#else
|
||||
baseURL = "https://api.chemodan.example/sso"
|
||||
#endif
|
||||
}
|
||||
|
||||
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||
}
|
||||
|
||||
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 {
|
||||
if endpoint.method == "GET" {
|
||||
// Append query parameters to URL for GET requests
|
||||
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
||||
components.queryItems = body.map { key, value in
|
||||
URLQueryItem(name: key, value: "\(value)")
|
||||
}
|
||||
urlRequest.url = components.url
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
} catch {
|
||||
throw APIError.networkError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
do {
|
||||
try await ensureTokenRefreshed()
|
||||
} catch {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures tokens are refreshed exactly once even when multiple requests receive 401
|
||||
/// concurrently. All callers await the same Task; only one network request is made.
|
||||
private func ensureTokenRefreshed() async throws {
|
||||
if let existing = refreshTask {
|
||||
try await existing.value
|
||||
return
|
||||
}
|
||||
|
||||
guard let refreshToken = keychain.loadRefreshToken() else {
|
||||
keychain.clearTokens()
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
let task = Task<Void, Error> {
|
||||
let response: TokenRefreshResponse = try await self.performRequest(
|
||||
.refresh(refreshToken: refreshToken),
|
||||
retryOnUnauthorized: false
|
||||
)
|
||||
try self.keychain.saveTokens(response.tokens)
|
||||
}
|
||||
refreshTask = task
|
||||
do {
|
||||
try await task.value
|
||||
refreshTask = nil
|
||||
} catch {
|
||||
refreshTask = nil
|
||||
keychain.clearTokens()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenRefreshResponse: Decodable {
|
||||
let tokens: TokenPair
|
||||
}
|
||||
@@ -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,41 @@
|
||||
import Foundation
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
struct LogoutAllResponse: Decodable {
|
||||
let revokedSessions: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case revokedSessions = "revoked_sessions"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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 }
|
||||
|
||||
// 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)
|
||||
_ = 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
|
||||
}()
|
||||
}
|
||||
@@ -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,94 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@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() {
|
||||
// Guard against starting a second polling loop if already running.
|
||||
guard pollingTask == nil else { return }
|
||||
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
|
||||
UIApplication.shared.applicationIconBadgeNumber = unreadCount
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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 cooldownTask: Task<Void, Never>?
|
||||
|
||||
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 }
|
||||
.onDisappear { cooldownTask?.cancel() }
|
||||
}
|
||||
|
||||
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)
|
||||
startCooldown()
|
||||
} catch {
|
||||
authViewModel.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func startCooldown() {
|
||||
cooldownTask?.cancel()
|
||||
cooldownTask = Task {
|
||||
for remaining in stride(from: 60, through: 1, by: -1) {
|
||||
guard !Task.isCancelled else { return }
|
||||
resendCooldown = remaining
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
resendCooldown = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,116 @@
|
||||
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, style: .relative)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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
|
||||
@State private var logoutAllError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Аккаунт") {
|
||||
if let user = authViewModel.currentUser {
|
||||
LabeledContent("Email", value: user.email)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Сменить пароль") {
|
||||
showChangePassword = true
|
||||
}
|
||||
|
||||
Button {
|
||||
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Push-уведомления", systemImage: "bell.badge")
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
do {
|
||||
_ = try await NotificationsAPIService.shared.logoutAll()
|
||||
await authViewModel.logout()
|
||||
} catch {
|
||||
logoutAllError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Отмена", role: .cancel) {}
|
||||
}
|
||||
.alert(
|
||||
"Ошибка",
|
||||
isPresented: Binding(
|
||||
get: { logoutAllError != nil },
|
||||
set: { if !$0 { logoutAllError = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK") { logoutAllError = nil }
|
||||
} message: {
|
||||
Text(logoutAllError ?? "")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).MaydayLiveActivityBundle</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,9 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MaydayLiveActivityBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
MaydayLiveActivityLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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(date, style: .timer) updates automatically without re-render.
|
||||
HStack(spacing: 2) {
|
||||
Text("Длит.:")
|
||||
Text(context.state.startedAt, style: .timer)
|
||||
}
|
||||
.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(date, style: .relative) updates automatically without re-render.
|
||||
Text(context.state.startedAt, style: .relative)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
statusBadge(context.state.status)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func statusBadge(_ status: AlertStatus) -> some View {
|
||||
let (text, color): (String, Color) = status == .active
|
||||
? ("активен", .red)
|
||||
: ("завершён", .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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user