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