Compare commits
1 Commits
main
...
test-view-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c0c24cff2 |
@ -7,8 +7,6 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1A38EB882EDE871B00DD8FC4 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */; };
|
|
||||||
1A38EB8C2EDE8CC700DD8FC4 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */; };
|
|
||||||
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = 1A85C6CB2EA6FD73009FA847 /* SocketIO */; };
|
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = 1A85C6CB2EA6FD73009FA847 /* SocketIO */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@ -35,22 +33,9 @@
|
|||||||
1A6D61E42E7CD04100B9F736 /* yobbleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = yobbleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
1A6D61E42E7CD04100B9F736 /* yobbleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = yobbleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
1A38EB832EDE80E700DD8FC4 /* Exceptions for "yobble" folder in "yobble" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = 1A6D61CB2E7CD03E00B9F736 /* yobble */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
1A6D61CE2E7CD03E00B9F736 /* yobble */ = {
|
1A6D61CE2E7CD03E00B9F736 /* yobble */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
1A38EB832EDE80E700DD8FC4 /* Exceptions for "yobble" folder in "yobble" target */,
|
|
||||||
);
|
|
||||||
path = yobble;
|
path = yobble;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -71,8 +56,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1A38EB882EDE871B00DD8FC4 /* FirebaseMessaging in Frameworks */,
|
|
||||||
1A38EB8C2EDE8CC700DD8FC4 /* FirebaseCore in Frameworks */,
|
|
||||||
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */,
|
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@ -128,8 +111,6 @@
|
|||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */,
|
|
||||||
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */,
|
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
1A6D61CE2E7CD03E00B9F736 /* yobble */,
|
1A6D61CE2E7CD03E00B9F736 /* yobble */,
|
||||||
@ -137,8 +118,6 @@
|
|||||||
name = yobble;
|
name = yobble;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
1A85C6CB2EA6FD73009FA847 /* SocketIO */,
|
1A85C6CB2EA6FD73009FA847 /* SocketIO */,
|
||||||
1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */,
|
|
||||||
1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */,
|
|
||||||
);
|
);
|
||||||
productName = yobble;
|
productName = yobble;
|
||||||
productReference = 1A6D61CC2E7CD03E00B9F736 /* yobble.app */;
|
productReference = 1A6D61CC2E7CD03E00B9F736 /* yobble.app */;
|
||||||
@ -225,7 +204,6 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */,
|
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */,
|
||||||
1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 1A6D61CD2E7CD03E00B9F736 /* Products */;
|
productRefGroup = 1A6D61CD2E7CD03E00B9F736 /* Products */;
|
||||||
@ -288,14 +266,6 @@
|
|||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
productRef = 1A38EB8F2EDE8ECA00DD8FC4 /* FirebaseMessaging */;
|
|
||||||
};
|
|
||||||
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
productRef = 1A38EB912EDE8ED500DD8FC4 /* FirebaseCore */;
|
|
||||||
};
|
|
||||||
1A6D61DC2E7CD04000B9F736 /* PBXTargetDependency */ = {
|
1A6D61DC2E7CD04000B9F736 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = 1A6D61CB2E7CD03E00B9F736 /* yobble */;
|
target = 1A6D61CB2E7CD03E00B9F736 /* yobble */;
|
||||||
@ -434,12 +404,11 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_TEAM = V22H44W47J;
|
DEVELOPMENT_TEAM = V22H44W47J;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = yobble/Info.plist;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
@ -451,7 +420,7 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11;
|
MACOSX_DEPLOYMENT_TARGET = 11;
|
||||||
@ -475,12 +444,11 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_TEAM = V22H44W47J;
|
DEVELOPMENT_TEAM = V22H44W47J;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = yobble/Info.plist;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
@ -492,7 +460,7 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 11;
|
MACOSX_DEPLOYMENT_TARGET = 11;
|
||||||
@ -641,14 +609,6 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMinorVersion;
|
|
||||||
minimumVersion = 12.6.0;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */ = {
|
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/socketio/socket.io-client-swift";
|
repositoryURL = "https://github.com/socketio/socket.io-client-swift";
|
||||||
@ -660,26 +620,6 @@
|
|||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
|
||||||
productName = FirebaseMessaging;
|
|
||||||
};
|
|
||||||
1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
|
||||||
productName = FirebaseCore;
|
|
||||||
};
|
|
||||||
1A38EB8F2EDE8ECA00DD8FC4 /* FirebaseMessaging */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
|
||||||
productName = FirebaseMessaging;
|
|
||||||
};
|
|
||||||
1A38EB912EDE8ED500DD8FC4 /* FirebaseCore */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
|
||||||
productName = FirebaseCore;
|
|
||||||
};
|
|
||||||
1A85C6CB2EA6FD73009FA847 /* SocketIO */ = {
|
1A85C6CB2EA6FD73009FA847 /* SocketIO */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */;
|
package = 1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */;
|
||||||
|
|||||||
@ -1,123 +1,6 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "600d4311db652d123053aed731b25dc6c4fe63e106bb9043d105bb4fedf8b79b",
|
"originHash" : "c9fb241c5f575df8f20b39649006995779013948e60c51c3f85b729f83b054e7",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
|
||||||
"identity" : "abseil-cpp-binary",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
|
||||||
"version" : "1.2024072200.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "app-check",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/app-check.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
|
||||||
"version" : "11.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "firebase-ios-sdk",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "087bb95235f676c1a37e928769a5b6645dcbd325",
|
|
||||||
"version" : "12.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c",
|
|
||||||
"version" : "3.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "googleappmeasurement",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1",
|
|
||||||
"version" : "12.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "googledatatransport",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
|
||||||
"version" : "10.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "googleutilities",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
|
||||||
"version" : "8.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "grpc-binary",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/grpc-binary.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
|
||||||
"version" : "1.69.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "gtm-session-fetcher",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7",
|
|
||||||
"version" : "5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "interop-ios-for-google-sdks",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
|
||||||
"version" : "101.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "leveldb",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/firebase/leveldb.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
|
||||||
"version" : "1.22.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "nanopb",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/firebase/nanopb.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
|
||||||
"version" : "2.30910.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "promises",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/google/promises.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
|
||||||
"version" : "2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "socket.io-client-swift",
|
"identity" : "socket.io-client-swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -135,15 +18,6 @@
|
|||||||
"revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a",
|
"revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a",
|
||||||
"version" : "4.0.8"
|
"version" : "4.0.8"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-protobuf",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
|
||||||
"version" : "1.33.3"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 3
|
||||||
|
|||||||
@ -3,22 +3,4 @@
|
|||||||
uuid = "AEE1609A-17B4-4FCC-80A6-0D556940F4D7"
|
uuid = "AEE1609A-17B4-4FCC-80A6-0D556940F4D7"
|
||||||
type = "1"
|
type = "1"
|
||||||
version = "2.0">
|
version = "2.0">
|
||||||
<Breakpoints>
|
|
||||||
<BreakpointProxy
|
|
||||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
|
||||||
<BreakpointContent
|
|
||||||
uuid = "09699199-8124-4F89-892D-6880A0EB7C04"
|
|
||||||
shouldBeEnabled = "No"
|
|
||||||
ignoreCount = "0"
|
|
||||||
continueAfterRunningActions = "No"
|
|
||||||
filePath = "yobble/Views/Contacts/ContactEditView.swift"
|
|
||||||
startingColumnNumber = "9223372036854775807"
|
|
||||||
endingColumnNumber = "9223372036854775807"
|
|
||||||
startingLineNumber = "74"
|
|
||||||
endingLineNumber = "74"
|
|
||||||
landmarkName = "ContactEditView"
|
|
||||||
landmarkType = "14">
|
|
||||||
</BreakpointContent>
|
|
||||||
</BreakpointProxy>
|
|
||||||
</Breakpoints>
|
|
||||||
</Bucket>
|
</Bucket>
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
//e8DBOKOTPUGxtvK2mqZ-gy:APA91bGtJO3Jf8NxuvzSnfj4YyZllen29x1c_o3UtKHKTvnVcTz0TdHapCyjJH4ZsuiO9z2HhGW134165c-VXmrdKlYSBGz5-ZtU0lTWLe5LDLuZGDbqYdk
|
|
||||||
|
|
||||||
import Firebase
|
|
||||||
import FirebaseMessaging
|
|
||||||
import UserNotifications
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate {
|
|
||||||
private let pushTokenManager = PushTokenManager.shared
|
|
||||||
|
|
||||||
func application(_ application: UIApplication,
|
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
|
||||||
|
|
||||||
print("hello")
|
|
||||||
FirebaseApp.configure()
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = self
|
|
||||||
Messaging.messaging().delegate = self
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
|
|
||||||
if granted {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Foreground notifications — вот это важное!
|
|
||||||
func userNotificationCenter(
|
|
||||||
_ center: UNUserNotificationCenter,
|
|
||||||
willPresent notification: UNNotification,
|
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
||||||
) {
|
|
||||||
// completionHandler([.banner, .sound, .badge]) // push
|
|
||||||
completionHandler([]) // no push
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ application: UIApplication,
|
|
||||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
||||||
|
|
||||||
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
|
|
||||||
print("📨 APNs device token:", token)
|
|
||||||
|
|
||||||
Messaging.messaging().apnsToken = deviceToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ application: UIApplication,
|
|
||||||
didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
||||||
print("❌ APNs registration failed:", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
|
|
||||||
guard let fcmToken else {
|
|
||||||
if AppConfig.DEBUG { print("🔥 FCM token: NO TOKEN") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if AppConfig.DEBUG { print("🔥 FCM token:", fcmToken) }
|
|
||||||
pushTokenManager.registerFCMToken(fcmToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ProfileHeaderCardView: View {
|
|
||||||
struct PresenceStatus {
|
|
||||||
let text: String
|
|
||||||
let isOnline: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StatusTag: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let icon: String
|
|
||||||
let text: String
|
|
||||||
let background: Color
|
|
||||||
let tint: Color
|
|
||||||
}
|
|
||||||
|
|
||||||
private let avatar: AnyView
|
|
||||||
private let displayName: String
|
|
||||||
private let presenceStatus: PresenceStatus?
|
|
||||||
private let statusTags: [StatusTag]
|
|
||||||
private let isOfficial: Bool
|
|
||||||
|
|
||||||
init<Avatar: View>(
|
|
||||||
avatar: Avatar,
|
|
||||||
displayName: String,
|
|
||||||
presenceStatus: PresenceStatus?,
|
|
||||||
statusTags: [StatusTag],
|
|
||||||
isOfficial: Bool
|
|
||||||
) {
|
|
||||||
self.avatar = AnyView(avatar)
|
|
||||||
self.displayName = displayName
|
|
||||||
self.presenceStatus = presenceStatus
|
|
||||||
self.statusTags = statusTags
|
|
||||||
self.isOfficial = isOfficial
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
avatar
|
|
||||||
.overlay(alignment: .bottomTrailing) {
|
|
||||||
officialBadge
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Text(displayName)
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
if let presenceStatus {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Circle()
|
|
||||||
.fill(presenceStatus.isOnline ? Color.green : Color.gray.opacity(0.4))
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
Text(presenceStatus.text)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !statusTags.isEmpty {
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], spacing: 8) {
|
|
||||||
ForEach(statusTags) { tag in
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: tag.icon)
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
Text(tag.text)
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.foregroundColor(tag.tint)
|
|
||||||
.background(tag.background)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
|
||||||
.fill(headerGradient)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
|
||||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.shadow(color: Color.black.opacity(0.08), radius: 20, x: 0, y: 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var officialBadge: some View {
|
|
||||||
if isOfficial {
|
|
||||||
Image(systemName: "checkmark.seal.fill")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(6)
|
|
||||||
.background(Circle().fill(Color.accentColor))
|
|
||||||
.offset(x: 6, y: 6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var headerGradient: LinearGradient {
|
|
||||||
let first = isOfficial ? Color.accentColor : Color.accentColor.opacity(0.6)
|
|
||||||
let second = Color.accentColor.opacity(isOfficial ? 0.6 : 0.3)
|
|
||||||
let third = Color(UIColor.secondarySystemBackground)
|
|
||||||
return LinearGradient(colors: [first, second, third], startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,7 +3,6 @@ import SwiftUI
|
|||||||
struct TopBarView: View {
|
struct TopBarView: View {
|
||||||
var title: String
|
var title: String
|
||||||
|
|
||||||
let isMessengerModeEnabled: Bool
|
|
||||||
// Состояния для ProfileTab
|
// Состояния для ProfileTab
|
||||||
@Binding var selectedAccount: String
|
@Binding var selectedAccount: String
|
||||||
// @Binding var sheetType: ProfileTab.SheetType?
|
// @Binding var sheetType: ProfileTab.SheetType?
|
||||||
@ -11,7 +10,6 @@ struct TopBarView: View {
|
|||||||
// var viewModel: LoginViewModel
|
// var viewModel: LoginViewModel
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
@Binding var isSettingsPresented: Bool
|
@Binding var isSettingsPresented: Bool
|
||||||
@Binding var isQrPresented: Bool
|
|
||||||
|
|
||||||
// Привязка для управления боковым меню
|
// Привязка для управления боковым меню
|
||||||
@Binding var isSideMenuPresented: Bool
|
@Binding var isSideMenuPresented: Bool
|
||||||
@ -19,23 +17,15 @@ struct TopBarView: View {
|
|||||||
@Binding var chatSearchText: String
|
@Binding var chatSearchText: String
|
||||||
|
|
||||||
var isHomeTab: Bool {
|
var isHomeTab: Bool {
|
||||||
return title == NSLocalizedString("Home", comment: "")
|
return title == "Home"
|
||||||
}
|
}
|
||||||
|
|
||||||
var isChatsTab: Bool {
|
var isChatsTab: Bool {
|
||||||
return title == NSLocalizedString("Чаты", comment: "")
|
return title == "Chats"
|
||||||
}
|
}
|
||||||
|
|
||||||
var isProfileTab: Bool {
|
var isProfileTab: Bool {
|
||||||
return title == NSLocalizedString("Profile", comment: "")
|
return title == "Profile"
|
||||||
}
|
|
||||||
|
|
||||||
var isContactsTab: Bool {
|
|
||||||
return title == NSLocalizedString("Контакты", comment: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
var isSettingsTab: Bool {
|
|
||||||
return title == NSLocalizedString("Настройки", comment: "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusMessage: String? {
|
private var statusMessage: String? {
|
||||||
@ -51,22 +41,20 @@ struct TopBarView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
|
// Кнопка "Гамбургер" для открытия меню
|
||||||
if !isMessengerModeEnabled{
|
Button(action: {
|
||||||
// Кнопка "Гамбургер" для открытия меню
|
withAnimation {
|
||||||
Button(action: {
|
isSideMenuPresented.toggle()
|
||||||
withAnimation {
|
|
||||||
isSideMenuPresented.toggle()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: "line.horizontal.3")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "line.horizontal.3")
|
||||||
|
.imageScale(.large)
|
||||||
|
.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spacer()
|
// Spacer()
|
||||||
|
|
||||||
if let statusMessage, !isContactsTab, !isSettingsTab {
|
if let statusMessage {
|
||||||
connectionStatusView(message: statusMessage)
|
connectionStatusView(message: statusMessage)
|
||||||
Spacer()
|
Spacer()
|
||||||
} else if isHomeTab{
|
} else if isHomeTab{
|
||||||
@ -121,14 +109,6 @@ struct TopBarView: View {
|
|||||||
.imageScale(.large)
|
.imageScale(.large)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
} else if isContactsTab {
|
|
||||||
NavigationLink(isActive: $isQrPresented) {
|
|
||||||
QrView()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "qrcode.viewfinder")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// else if isChatsTab {
|
// else if isChatsTab {
|
||||||
@ -237,20 +217,17 @@ struct TopBarView_Previews: PreviewProvider {
|
|||||||
@StateObject private var viewModel = LoginViewModel()
|
@StateObject private var viewModel = LoginViewModel()
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
@State private var isSettingsPresented = false
|
@State private var isSettingsPresented = false
|
||||||
@State private var isQrPresented = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TopBarView(
|
TopBarView(
|
||||||
title: "Chats",
|
title: "Chats",
|
||||||
isMessengerModeEnabled: false,
|
|
||||||
selectedAccount: $selectedAccount,
|
selectedAccount: $selectedAccount,
|
||||||
accounts: [selectedAccount],
|
accounts: [selectedAccount],
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
isSettingsPresented: $isSettingsPresented,
|
isSettingsPresented: $isSettingsPresented,
|
||||||
isQrPresented: $isSettingsPresented,
|
|
||||||
isSideMenuPresented: $isSideMenuPresented,
|
isSideMenuPresented: $isSideMenuPresented,
|
||||||
chatSearchRevealProgress: $revealProgress,
|
chatSearchRevealProgress: $revealProgress,
|
||||||
chatSearchText: $searchText,
|
chatSearchText: $searchText
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
<?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>API_KEY</key>
|
|
||||||
<string>AIzaSyAdhhLghSRDeN9ivTG5jd9ZT6DNdQ8pBM4</string>
|
|
||||||
<key>GCM_SENDER_ID</key>
|
|
||||||
<string>1058456897662</string>
|
|
||||||
<key>PLIST_VERSION</key>
|
|
||||||
<string>1</string>
|
|
||||||
<key>BUNDLE_ID</key>
|
|
||||||
<string>org.yobble.yobble</string>
|
|
||||||
<key>PROJECT_ID</key>
|
|
||||||
<string>yobble</string>
|
|
||||||
<key>STORAGE_BUCKET</key>
|
|
||||||
<string>yobble.firebasestorage.app</string>
|
|
||||||
<key>IS_ADS_ENABLED</key>
|
|
||||||
<false></false>
|
|
||||||
<key>IS_ANALYTICS_ENABLED</key>
|
|
||||||
<false></false>
|
|
||||||
<key>IS_APPINVITE_ENABLED</key>
|
|
||||||
<true></true>
|
|
||||||
<key>IS_GCM_ENABLED</key>
|
|
||||||
<true></true>
|
|
||||||
<key>IS_SIGNIN_ENABLED</key>
|
|
||||||
<true></true>
|
|
||||||
<key>GOOGLE_APP_ID</key>
|
|
||||||
<string>1:1058456897662:ios:c2a898d6a6412b8709f02f</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<?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>UIApplicationSupportsMultipleScenes</key>
|
|
||||||
<false/>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@ -29,16 +29,3 @@ struct ErrorResponse: Decodable {
|
|||||||
struct MessagePayload: Decodable {
|
struct MessagePayload: Decodable {
|
||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BlockedUserInfo: Decodable {
|
|
||||||
let userId: UUID
|
|
||||||
let login: String?
|
|
||||||
let fullName: String?
|
|
||||||
let customName: String?
|
|
||||||
let createdAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BlockedUsersPayload: Decodable {
|
|
||||||
let hasMore: Bool
|
|
||||||
let items: [BlockedUserInfo]
|
|
||||||
}
|
|
||||||
|
|||||||
@ -44,9 +44,8 @@ final class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NetworkClient.shared.request(
|
NetworkClient.shared.request(
|
||||||
path: "/v1/auth/login/password",
|
path: "/v1/auth/login",
|
||||||
method: .post,
|
method: .post,
|
||||||
headers: ["X-Client-Type": "ios"],
|
|
||||||
body: body,
|
body: body,
|
||||||
requiresAuth: false
|
requiresAuth: false
|
||||||
) { result in
|
) { result in
|
||||||
@ -84,91 +83,6 @@ final class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestLoginCode(identifier: String, completion: @escaping (Bool, String?) -> Void) {
|
|
||||||
let payload = LoginCodeRequestPayload(login: identifier)
|
|
||||||
|
|
||||||
guard let body = try? JSONEncoder().encode(payload) else {
|
|
||||||
completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkClient.shared.request(
|
|
||||||
path: "/v1/auth/login/code",
|
|
||||||
method: .post,
|
|
||||||
headers: ["X-Client-Type": "ios"],
|
|
||||||
body: body,
|
|
||||||
requiresAuth: false
|
|
||||||
) { [weak self] result in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? apiResponse.data.message
|
|
||||||
completion(false, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(true, nil)
|
|
||||||
} catch {
|
|
||||||
completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
completion(false, self.passwordlessRequestErrorMessage(for: error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loginWithCode(identifier: String, code: String, completion: @escaping (Bool, String?) -> Void) {
|
|
||||||
let payload = VerifyCodeRequestPayload(login: identifier, otp: code)
|
|
||||||
|
|
||||||
guard let body = try? JSONEncoder().encode(payload) else {
|
|
||||||
completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkClient.shared.request(
|
|
||||||
path: "/v1/auth/login/verify_code",
|
|
||||||
method: .post,
|
|
||||||
headers: ["X-Client-Type": "ios"],
|
|
||||||
body: body,
|
|
||||||
requiresAuth: false
|
|
||||||
) { [weak self] result in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<TokenPairPayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Проверьте код и попробуйте снова.", comment: "")
|
|
||||||
completion(false, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let tokens = apiResponse.data
|
|
||||||
KeychainService.shared.save(tokens.access_token, forKey: "access_token", service: identifier)
|
|
||||||
KeychainService.shared.save(tokens.refresh_token, forKey: "refresh_token", service: identifier)
|
|
||||||
if let userId = tokens.user_id {
|
|
||||||
KeychainService.shared.save(userId, forKey: "userId", service: identifier)
|
|
||||||
}
|
|
||||||
UserDefaults.standard.set(identifier, forKey: "currentUser")
|
|
||||||
|
|
||||||
NotificationCenter.default.post(name: .accessTokenDidChange, object: nil)
|
|
||||||
|
|
||||||
completion(true, nil)
|
|
||||||
} catch {
|
|
||||||
completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
completion(false, self.passwordlessVerifyErrorMessage(for: error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
||||||
let payload = RegisterRequest(login: username, password: password, invite: invite)
|
let payload = RegisterRequest(login: username, password: password, invite: invite)
|
||||||
guard let body = try? JSONEncoder().encode(payload) else {
|
guard let body = try? JSONEncoder().encode(payload) else {
|
||||||
@ -315,24 +229,11 @@ final class AuthService {
|
|||||||
return mappedRegistrationMessage(for: message, statusCode: statusCode)
|
return mappedRegistrationMessage(for: message, statusCode: statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = extractMessage(from: data)
|
|
||||||
|
|
||||||
switch statusCode {
|
switch statusCode {
|
||||||
case 400:
|
case 400:
|
||||||
return NSLocalizedString("Неверный запрос (400).", comment: "")
|
return NSLocalizedString("Неверный запрос (400).", comment: "")
|
||||||
case 403:
|
case 403:
|
||||||
return NSLocalizedString("Регистрация запрещена.", comment: "")
|
return NSLocalizedString("Регистрация запрещена.", comment: "")
|
||||||
case 409:
|
|
||||||
return NSLocalizedString("Логин уже занят.", comment: "")
|
|
||||||
case 422:
|
|
||||||
if let message {
|
|
||||||
if message == "Value error, Login must not end with 'bot' for non-bot accounts"{
|
|
||||||
return NSLocalizedString("Login must not end with 'bot' for non-bot accounts", comment: "")
|
|
||||||
}
|
|
||||||
return message
|
|
||||||
} else {
|
|
||||||
return NSLocalizedString("Ошибка в данных. Проверьте введённую информацию.", comment: "")
|
|
||||||
}
|
|
||||||
case 429:
|
case 429:
|
||||||
return NSLocalizedString("Слишком много запросов.", comment: "")
|
return NSLocalizedString("Слишком много запросов.", comment: "")
|
||||||
case 502:
|
case 502:
|
||||||
@ -347,70 +248,6 @@ final class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func passwordlessRequestErrorMessage(for error: NetworkError) -> String {
|
|
||||||
switch error {
|
|
||||||
case .network(let err):
|
|
||||||
return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription)
|
|
||||||
case .server(let statusCode, let data):
|
|
||||||
let message = extractMessage(from: data)
|
|
||||||
|
|
||||||
switch statusCode {
|
|
||||||
case 401, 404:
|
|
||||||
return message ?? NSLocalizedString("Аккаунт не найден.", comment: "")
|
|
||||||
case 403:
|
|
||||||
return message ?? NSLocalizedString("Этому аккаунту недоступен вход по коду.", comment: "")
|
|
||||||
case 422:
|
|
||||||
return message ?? NSLocalizedString("Неверный логин. Проверьте и попробуйте снова.", comment: "")
|
|
||||||
case 429:
|
|
||||||
return NSLocalizedString("Слишком много попыток. Попробуйте позже.", comment: "")
|
|
||||||
case 502:
|
|
||||||
return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
|
|
||||||
default:
|
|
||||||
if let message {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)")
|
|
||||||
}
|
|
||||||
case .unauthorized:
|
|
||||||
return NSLocalizedString("Необходимо авторизоваться заново.", comment: "")
|
|
||||||
case .invalidURL, .noResponse:
|
|
||||||
return NSLocalizedString("Некорректный ответ от сервера.", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func passwordlessVerifyErrorMessage(for error: NetworkError) -> String {
|
|
||||||
switch error {
|
|
||||||
case .network(let err):
|
|
||||||
return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription)
|
|
||||||
case .server(let statusCode, let data):
|
|
||||||
let message = extractMessage(from: data)
|
|
||||||
|
|
||||||
switch statusCode {
|
|
||||||
case 401:
|
|
||||||
return message ?? NSLocalizedString("Неверный или просроченный код.", comment: "")
|
|
||||||
case 403:
|
|
||||||
return message ?? NSLocalizedString("Этот аккаунт недоступен.", comment: "")
|
|
||||||
case 404:
|
|
||||||
return message ?? NSLocalizedString("Аккаунт не найден.", comment: "")
|
|
||||||
case 422:
|
|
||||||
return message ?? NSLocalizedString("Некорректные данные. Проверьте код и логин.", comment: "")
|
|
||||||
case 429:
|
|
||||||
return NSLocalizedString("Слишком много попыток. Попробуйте позже.", comment: "")
|
|
||||||
case 502:
|
|
||||||
return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
|
|
||||||
default:
|
|
||||||
if let message {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)")
|
|
||||||
}
|
|
||||||
case .unauthorized:
|
|
||||||
return NSLocalizedString("Сессия недействительна. Авторизуйтесь заново.", comment: "")
|
|
||||||
case .invalidURL, .noResponse:
|
|
||||||
return NSLocalizedString("Некорректный ответ от сервера.", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mappedRegistrationMessage(for message: String, statusCode: Int) -> String {
|
private func mappedRegistrationMessage(for message: String, statusCode: Int) -> String {
|
||||||
if statusCode == 400 {
|
if statusCode == 400 {
|
||||||
if message.contains("Invalid invitation code") {
|
if message.contains("Invalid invitation code") {
|
||||||
@ -550,15 +387,6 @@ private struct LoginRequest: Encodable {
|
|||||||
let password: String
|
let password: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LoginCodeRequestPayload: Encodable {
|
|
||||||
let login: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct VerifyCodeRequestPayload: Encodable {
|
|
||||||
let login: String
|
|
||||||
let otp: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RegisterRequest: Encodable {
|
private struct RegisterRequest: Encodable {
|
||||||
let login: String
|
let login: String
|
||||||
let password: String
|
let password: String
|
||||||
|
|||||||
@ -1,307 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum BlockedUsersServiceError: LocalizedError {
|
|
||||||
case unexpectedStatus(String)
|
|
||||||
case decoding(debugDescription: String)
|
|
||||||
case encoding(String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .unexpectedStatus(let message):
|
|
||||||
return message
|
|
||||||
case .decoding(let debugDescription):
|
|
||||||
return AppConfig.DEBUG
|
|
||||||
? debugDescription
|
|
||||||
: NSLocalizedString("Не удалось загрузить список.", comment: "Blocked users service decoding error")
|
|
||||||
case .encoding(let message):
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class BlockedUsersService {
|
|
||||||
private let client: NetworkClient
|
|
||||||
private let decoder: JSONDecoder
|
|
||||||
|
|
||||||
init(client: NetworkClient = .shared) {
|
|
||||||
self.client = client
|
|
||||||
self.decoder = JSONDecoder()
|
|
||||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchBlockedUsers(limit: Int, offset: Int, completion: @escaping (Result<BlockedUsersPayload, Error>) -> Void) {
|
|
||||||
let query = [
|
|
||||||
"limit": String(limit),
|
|
||||||
"offset": String(offset)
|
|
||||||
]
|
|
||||||
|
|
||||||
client.request(
|
|
||||||
path: "/v1/user/blacklist/list",
|
|
||||||
method: .get,
|
|
||||||
query: query,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<BlockedUsersPayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список.", comment: "Blocked users service unexpected status")
|
|
||||||
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[BlockedUsersService] decode blocked users failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(BlockedUsersServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchBlockedUsers(limit: Int, offset: Int) async throws -> BlockedUsersPayload {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
fetchBlockedUsers(limit: limit, offset: offset) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(userId: UUID, completion: @escaping (Result<String, Error>) -> Void) {
|
|
||||||
let request = BlockedUserDeleteRequest(userId: userId)
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
||||||
|
|
||||||
guard let body = try? encoder.encode(request) else {
|
|
||||||
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Blocked users delete encoding error")
|
|
||||||
completion(.failure(BlockedUsersServiceError.encoding(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.request(
|
|
||||||
path: "/v1/user/blacklist/remove",
|
|
||||||
method: .delete,
|
|
||||||
body: body,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось удалить пользователя из списка.", comment: "Blocked users delete unexpected status")
|
|
||||||
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data.message))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[BlockedUsersService] decode delete response failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(BlockedUsersServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(userId: UUID) async throws -> String {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
remove(userId: userId) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(userId: UUID, completion: @escaping (Result<BlockedUserInfo, Error>) -> Void) {
|
|
||||||
let request = BlockedUserCreateRequest(userId: userId, login: nil)
|
|
||||||
add(request: request, completion: completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(login: String, completion: @escaping (Result<BlockedUserInfo, Error>) -> Void) {
|
|
||||||
let request = BlockedUserCreateRequest(userId: nil, login: login)
|
|
||||||
add(request: request, completion: completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func add(request: BlockedUserCreateRequest, completion: @escaping (Result<BlockedUserInfo, Error>) -> Void) {
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
||||||
|
|
||||||
guard let body = try? encoder.encode(request) else {
|
|
||||||
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Blocked users create encoding error")
|
|
||||||
completion(.failure(BlockedUsersServiceError.encoding(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.request(
|
|
||||||
path: "/v1/user/blacklist/add",
|
|
||||||
method: .post,
|
|
||||||
body: body,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<BlockedUserInfo>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось заблокировать пользователя.", comment: "Blocked users create unexpected status")
|
|
||||||
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[BlockedUsersService] decode create response failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(BlockedUsersServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(userId: UUID) async throws -> BlockedUserInfo {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
add(userId: userId) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(login: String) async throws -> BlockedUserInfo {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
add(login: login) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodeDate(from decoder: Decoder) throws -> Date {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let string = try container.decode(String.self)
|
|
||||||
if let date = iso8601WithFractionalSeconds.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
if let date = iso8601Simple.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
throw DecodingError.dataCorruptedError(
|
|
||||||
in: container,
|
|
||||||
debugDescription: "Невозможно декодировать дату: \(string)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func describeDecodingError(error: Error, data: Data) -> String {
|
|
||||||
var parts: [String] = []
|
|
||||||
|
|
||||||
if let decodingError = error as? DecodingError {
|
|
||||||
parts.append(decodingDescription(from: decodingError))
|
|
||||||
} else {
|
|
||||||
parts.append(error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let payload = truncatedPayload(from: data) {
|
|
||||||
parts.append("payload=\(payload)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.joined(separator: "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodingDescription(from error: DecodingError) -> String {
|
|
||||||
switch error {
|
|
||||||
case .typeMismatch(let type, let context):
|
|
||||||
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .valueNotFound(let type, let context):
|
|
||||||
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .keyNotFound(let key, let context):
|
|
||||||
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .dataCorrupted(let context):
|
|
||||||
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
@unknown default:
|
|
||||||
return error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func codingPath(from context: DecodingError.Context) -> String {
|
|
||||||
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
|
|
||||||
return path.isEmpty ? "root" : path.joined(separator: ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
|
|
||||||
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!string.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if string.count <= limit {
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = string.index(string.startIndex, offsetBy: limit)
|
|
||||||
return String(string[string.startIndex..<index]) + "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func errorMessage(from data: Data) -> String? {
|
|
||||||
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
|
||||||
if let detail = apiError.detail, !detail.isEmpty {
|
|
||||||
return detail
|
|
||||||
}
|
|
||||||
if let message = apiError.data?.message, !message.isEmpty {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let iso8601Simple: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [.withInternetDateTime]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct BlockedUserDeleteRequest: Encodable {
|
|
||||||
let userId: UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct BlockedUserCreateRequest: Encodable {
|
|
||||||
let userId: UUID?
|
|
||||||
let login: String?
|
|
||||||
}
|
|
||||||
@ -83,7 +83,6 @@ struct MessageItem: Decodable, Identifiable {
|
|||||||
let content: String?
|
let content: String?
|
||||||
let mediaLink: String?
|
let mediaLink: String?
|
||||||
let isViewed: Bool?
|
let isViewed: Bool?
|
||||||
let viewedAt: Date?
|
|
||||||
let createdAt: Date?
|
let createdAt: Date?
|
||||||
let updatedAt: Date?
|
let updatedAt: Date?
|
||||||
let forwardMetadata: ForwardMetadata?
|
let forwardMetadata: ForwardMetadata?
|
||||||
@ -99,7 +98,6 @@ struct MessageItem: Decodable, Identifiable {
|
|||||||
case content
|
case content
|
||||||
case mediaLink
|
case mediaLink
|
||||||
case isViewed
|
case isViewed
|
||||||
case viewedAt
|
|
||||||
case createdAt
|
case createdAt
|
||||||
case updatedAt
|
case updatedAt
|
||||||
case forwardMetadata
|
case forwardMetadata
|
||||||
@ -115,7 +113,6 @@ struct MessageItem: Decodable, Identifiable {
|
|||||||
self.content = try container.decodeIfPresent(String.self, forKey: .content)
|
self.content = try container.decodeIfPresent(String.self, forKey: .content)
|
||||||
self.mediaLink = try container.decodeIfPresent(String.self, forKey: .mediaLink)
|
self.mediaLink = try container.decodeIfPresent(String.self, forKey: .mediaLink)
|
||||||
self.isViewed = try container.decodeIfPresent(Bool.self, forKey: .isViewed)
|
self.isViewed = try container.decodeIfPresent(Bool.self, forKey: .isViewed)
|
||||||
self.viewedAt = try container.decodeIfPresent(Date.self, forKey: .viewedAt)
|
|
||||||
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||||
self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt)
|
self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt)
|
||||||
self.forwardMetadata = try container.decodeIfPresent(ForwardMetadata.self, forKey: .forwardMetadata)
|
self.forwardMetadata = try container.decodeIfPresent(ForwardMetadata.self, forKey: .forwardMetadata)
|
||||||
@ -130,7 +127,6 @@ struct MessageItem: Decodable, Identifiable {
|
|||||||
content: String?,
|
content: String?,
|
||||||
mediaLink: String?,
|
mediaLink: String?,
|
||||||
isViewed: Bool?,
|
isViewed: Bool?,
|
||||||
viewedAt: Date?,
|
|
||||||
createdAt: Date?,
|
createdAt: Date?,
|
||||||
updatedAt: Date?,
|
updatedAt: Date?,
|
||||||
forwardMetadata: ForwardMetadata?
|
forwardMetadata: ForwardMetadata?
|
||||||
@ -143,7 +139,6 @@ struct MessageItem: Decodable, Identifiable {
|
|||||||
self.content = content
|
self.content = content
|
||||||
self.mediaLink = mediaLink
|
self.mediaLink = mediaLink
|
||||||
self.isViewed = isViewed
|
self.isViewed = isViewed
|
||||||
self.viewedAt = viewedAt
|
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
self.forwardMetadata = forwardMetadata
|
self.forwardMetadata = forwardMetadata
|
||||||
@ -172,12 +167,10 @@ struct ChatProfile: Decodable {
|
|||||||
let bio: String?
|
let bio: String?
|
||||||
let lastSeen: Int?
|
let lastSeen: Int?
|
||||||
let createdAt: Date?
|
let createdAt: Date?
|
||||||
let avatars: Avatars?
|
|
||||||
let stories: [JSONValue]
|
let stories: [JSONValue]
|
||||||
let permissions: ChatPermissions?
|
let permissions: ChatPermissions?
|
||||||
let profilePermissions: ChatProfilePermissions?
|
let profilePermissions: ChatProfilePermissions?
|
||||||
let relationship: RelationshipStatus?
|
let relationship: RelationshipStatus?
|
||||||
let rating: Double?
|
|
||||||
let isOfficial: Bool
|
let isOfficial: Bool
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@ -188,12 +181,10 @@ struct ChatProfile: Decodable {
|
|||||||
case bio
|
case bio
|
||||||
case lastSeen
|
case lastSeen
|
||||||
case createdAt
|
case createdAt
|
||||||
case avatars
|
|
||||||
case stories
|
case stories
|
||||||
case permissions
|
case permissions
|
||||||
case profilePermissions
|
case profilePermissions
|
||||||
case relationship
|
case relationship
|
||||||
case rating
|
|
||||||
case isOfficial
|
case isOfficial
|
||||||
case isVerified
|
case isVerified
|
||||||
}
|
}
|
||||||
@ -207,50 +198,16 @@ struct ChatProfile: Decodable {
|
|||||||
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
|
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
|
||||||
self.lastSeen = try container.decodeIfPresent(Int.self, forKey: .lastSeen)
|
self.lastSeen = try container.decodeIfPresent(Int.self, forKey: .lastSeen)
|
||||||
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||||
self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars)
|
|
||||||
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
|
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
|
||||||
self.permissions = try container.decodeIfPresent(ChatPermissions.self, forKey: .permissions)
|
self.permissions = try container.decodeIfPresent(ChatPermissions.self, forKey: .permissions)
|
||||||
self.profilePermissions = try container.decodeIfPresent(ChatProfilePermissions.self, forKey: .profilePermissions)
|
self.profilePermissions = try container.decodeIfPresent(ChatProfilePermissions.self, forKey: .profilePermissions)
|
||||||
self.relationship = try container.decodeIfPresent(RelationshipStatus.self, forKey: .relationship)
|
self.relationship = try container.decodeIfPresent(RelationshipStatus.self, forKey: .relationship)
|
||||||
let ratingPayload = try container.decodeIfPresent(ChatProfileRatingPayload.self, forKey: .rating)
|
|
||||||
self.rating = ratingPayload?.resolvedRating
|
|
||||||
let explicitOfficial = try container.decodeIfPresent(Bool.self, forKey: .isOfficial)
|
let explicitOfficial = try container.decodeIfPresent(Bool.self, forKey: .isOfficial)
|
||||||
let verifiedFlag = try container.decodeIfPresent(Bool.self, forKey: .isVerified)
|
let verifiedFlag = try container.decodeIfPresent(Bool.self, forKey: .isVerified)
|
||||||
self.isOfficial = explicitOfficial ?? verifiedFlag ?? false
|
self.isOfficial = explicitOfficial ?? verifiedFlag ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ChatProfileRatingPayload: Decodable {
|
|
||||||
let status: String?
|
|
||||||
let rating: Double?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case rating
|
|
||||||
case status
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
status = try container.decodeIfPresent(String.self, forKey: .status)
|
|
||||||
|
|
||||||
if let doubleValue = try? container.decode(Double.self, forKey: .rating) {
|
|
||||||
rating = doubleValue
|
|
||||||
} else if let stringValue = try? container.decode(String.self, forKey: .rating),
|
|
||||||
let doubleValue = Double(stringValue) {
|
|
||||||
rating = doubleValue
|
|
||||||
} else if let intValue = try? container.decode(Int.self, forKey: .rating) {
|
|
||||||
rating = Double(intValue)
|
|
||||||
} else {
|
|
||||||
rating = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resolvedRating: Double? {
|
|
||||||
guard status?.lowercased() == "fine" else { return nil }
|
|
||||||
return rating
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ChatProfile {
|
extension ChatProfile {
|
||||||
init(
|
init(
|
||||||
userId: String,
|
userId: String,
|
||||||
@ -260,12 +217,10 @@ extension ChatProfile {
|
|||||||
bio: String? = nil,
|
bio: String? = nil,
|
||||||
lastSeen: Int? = nil,
|
lastSeen: Int? = nil,
|
||||||
createdAt: Date? = nil,
|
createdAt: Date? = nil,
|
||||||
avatars: Avatars? = nil,
|
|
||||||
stories: [JSONValue] = [],
|
stories: [JSONValue] = [],
|
||||||
permissions: ChatPermissions? = nil,
|
permissions: ChatPermissions? = nil,
|
||||||
profilePermissions: ChatProfilePermissions? = nil,
|
profilePermissions: ChatProfilePermissions? = nil,
|
||||||
relationship: RelationshipStatus? = nil,
|
relationship: RelationshipStatus? = nil,
|
||||||
rating: Double? = nil,
|
|
||||||
isOfficial: Bool = false
|
isOfficial: Bool = false
|
||||||
) {
|
) {
|
||||||
self.userId = userId
|
self.userId = userId
|
||||||
@ -275,29 +230,14 @@ extension ChatProfile {
|
|||||||
self.bio = bio
|
self.bio = bio
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.avatars = avatars
|
|
||||||
self.stories = stories
|
self.stories = stories
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
self.profilePermissions = profilePermissions
|
self.profilePermissions = profilePermissions
|
||||||
self.relationship = relationship
|
self.relationship = relationship
|
||||||
self.rating = rating
|
|
||||||
self.isOfficial = isOfficial
|
self.isOfficial = isOfficial
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AvatarInfo: Decodable {
|
|
||||||
let fileId: String
|
|
||||||
let mime: String?
|
|
||||||
let size: Int?
|
|
||||||
let width: Int?
|
|
||||||
let height: Int?
|
|
||||||
let createdAt: Date?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Avatars: Decodable {
|
|
||||||
let current: AvatarInfo?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ChatPermissions: Decodable {
|
struct ChatPermissions: Decodable {
|
||||||
let youCanSendMessage: Bool
|
let youCanSendMessage: Bool
|
||||||
let youCanPublicInvitePermission: Bool
|
let youCanPublicInvitePermission: Bool
|
||||||
@ -315,39 +255,9 @@ struct ChatProfilePermissions: Decodable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RelationshipStatus: Decodable {
|
struct RelationshipStatus: Decodable {
|
||||||
let isTargetInContactsOfCurrentUser: Bool
|
|
||||||
let isCurrentUserInContactsOfTarget: Bool
|
let isCurrentUserInContactsOfTarget: Bool
|
||||||
let isTargetUserBlockedByCurrentUser: Bool
|
let isTargetUserBlockedByCurrentUser: Bool
|
||||||
let isCurrentUserInBlacklistOfTarget: Bool
|
let isCurrentUserInBlacklistOfTarget: Bool
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case isTargetInContactsOfCurrentUser
|
|
||||||
case isCurrentUserInContactsOfTarget
|
|
||||||
case isTargetUserBlockedByCurrentUser
|
|
||||||
case isCurrentUserInBlacklistOfTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
self.isTargetInContactsOfCurrentUser = try container.decodeIfPresent(Bool.self, forKey: .isTargetInContactsOfCurrentUser) ?? false
|
|
||||||
self.isCurrentUserInContactsOfTarget = try container.decodeIfPresent(Bool.self, forKey: .isCurrentUserInContactsOfTarget) ?? false
|
|
||||||
self.isTargetUserBlockedByCurrentUser = try container.decodeIfPresent(Bool.self, forKey: .isTargetUserBlockedByCurrentUser) ?? false
|
|
||||||
self.isCurrentUserInBlacklistOfTarget = try container.decodeIfPresent(Bool.self, forKey: .isCurrentUserInBlacklistOfTarget) ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RelationshipStatus {
|
|
||||||
init(
|
|
||||||
isTargetInContactsOfCurrentUser: Bool,
|
|
||||||
isCurrentUserInContactsOfTarget: Bool,
|
|
||||||
isTargetUserBlockedByCurrentUser: Bool,
|
|
||||||
isCurrentUserInBlacklistOfTarget: Bool
|
|
||||||
) {
|
|
||||||
self.isTargetInContactsOfCurrentUser = isTargetInContactsOfCurrentUser
|
|
||||||
self.isCurrentUserInContactsOfTarget = isCurrentUserInContactsOfTarget
|
|
||||||
self.isTargetUserBlockedByCurrentUser = isTargetUserBlockedByCurrentUser
|
|
||||||
self.isCurrentUserInBlacklistOfTarget = isCurrentUserInBlacklistOfTarget
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum JSONValue: Decodable {
|
enum JSONValue: Decodable {
|
||||||
|
|||||||
@ -1,366 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum ContactsServiceError: LocalizedError {
|
|
||||||
case unexpectedStatus(String)
|
|
||||||
case decoding(debugDescription: String)
|
|
||||||
case encoding(String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .unexpectedStatus(let message):
|
|
||||||
return message
|
|
||||||
case .decoding(let debugDescription):
|
|
||||||
return AppConfig.DEBUG
|
|
||||||
? debugDescription
|
|
||||||
: NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service decoding error")
|
|
||||||
case .encoding(let message):
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContactPayload: Decodable {
|
|
||||||
let userId: UUID
|
|
||||||
let login: String?
|
|
||||||
let fullName: String?
|
|
||||||
let customName: String?
|
|
||||||
let friendCode: Bool
|
|
||||||
let createdAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContactsListPayload: Decodable {
|
|
||||||
let items: [ContactPayload]
|
|
||||||
let hasMore: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContactCreateRequestPayload: Encodable {
|
|
||||||
let userId: UUID?
|
|
||||||
let login: String?
|
|
||||||
let friendCode: String?
|
|
||||||
let customName: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContactDeleteRequestPayload: Encodable {
|
|
||||||
let userId: UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContactUpdateRequestPayload: Encodable {
|
|
||||||
let userId: UUID
|
|
||||||
let customName: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
final class ContactsService {
|
|
||||||
private let client: NetworkClient
|
|
||||||
private let decoder: JSONDecoder
|
|
||||||
private let encoder: JSONEncoder
|
|
||||||
|
|
||||||
init(client: NetworkClient = .shared) {
|
|
||||||
self.client = client
|
|
||||||
self.decoder = JSONDecoder()
|
|
||||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
|
|
||||||
|
|
||||||
self.encoder = JSONEncoder()
|
|
||||||
self.encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchContacts(limit: Int, offset: Int, completion: @escaping (Result<ContactsListPayload, Error>) -> Void) {
|
|
||||||
client.request(
|
|
||||||
path: "/v1/user/contact/list",
|
|
||||||
method: .get,
|
|
||||||
query: [
|
|
||||||
"limit": String(limit),
|
|
||||||
"offset": String(offset)
|
|
||||||
],
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<ContactsListPayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service unexpected status")
|
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[ContactsService] decode contacts failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchContacts(limit: Int, offset: Int) async throws -> ContactsListPayload {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
fetchContacts(limit: limit, offset: offset) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addContact(userId: UUID, customName: String?, completion: @escaping (Result<ContactPayload, Error>) -> Void) {
|
|
||||||
let request = ContactCreateRequestPayload(
|
|
||||||
userId: userId,
|
|
||||||
login: nil,
|
|
||||||
friendCode: nil,
|
|
||||||
customName: customName
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let body = try? encoder.encode(request) else {
|
|
||||||
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Contacts service encoding error")
|
|
||||||
completion(.failure(ContactsServiceError.encoding(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.request(
|
|
||||||
path: "/v1/user/contact/add",
|
|
||||||
method: .post,
|
|
||||||
body: body,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<ContactPayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось добавить контакт.", comment: "Contacts service add unexpected status")
|
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[ContactsService] decode contact add failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addContact(userId: UUID, customName: String?) async throws -> ContactPayload {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
addContact(userId: userId, customName: customName) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeContact(userId: UUID, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
||||||
let request = ContactDeleteRequestPayload(userId: userId)
|
|
||||||
|
|
||||||
guard let body = try? encoder.encode(request) else {
|
|
||||||
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Contacts service encoding error")
|
|
||||||
completion(.failure(ContactsServiceError.encoding(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.request(
|
|
||||||
path: "/v1/user/contact/remove",
|
|
||||||
method: .delete,
|
|
||||||
body: body,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось удалить контакт.", comment: "Contacts service delete unexpected status")
|
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(()))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[ContactsService] decode contact delete failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeContact(userId: UUID) async throws {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
removeContact(userId: userId) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateContact(userId: UUID, customName: String?, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
||||||
let request = ContactUpdateRequestPayload(userId: userId, customName: customName)
|
|
||||||
|
|
||||||
guard let body = try? encoder.encode(request) else {
|
|
||||||
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Contacts service encoding error")
|
|
||||||
completion(.failure(ContactsServiceError.encoding(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client.request(
|
|
||||||
path: "/v1/user/contact/update",
|
|
||||||
method: .patch,
|
|
||||||
body: body,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить контакт.", comment: "Contacts service update unexpected status")
|
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(()))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[ContactsService] decode contact update failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateContact(userId: UUID, customName: String?) async throws {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
updateContact(userId: userId, customName: customName) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodeDate(from decoder: Decoder) throws -> Date {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let string = try container.decode(String.self)
|
|
||||||
if let date = iso8601WithFractionalSeconds.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
if let date = iso8601Simple.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
throw DecodingError.dataCorruptedError(
|
|
||||||
in: container,
|
|
||||||
debugDescription: "Невозможно декодировать дату: \(string)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func describeDecodingError(error: Error, data: Data) -> String {
|
|
||||||
var parts: [String] = []
|
|
||||||
|
|
||||||
if let decodingError = error as? DecodingError {
|
|
||||||
parts.append(decodingDescription(from: decodingError))
|
|
||||||
} else {
|
|
||||||
parts.append(error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let payload = truncatedPayload(from: data) {
|
|
||||||
parts.append("payload=\(payload)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.joined(separator: "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodingDescription(from error: DecodingError) -> String {
|
|
||||||
switch error {
|
|
||||||
case .typeMismatch(let type, let context):
|
|
||||||
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .valueNotFound(let type, let context):
|
|
||||||
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .keyNotFound(let key, let context):
|
|
||||||
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .dataCorrupted(let context):
|
|
||||||
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
@unknown default:
|
|
||||||
return error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func codingPath(from context: DecodingError.Context) -> String {
|
|
||||||
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
|
|
||||||
return path.isEmpty ? "root" : path.joined(separator: ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
|
|
||||||
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!string.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if string.count <= limit {
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = string.index(string.startIndex, offsetBy: limit)
|
|
||||||
return String(string[string.startIndex..<index]) + "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func errorMessage(from data: Data) -> String? {
|
|
||||||
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
|
||||||
if let detail = apiError.detail, !detail.isEmpty {
|
|
||||||
return detail
|
|
||||||
}
|
|
||||||
if let message = apiError.data?.message, !message.isEmpty {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let iso8601Simple: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [.withInternetDateTime]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@ -5,26 +5,20 @@ struct ProfileDataPayload: Decodable {
|
|||||||
let login: String
|
let login: String
|
||||||
let fullName: String?
|
let fullName: String?
|
||||||
let bio: String?
|
let bio: String?
|
||||||
let avatars: Avatars?
|
|
||||||
let balances: [WalletBalancePayload]
|
let balances: [WalletBalancePayload]
|
||||||
let createdAt: Date?
|
let createdAt: Date?
|
||||||
let isVerified: Bool
|
|
||||||
let stories: [JSONValue]
|
let stories: [JSONValue]
|
||||||
let profilePermissions: ProfilePermissionsPayload
|
let profilePermissions: ProfilePermissionsPayload
|
||||||
let rating: Double?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case userId
|
case userId
|
||||||
case login
|
case login
|
||||||
case fullName
|
case fullName
|
||||||
case bio
|
case bio
|
||||||
case avatars
|
|
||||||
case balances
|
case balances
|
||||||
case createdAt
|
case createdAt
|
||||||
case isVerified
|
|
||||||
case stories
|
case stories
|
||||||
case profilePermissions
|
case profilePermissions
|
||||||
case rating
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@ -33,61 +27,13 @@ struct ProfileDataPayload: Decodable {
|
|||||||
self.login = try container.decode(String.self, forKey: .login)
|
self.login = try container.decode(String.self, forKey: .login)
|
||||||
self.fullName = try container.decodeIfPresent(String.self, forKey: .fullName)
|
self.fullName = try container.decodeIfPresent(String.self, forKey: .fullName)
|
||||||
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
|
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
|
||||||
self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars)
|
|
||||||
self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? []
|
self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? []
|
||||||
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||||
self.isVerified = try container.decode(Bool.self, forKey: .isVerified)
|
|
||||||
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
|
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
|
||||||
self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions)
|
self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions)
|
||||||
let ratingPayload = try container.decodeIfPresent(ProfileRatingPayload.self, forKey: .rating)
|
|
||||||
self.rating = ratingPayload?.resolvedRating
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ProfileRatingPayload: Decodable {
|
|
||||||
let status: String?
|
|
||||||
let rating: Double?
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case rating
|
|
||||||
case status
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
status = try container.decodeIfPresent(String.self, forKey: .status)
|
|
||||||
|
|
||||||
if let doubleValue = try? container.decode(Double.self, forKey: .rating) {
|
|
||||||
rating = doubleValue
|
|
||||||
} else if let stringValue = try? container.decode(String.self, forKey: .rating),
|
|
||||||
let doubleValue = Double(stringValue) {
|
|
||||||
rating = doubleValue
|
|
||||||
} else if let intValue = try? container.decode(Int.self, forKey: .rating) {
|
|
||||||
rating = Double(intValue)
|
|
||||||
} else {
|
|
||||||
rating = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resolvedRating: Double? {
|
|
||||||
guard status?.lowercased() == "fine" else { return nil }
|
|
||||||
return rating
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//struct AvatarInfo: Decodable {
|
|
||||||
// let fileId: String
|
|
||||||
// let mime: String?
|
|
||||||
// let size: Int?
|
|
||||||
// let width: Int?
|
|
||||||
// let height: Int?
|
|
||||||
// let createdAt: Date?
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//struct Avatars: Decodable {
|
|
||||||
// let current: AvatarInfo?
|
|
||||||
//}
|
|
||||||
|
|
||||||
struct WalletBalancePayload: Decodable {
|
struct WalletBalancePayload: Decodable {
|
||||||
let currency: String
|
let currency: String
|
||||||
let balance: Decimal
|
let balance: Decimal
|
||||||
@ -210,39 +156,6 @@ struct ProfilePermissionsRequestPayload: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfilePermissionsRequestPayload {
|
|
||||||
init(payload: ProfilePermissionsPayload) {
|
|
||||||
self.init(
|
|
||||||
isSearchable: payload.isSearchable,
|
|
||||||
allowMessageForwarding: payload.allowMessageForwarding,
|
|
||||||
allowMessagesFromNonContacts: payload.allowMessagesFromNonContacts,
|
|
||||||
showProfilePhotoToNonContacts: payload.showProfilePhotoToNonContacts,
|
|
||||||
lastSeenVisibility: payload.lastSeenVisibility,
|
|
||||||
showBioToNonContacts: payload.showBioToNonContacts,
|
|
||||||
showStoriesToNonContacts: payload.showStoriesToNonContacts,
|
|
||||||
allowServerChats: payload.allowServerChats,
|
|
||||||
publicInvitePermission: payload.publicInvitePermission,
|
|
||||||
groupInvitePermission: payload.groupInvitePermission,
|
|
||||||
callPermission: payload.callPermission,
|
|
||||||
forceAutoDeleteMessagesInPrivate: payload.forceAutoDeleteMessagesInPrivate,
|
|
||||||
maxMessageAutoDeleteSeconds: payload.maxMessageAutoDeleteSeconds,
|
|
||||||
autoDeleteAfterDays: payload.autoDeleteAfterDays
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ProfileUpdateRequestPayload: Encodable {
|
struct ProfileUpdateRequestPayload: Encodable {
|
||||||
let fullName: String?
|
|
||||||
let bio: String?
|
|
||||||
let profilePermissions: ProfilePermissionsRequestPayload
|
let profilePermissions: ProfilePermissionsRequestPayload
|
||||||
|
|
||||||
init(fullName: String? = nil, bio: String? = nil, profilePermissions: ProfilePermissionsRequestPayload) {
|
|
||||||
self.fullName = fullName
|
|
||||||
self.bio = bio
|
|
||||||
self.profilePermissions = profilePermissions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UploadAvatarPayload: Decodable {
|
|
||||||
let fileId: String
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
|
||||||
|
|
||||||
enum ProfileServiceError: LocalizedError {
|
enum ProfileServiceError: LocalizedError {
|
||||||
case unexpectedStatus(String)
|
case unexpectedStatus(String)
|
||||||
@ -74,57 +73,6 @@ final class ProfileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchProfile(userId: UUID, completion: @escaping (Result<ChatProfile, Error>) -> Void) {
|
|
||||||
fetchProfile(userId: userId.uuidString, completion: completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchProfile(userId: String, completion: @escaping (Result<ChatProfile, Error>) -> Void) {
|
|
||||||
let sanitizedId = userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
|
|
||||||
client.request(
|
|
||||||
path: "/v1/profile/\(sanitizedId)",
|
|
||||||
method: .get,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let profile = try Self.decodeProfileResponse(
|
|
||||||
data: response.data,
|
|
||||||
decoder: decoder,
|
|
||||||
requestedId: sanitizedId
|
|
||||||
)
|
|
||||||
completion(.success(profile))
|
|
||||||
} catch {
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[ProfileService] decode profile by id failed: \(error)")
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchProfile(userId: UUID) async throws -> ChatProfile {
|
|
||||||
try await fetchProfile(userId: userId.uuidString)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchProfile(userId: String) async throws -> ChatProfile {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
fetchProfile(userId: userId, completion: { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateProfile(_ payload: ProfileUpdateRequestPayload, completion: @escaping (Result<String, Error>) -> Void) {
|
func updateProfile(_ payload: ProfileUpdateRequestPayload, completion: @escaping (Result<String, Error>) -> Void) {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||||
@ -185,126 +133,6 @@ final class ProfileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadAvatar(image: UIImage, completion: @escaping (Result<String, Error>) -> Void) {
|
|
||||||
guard let imageData = image.jpegData(compressionQuality: 0.9) else {
|
|
||||||
let message = NSLocalizedString("Не удалось подготовить изображение для загрузки.", comment: "Avatar encoding error")
|
|
||||||
completion(.failure(ProfileServiceError.encoding(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let boundary = "Boundary-\(UUID().uuidString)"
|
|
||||||
let body = Self.makeMultipartBody(
|
|
||||||
data: imageData,
|
|
||||||
boundary: boundary,
|
|
||||||
fieldName: "file",
|
|
||||||
filename: "avatar.jpg",
|
|
||||||
mimeType: "image/jpeg"
|
|
||||||
)
|
|
||||||
|
|
||||||
client.request(
|
|
||||||
path: "/v1/storage/upload/avatar",
|
|
||||||
method: .post,
|
|
||||||
body: body,
|
|
||||||
contentType: "multipart/form-data; boundary=\(boundary)",
|
|
||||||
requiresAuth: true
|
|
||||||
) { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<UploadAvatarPayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить аватар.", comment: "Avatar upload unexpected status")
|
|
||||||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data.fileId))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[ProfileService] decode upload avatar failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
} else {
|
|
||||||
let message = NSLocalizedString("Не удалось обработать ответ сервера.", comment: "Avatar upload decode error")
|
|
||||||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadAvatar(image: UIImage) async throws -> String {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
uploadAvatar(image: image) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodeProfileResponse(
|
|
||||||
data: Data,
|
|
||||||
decoder: JSONDecoder,
|
|
||||||
requestedId: String
|
|
||||||
) throws -> ChatProfile {
|
|
||||||
let defaultErrorMessage = NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile unexpected status")
|
|
||||||
var dictionaryDecodeError: String?
|
|
||||||
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<[String: ChatProfile]>.self, from: data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? defaultErrorMessage
|
|
||||||
throw ProfileServiceError.unexpectedStatus(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalizedKey = requestedId.lowercased()
|
|
||||||
if let profile = apiResponse.data[requestedId]
|
|
||||||
?? apiResponse.data[normalizedKey]
|
|
||||||
?? apiResponse.data[requestedId.uppercased()]
|
|
||||||
?? apiResponse.data.first?.value {
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ProfileServiceError.unexpectedStatus(
|
|
||||||
NSLocalizedString("Профиль не найден.", comment: "Profile by id missing")
|
|
||||||
)
|
|
||||||
} catch let error as ProfileServiceError {
|
|
||||||
throw error
|
|
||||||
} catch {
|
|
||||||
dictionaryDecodeError = Self.describeDecodingError(error: error, data: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<ChatProfile>.self, from: data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? defaultErrorMessage
|
|
||||||
throw ProfileServiceError.unexpectedStatus(message)
|
|
||||||
}
|
|
||||||
return apiResponse.data
|
|
||||||
} catch let error as ProfileServiceError {
|
|
||||||
throw error
|
|
||||||
} catch {
|
|
||||||
let singleError = Self.describeDecodingError(error: error, data: data)
|
|
||||||
let combined: String
|
|
||||||
if let dictionaryDecodeError {
|
|
||||||
combined = dictionaryDecodeError + "\nOR\n" + singleError
|
|
||||||
} else {
|
|
||||||
combined = singleError
|
|
||||||
}
|
|
||||||
throw ProfileServiceError.decoding(debugDescription: combined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodeDate(from decoder: Decoder) throws -> Date {
|
private static func decodeDate(from decoder: Decoder) throws -> Date {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
let string = try container.decode(String.self)
|
let string = try container.decode(String.self)
|
||||||
@ -370,31 +198,6 @@ final class ProfileService {
|
|||||||
return String(string[string.startIndex..<index]) + "…"
|
return String(string[string.startIndex..<index]) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeMultipartBody(
|
|
||||||
data: Data,
|
|
||||||
boundary: String,
|
|
||||||
fieldName: String,
|
|
||||||
filename: String,
|
|
||||||
mimeType: String
|
|
||||||
) -> Data {
|
|
||||||
var body = Data()
|
|
||||||
let lineBreak = "\r\n"
|
|
||||||
if let boundaryData = "--\(boundary)\(lineBreak)".data(using: .utf8) {
|
|
||||||
body.append(boundaryData)
|
|
||||||
}
|
|
||||||
if let dispositionData = "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(filename)\"\(lineBreak)".data(using: .utf8) {
|
|
||||||
body.append(dispositionData)
|
|
||||||
}
|
|
||||||
if let typeData = "Content-Type: \(mimeType)\(lineBreak)\(lineBreak)".data(using: .utf8) {
|
|
||||||
body.append(typeData)
|
|
||||||
}
|
|
||||||
body.append(data)
|
|
||||||
if let closingData = "\(lineBreak)--\(boundary)--\(lineBreak)".data(using: .utf8) {
|
|
||||||
body.append(closingData)
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func errorMessage(from data: Data) -> String? {
|
private static func errorMessage(from data: Data) -> String? {
|
||||||
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
||||||
if let detail = apiError.detail, !detail.isEmpty {
|
if let detail = apiError.detail, !detail.isEmpty {
|
||||||
|
|||||||
@ -63,27 +63,14 @@ extension UserSearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var avatarInitial: String {
|
var avatarInitial: String {
|
||||||
let nameSource: String?
|
let source = preferredCustomName
|
||||||
if let customName = preferredCustomName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
?? officialFullName
|
||||||
nameSource = customName
|
?? login
|
||||||
} else if let fullName = officialFullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
?? userId.uuidString
|
||||||
nameSource = fullName
|
|
||||||
} else {
|
|
||||||
nameSource = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let name = nameSource {
|
if let character = source.first(where: { !$0.isWhitespace && $0 != "@" }) {
|
||||||
let components = name.split(separator: " ")
|
return String(character).uppercased()
|
||||||
let nameInitials = components.prefix(2).compactMap { $0.first }
|
|
||||||
if !nameInitials.isEmpty {
|
|
||||||
return nameInitials.map { String($0) }.joined().uppercased()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let login = login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
return String(login.prefix(1)).uppercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
return "?"
|
return "?"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,309 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SessionsServiceError: LocalizedError {
|
|
||||||
case unexpectedStatus(String)
|
|
||||||
case decoding(debugDescription: String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .unexpectedStatus(let message):
|
|
||||||
return message
|
|
||||||
case .decoding(let debugDescription):
|
|
||||||
return AppConfig.DEBUG
|
|
||||||
? debugDescription
|
|
||||||
: NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service decoding error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct UserSessionPayload: Decodable {
|
|
||||||
let id: UUID
|
|
||||||
let ipAddress: String?
|
|
||||||
let userAgent: String?
|
|
||||||
let clientType: String
|
|
||||||
let isActive: Bool
|
|
||||||
let createdAt: Date
|
|
||||||
let lastRefreshAt: Date
|
|
||||||
let isCurrent: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SessionsListPayload: Decodable {
|
|
||||||
let sessions: [UserSessionPayload]
|
|
||||||
}
|
|
||||||
|
|
||||||
final class SessionsService {
|
|
||||||
private let client: NetworkClient
|
|
||||||
private let decoder: JSONDecoder
|
|
||||||
|
|
||||||
init(client: NetworkClient = .shared) {
|
|
||||||
self.client = client
|
|
||||||
self.decoder = JSONDecoder()
|
|
||||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchSessions(completion: @escaping (Result<[UserSessionPayload], Error>) -> Void) {
|
|
||||||
client.request(
|
|
||||||
path: "/v1/auth/sessions/list",
|
|
||||||
method: .get,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<SessionsListPayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service unexpected status")
|
|
||||||
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data.sessions))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[SessionsService] decode sessions failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchSessions() async throws -> [UserSessionPayload] {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
fetchSessions { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func revokeAllExceptCurrent(completion: @escaping (Result<String, Error>) -> Void) {
|
|
||||||
client.request(
|
|
||||||
path: "/v1/auth/sessions/revoke_all_except_current",
|
|
||||||
method: .post,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось завершить другие сессии.", comment: "Sessions service revoke-all unexpected status")
|
|
||||||
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data.message))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[SessionsService] decode revoke-all failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func revokeAllExceptCurrent() async throws -> String {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
revokeAllExceptCurrent { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func revoke(sessionId: UUID, completion: @escaping (Result<String, Error>) -> Void) {
|
|
||||||
client.request(
|
|
||||||
path: "/v1/auth/sessions/revoke/\(sessionId.uuidString)",
|
|
||||||
method: .post,
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось завершить сессию.", comment: "Sessions service revoke unexpected status")
|
|
||||||
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data.message))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[SessionsService] decode revoke failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func revoke(sessionId: UUID) async throws -> String {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
revoke(sessionId: sessionId) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePushToken(_ token: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
||||||
client.request(
|
|
||||||
path: "/v1/auth/sessions/update_push_token",
|
|
||||||
method: .post,
|
|
||||||
query: ["fcm_token": token],
|
|
||||||
requiresAuth: true
|
|
||||||
) { [decoder] result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
do {
|
|
||||||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
|
||||||
guard apiResponse.status == "fine" else {
|
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить push-токен.", comment: "Sessions service update push unexpected status")
|
|
||||||
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.success(apiResponse.data.message))
|
|
||||||
} catch {
|
|
||||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[SessionsService] decode update-push failed: \(debugMessage)")
|
|
||||||
}
|
|
||||||
completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if case let NetworkError.server(_, data) = error,
|
|
||||||
let data,
|
|
||||||
let message = Self.errorMessage(from: data) {
|
|
||||||
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(.failure(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePushToken(_ token: String) async throws -> String {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
updatePushToken(token) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodeDate(from decoder: Decoder) throws -> Date {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let string = try container.decode(String.self)
|
|
||||||
if let date = iso8601WithFractionalSeconds.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
if let date = iso8601Simple.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
throw DecodingError.dataCorruptedError(
|
|
||||||
in: container,
|
|
||||||
debugDescription: "Невозможно декодировать дату: \(string)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func describeDecodingError(error: Error, data: Data) -> String {
|
|
||||||
var parts: [String] = []
|
|
||||||
|
|
||||||
if let decodingError = error as? DecodingError {
|
|
||||||
parts.append(decodingDescription(from: decodingError))
|
|
||||||
} else {
|
|
||||||
parts.append(error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let payload = truncatedPayload(from: data) {
|
|
||||||
parts.append("payload=\(payload)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.joined(separator: "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodingDescription(from error: DecodingError) -> String {
|
|
||||||
switch error {
|
|
||||||
case .typeMismatch(let type, let context):
|
|
||||||
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .valueNotFound(let type, let context):
|
|
||||||
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .keyNotFound(let key, let context):
|
|
||||||
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
case .dataCorrupted(let context):
|
|
||||||
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
|
|
||||||
@unknown default:
|
|
||||||
return error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func codingPath(from context: DecodingError.Context) -> String {
|
|
||||||
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
|
|
||||||
return path.isEmpty ? "root" : path.joined(separator: ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
|
|
||||||
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!string.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if string.count <= limit {
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = string.index(string.startIndex, offsetBy: limit)
|
|
||||||
return String(string[string.startIndex..<index]) + "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func errorMessage(from data: Data) -> String? {
|
|
||||||
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
|
||||||
if let detail = apiError.detail, !detail.isEmpty {
|
|
||||||
return detail
|
|
||||||
}
|
|
||||||
if let message = apiError.data?.message, !message.isEmpty {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let iso8601Simple: ISO8601DateFormatter = {
|
|
||||||
let formatter = ISO8601DateFormatter()
|
|
||||||
formatter.formatOptions = [.withInternetDateTime]
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
////
|
|
||||||
//// PushAppDelegate.swift
|
|
||||||
//// yobble
|
|
||||||
////
|
|
||||||
//// Created by cheykrym on 02.12.2025.
|
|
||||||
//// 72acf38bfbf0e990f745a612527911f8df1d63d60de70d41391c54b52498f7ab
|
|
||||||
//
|
|
||||||
//import UIKit
|
|
||||||
//import UserNotifications
|
|
||||||
//
|
|
||||||
//class PushAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
||||||
//
|
|
||||||
// // Запрос разрешения на уведомления
|
|
||||||
// func application(_ application: UIApplication,
|
|
||||||
// didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
|
||||||
//
|
|
||||||
// UNUserNotificationCenter.current().delegate = self
|
|
||||||
//
|
|
||||||
// UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
|
||||||
// guard granted else {
|
|
||||||
// print("⛔️ User denied push notifications")
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// UIApplication.shared.registerForRemoteNotifications()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // Получаем пуш-токен устройства
|
|
||||||
// func application(_ application: UIApplication,
|
|
||||||
// didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
||||||
//
|
|
||||||
// let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
|
||||||
// print("📨 Device Token:", token)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // Ошибка регистрации
|
|
||||||
// func application(_ application: UIApplication,
|
|
||||||
// didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
||||||
// print("❌ Failed to register for remote notifications:", error)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // Пуш пришёл в форграунде
|
|
||||||
// func userNotificationCenter(_ center: UNUserNotificationCenter,
|
|
||||||
// willPresent notification: UNNotification,
|
|
||||||
// withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
|
||||||
//
|
|
||||||
// // показываем алерт даже если приложение открыто
|
|
||||||
// completionHandler([.banner, .sound, .badge])
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 843 B After Width: | Height: | Size: 720 B |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1018 B |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.6 KiB |
@ -1,202 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
struct AppUpdateNotice: Identifiable {
|
|
||||||
enum Kind {
|
|
||||||
case need
|
|
||||||
case force
|
|
||||||
case soft
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = UUID()
|
|
||||||
let kind: Kind
|
|
||||||
let appStoreURL: URL
|
|
||||||
let skipBuild: Int?
|
|
||||||
|
|
||||||
init(kind: Kind, appStoreURL: URL, skipBuild: Int? = nil) {
|
|
||||||
self.kind = kind
|
|
||||||
self.appStoreURL = appStoreURL
|
|
||||||
self.skipBuild = skipBuild
|
|
||||||
}
|
|
||||||
|
|
||||||
var canSkip: Bool { skipBuild != nil }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch kind {
|
|
||||||
case .need:
|
|
||||||
return NSLocalizedString("Обновление обязательно", comment: "Need update alert title")
|
|
||||||
case .force:
|
|
||||||
return NSLocalizedString("Рекомендуется обновление", comment: "Force update alert title")
|
|
||||||
case .soft:
|
|
||||||
return NSLocalizedString("Доступно обновление", comment: "Soft update alert title")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var message: String {
|
|
||||||
switch kind {
|
|
||||||
case .need:
|
|
||||||
return NSLocalizedString("Для продолжения работы необходимо обновить приложение до последней версии.", comment: "Need update alert message")
|
|
||||||
case .force:
|
|
||||||
return NSLocalizedString("Эта версия приложения устарела. Некоторые функции могут работать некорректно.", comment: "Force update alert message")
|
|
||||||
case .soft:
|
|
||||||
return NSLocalizedString("Вышла новая версия приложения с улучшениями и исправлениями.", comment: "Soft update alert message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class AppUpdateChecker: ObservableObject {
|
|
||||||
@AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
|
|
||||||
@AppStorage("lastCheckedAppBuild") private var lastCheckedAppBuild: Int = 0
|
|
||||||
|
|
||||||
@Published private(set) var needUpdateNotice: AppUpdateNotice?
|
|
||||||
@Published private(set) var softUpdateNotice: AppUpdateNotice?
|
|
||||||
@Published private(set) var forceUpdateNotice: AppUpdateNotice?
|
|
||||||
|
|
||||||
private let session: URLSession
|
|
||||||
private var didStartCheck = false
|
|
||||||
|
|
||||||
init(session: URLSession = .shared) {
|
|
||||||
self.session = session
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkForUpdatesIfNeeded() {
|
|
||||||
guard !didStartCheck else { return }
|
|
||||||
didStartCheck = true
|
|
||||||
|
|
||||||
Task { await fetchRemoteConfig() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func dismissSoftUpdateIfNeeded(skipBuild: Int? = nil) {
|
|
||||||
if let skipBuild {
|
|
||||||
lastCheckedAppBuild = skipBuild
|
|
||||||
}
|
|
||||||
softUpdateNotice = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func openAppStore(link overrideURL: URL? = nil) {
|
|
||||||
guard let url = overrideURL
|
|
||||||
?? needUpdateNotice?.appStoreURL
|
|
||||||
?? forceUpdateNotice?.appStoreURL
|
|
||||||
?? softUpdateNotice?.appStoreURL else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchRemoteConfig() async {
|
|
||||||
guard
|
|
||||||
let buildType = AppConfig.APP_BUILD.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
|
||||||
let url = URL(string: "https://static.yobble.org/config/ios/\(buildType).json")
|
|
||||||
else {
|
|
||||||
log("Unable to build remote config URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await session.data(from: url)
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
|
|
||||||
log("Unexpected response when fetching remote config")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
let remoteConfig = try decoder.decode(RemoteBuildConfiguration.self, from: data)
|
|
||||||
await MainActor.run {
|
|
||||||
self.apply(remoteConfig)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
log("Failed to fetch remote config: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func apply(_ config: RemoteBuildConfiguration) {
|
|
||||||
guard let buildNumber = currentBuildNumber() else {
|
|
||||||
log("Unable to read current build number")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
needUpdateNotice = nil
|
|
||||||
forceUpdateNotice = nil
|
|
||||||
softUpdateNotice = nil
|
|
||||||
|
|
||||||
guard let appStoreURL = config.appStoreURL else {
|
|
||||||
log("Config missing App Store URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// print("buildNumber", buildNumber)
|
|
||||||
// print("config", config.notSupportedBuild, config.minSupportedBuild, config.recommendedBuild)
|
|
||||||
|
|
||||||
let requiresNeedUpdate = buildNumber <= config.notSupportedBuild
|
|
||||||
if requiresNeedUpdate {
|
|
||||||
isAppBlocked = true
|
|
||||||
needUpdateNotice = AppUpdateNotice(kind: .need, appStoreURL: appStoreURL)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
isAppBlocked = false
|
|
||||||
}
|
|
||||||
|
|
||||||
let requiresForcedUpdate = buildNumber < config.minSupportedBuild
|
|
||||||
if requiresForcedUpdate {
|
|
||||||
softUpdateNotice = AppUpdateNotice(kind: .force, appStoreURL: appStoreURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if buildNumber < config.recommendedBuild && config.recommendedBuild != lastCheckedAppBuild {
|
|
||||||
// lastCheckedAppBuild = config.recommendedBuild
|
|
||||||
softUpdateNotice = AppUpdateNotice(
|
|
||||||
kind: .soft,
|
|
||||||
appStoreURL: appStoreURL,
|
|
||||||
skipBuild: config.recommendedBuild
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func currentBuildNumber() -> Int? {
|
|
||||||
guard let rawValue = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return Int(rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func log(_ message: String) {
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[AppUpdateChecker]", message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RemoteBuildConfiguration: Decodable {
|
|
||||||
let schemaVersion: Int
|
|
||||||
let notSupportedBuild: Int
|
|
||||||
let minSupportedBuild: Int
|
|
||||||
let recommendedBuild: Int
|
|
||||||
let appStoreURL: URL?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case schemaVersion = "schema_version"
|
|
||||||
case notSupportedBuild = "not_supported_build"
|
|
||||||
case minSupportedBuild = "min_supported_build"
|
|
||||||
case recommendedBuild = "recommended_build"
|
|
||||||
case appStoreURL = "appstore_url"
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
|
||||||
notSupportedBuild = try container.decode(Int.self, forKey: .notSupportedBuild)
|
|
||||||
minSupportedBuild = try container.decode(Int.self, forKey: .minSupportedBuild)
|
|
||||||
recommendedBuild = try container.decode(Int.self, forKey: .recommendedBuild)
|
|
||||||
if let urlString = try container.decodeIfPresent(String.self, forKey: .appStoreURL) {
|
|
||||||
appStoreURL = URL(string: urlString)
|
|
||||||
} else {
|
|
||||||
appStoreURL = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
struct ChatNavigationTarget: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let chat: PrivateChatListItem
|
|
||||||
}
|
|
||||||
|
|
||||||
final class IncomingMessageCenter: ObservableObject {
|
final class IncomingMessageCenter: ObservableObject {
|
||||||
@Published private(set) var banner: IncomingMessageBanner?
|
@Published private(set) var banner: IncomingMessageBanner?
|
||||||
@Published var presentedChat: PrivateChatListItem?
|
@Published var presentedChat: PrivateChatListItem?
|
||||||
@Published var pendingNavigation: ChatNavigationTarget?
|
|
||||||
var currentUserId: String?
|
var currentUserId: String?
|
||||||
var activeChatId: String?
|
var activeChatId: String?
|
||||||
|
|
||||||
@ -38,13 +32,7 @@ final class IncomingMessageCenter: ObservableObject {
|
|||||||
guard let banner else { return }
|
guard let banner else { return }
|
||||||
activeChatId = banner.message.chatId
|
activeChatId = banner.message.chatId
|
||||||
let chatItem = makeChatItem(from: banner.message)
|
let chatItem = makeChatItem(from: banner.message)
|
||||||
if AppConfig.PRESENT_CHAT_AS_SHEET {
|
presentedChat = chatItem
|
||||||
presentedChat = chatItem
|
|
||||||
pendingNavigation = nil
|
|
||||||
} else {
|
|
||||||
pendingNavigation = ChatNavigationTarget(chat: chatItem)
|
|
||||||
presentedChat = nil
|
|
||||||
}
|
|
||||||
dismissBanner()
|
dismissBanner()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,8 +45,7 @@ final class IncomingMessageCenter: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if AppConfig.PRESENT_CHAT_AS_SHEET,
|
if let presentedChat,
|
||||||
let presentedChat,
|
|
||||||
presentedChat.chatId == message.chatId {
|
presentedChat.chatId == message.chatId {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUICore
|
|
||||||
import Security
|
import Security
|
||||||
import UIKit
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
|
|
||||||
//let username = "user1"
|
//let username = "user1"
|
||||||
@ -114,163 +111,3 @@ class KeychainService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AvatarCacheService {
|
|
||||||
static let shared = AvatarCacheService()
|
|
||||||
private let fileManager = FileManager.default
|
|
||||||
private var baseCacheDirectory: URL? {
|
|
||||||
fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("avatar_cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
private func cacheDirectory(for userId: String) -> URL? {
|
|
||||||
baseCacheDirectory?.appendingPathComponent(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func filePath(forKey key: String, userId: String) -> URL? {
|
|
||||||
cacheDirectory(for: userId)?.appendingPathComponent(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getImage(forKey key: String, userId: String) -> UIImage? {
|
|
||||||
guard let url = filePath(forKey: key, userId: userId),
|
|
||||||
fileManager.fileExists(atPath: url.path),
|
|
||||||
let data = try? Data(contentsOf: url) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return UIImage(data: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveImage(_ image: UIImage, forKey key: String, userId: String) {
|
|
||||||
guard let directory = cacheDirectory(for: userId),
|
|
||||||
let url = filePath(forKey: key, userId: userId),
|
|
||||||
let data = image.jpegData(compressionQuality: 0.8) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !fileManager.fileExists(atPath: directory.path) {
|
|
||||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
try? data.write(to: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearCache(forUserId userId: String) {
|
|
||||||
guard let directory = cacheDirectory(for: userId) else { return }
|
|
||||||
|
|
||||||
// Try to delete files inside first, ignoring errors
|
|
||||||
if let fileUrls = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: []) {
|
|
||||||
for fileUrl in fileUrls {
|
|
||||||
try? fileManager.removeItem(at: fileUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then try to delete the directory itself
|
|
||||||
try? fileManager.removeItem(at: directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearAllCache() {
|
|
||||||
guard let directory = baseCacheDirectory else { return }
|
|
||||||
try? fileManager.removeItem(at: directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAllCachedUserIds() -> [String] {
|
|
||||||
guard let baseDir = baseCacheDirectory else { return [] }
|
|
||||||
do {
|
|
||||||
let directoryContents = try fileManager.contentsOfDirectory(at: baseDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
|
||||||
return directoryContents.map { $0.lastPathComponent }
|
|
||||||
} catch {
|
|
||||||
// This can happen if the directory doesn't exist yet, which is not an error.
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sizeOfCache(forUserId userId: String) -> Int64 {
|
|
||||||
guard let directory = cacheDirectory(for: userId) else { return 0 }
|
|
||||||
return directorySize(url: directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sizeOfAllCache() -> Int64 {
|
|
||||||
guard let directory = baseCacheDirectory else { return 0 }
|
|
||||||
return directorySize(url: directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func directorySize(url: URL) -> Int64 {
|
|
||||||
let contents: [URL]
|
|
||||||
do {
|
|
||||||
contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey], options: .skipsHiddenFiles)
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalSize: Int64 = 0
|
|
||||||
for url in contents {
|
|
||||||
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
|
|
||||||
totalSize += Int64(fileSize)
|
|
||||||
}
|
|
||||||
return totalSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ImageLoader: ObservableObject {
|
|
||||||
@Published var image: UIImage?
|
|
||||||
|
|
||||||
private let url: URL
|
|
||||||
private let fileId: String
|
|
||||||
private let userId: String
|
|
||||||
private var cancellable: AnyCancellable?
|
|
||||||
private let cache = AvatarCacheService.shared
|
|
||||||
|
|
||||||
init(url: URL, fileId: String, userId: String) {
|
|
||||||
self.url = url
|
|
||||||
self.fileId = fileId
|
|
||||||
self.userId = userId
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
cancellable?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
func load() {
|
|
||||||
if let cachedImage = cache.getImage(forKey: fileId, userId: userId) {
|
|
||||||
self.image = cachedImage
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cancellable = URLSession.shared.dataTaskPublisher(for: url)
|
|
||||||
.map { UIImage(data: $0.data) }
|
|
||||||
.replaceError(with: nil)
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] loadedImage in
|
|
||||||
guard let self = self, let image = loadedImage else { return }
|
|
||||||
self.image = image
|
|
||||||
self.cache.saveImage(image, forKey: self.fileId, userId: self.userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CachedAvatarView<Placeholder: View>: View {
|
|
||||||
@StateObject private var loader: ImageLoader
|
|
||||||
private let placeholder: Placeholder
|
|
||||||
|
|
||||||
init(url: URL, fileId: String, userId: String, @ViewBuilder placeholder: () -> Placeholder) {
|
|
||||||
self.placeholder = placeholder()
|
|
||||||
_loader = StateObject(wrappedValue: ImageLoader(url: url, fileId: fileId, userId: userId))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
content
|
|
||||||
.onAppear(perform: loader.load)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var content: some View {
|
|
||||||
Group {
|
|
||||||
if let image = loader.image {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
} else {
|
|
||||||
placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,155 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class PushTokenManager {
|
|
||||||
static let shared = PushTokenManager()
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "org.yobble.push-token", qos: .utility)
|
|
||||||
private let sessionsService: SessionsService
|
|
||||||
private var currentFCMToken: String?
|
|
||||||
private var lastSentTokens: [String: String]
|
|
||||||
private var loginsRequiringSync: Set<String> = []
|
|
||||||
private var isUpdating = false
|
|
||||||
private var pendingUpdate = false
|
|
||||||
private var retryWorkItem: DispatchWorkItem?
|
|
||||||
private var notificationTokens: [NSObjectProtocol] = []
|
|
||||||
|
|
||||||
private enum Keys {
|
|
||||||
static let storedToken = "push.current_fcm_token"
|
|
||||||
static let sentTokens = "push.last_sent_tokens"
|
|
||||||
static let currentUser = "currentUser"
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum Constants {
|
|
||||||
static let retryDelay: TimeInterval = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
private init(sessionsService: SessionsService = SessionsService()) {
|
|
||||||
self.sessionsService = sessionsService
|
|
||||||
let defaults = UserDefaults.standard
|
|
||||||
self.currentFCMToken = defaults.string(forKey: Keys.storedToken)
|
|
||||||
self.lastSentTokens = defaults.dictionary(forKey: Keys.sentTokens) as? [String: String] ?? [:]
|
|
||||||
observeNotifications()
|
|
||||||
|
|
||||||
queue.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
if let login = self.currentLogin() {
|
|
||||||
self.loginsRequiringSync.insert(login)
|
|
||||||
}
|
|
||||||
self.pendingUpdate = true
|
|
||||||
self.tryUpdateTokenIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
notificationTokens.forEach { NotificationCenter.default.removeObserver($0) }
|
|
||||||
notificationTokens.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerFCMToken(_ token: String) {
|
|
||||||
queue.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
guard self.currentFCMToken != token else { return }
|
|
||||||
|
|
||||||
self.currentFCMToken = token
|
|
||||||
UserDefaults.standard.set(token, forKey: Keys.storedToken)
|
|
||||||
|
|
||||||
if let login = self.currentLogin() {
|
|
||||||
self.loginsRequiringSync.insert(login)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.pendingUpdate = true
|
|
||||||
self.tryUpdateTokenIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeNotifications() {
|
|
||||||
let center = NotificationCenter.default
|
|
||||||
|
|
||||||
let accessTokenObserver = center.addObserver(forName: .accessTokenDidChange, object: nil, queue: nil) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
self.queue.async {
|
|
||||||
if let login = self.currentLogin() {
|
|
||||||
self.loginsRequiringSync.insert(login)
|
|
||||||
} else {
|
|
||||||
self.loginsRequiringSync.removeAll()
|
|
||||||
}
|
|
||||||
self.pendingUpdate = true
|
|
||||||
self.tryUpdateTokenIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notificationTokens.append(accessTokenObserver)
|
|
||||||
|
|
||||||
let didBecomeActiveObserver = center.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
self.queue.async {
|
|
||||||
self.pendingUpdate = true
|
|
||||||
self.tryUpdateTokenIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notificationTokens.append(didBecomeActiveObserver)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tryUpdateTokenIfNeeded() {
|
|
||||||
guard pendingUpdate else { return }
|
|
||||||
guard !isUpdating else { return }
|
|
||||||
guard let login = currentLogin() else { return }
|
|
||||||
guard let token = currentFCMToken, !token.isEmpty else { return }
|
|
||||||
|
|
||||||
let needsForcedSync = loginsRequiringSync.contains(login)
|
|
||||||
if !needsForcedSync, let lastToken = lastSentTokens[login], lastToken == token {
|
|
||||||
pendingUpdate = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingUpdate = false
|
|
||||||
isUpdating = true
|
|
||||||
retryWorkItem?.cancel()
|
|
||||||
retryWorkItem = nil
|
|
||||||
|
|
||||||
sessionsService.updatePushToken(token) { [weak self] result in
|
|
||||||
guard let self else { return }
|
|
||||||
self.queue.async {
|
|
||||||
self.isUpdating = false
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
self.loginsRequiringSync.remove(login)
|
|
||||||
self.lastSentTokens[login] = token
|
|
||||||
UserDefaults.standard.set(self.lastSentTokens, forKey: Keys.sentTokens)
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[PushTokenManager] Push token updated for @\(login)")
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[PushTokenManager] Failed to update push token: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
self.loginsRequiringSync.insert(login)
|
|
||||||
self.pendingUpdate = true
|
|
||||||
self.scheduleRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.tryUpdateTokenIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleRetry() {
|
|
||||||
guard retryWorkItem == nil else { return }
|
|
||||||
let workItem = DispatchWorkItem { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.retryWorkItem = nil
|
|
||||||
self.pendingUpdate = true
|
|
||||||
self.tryUpdateTokenIfNeeded()
|
|
||||||
}
|
|
||||||
retryWorkItem = workItem
|
|
||||||
queue.asyncAfter(deadline: .now() + Constants.retryDelay, execute: workItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func currentLogin() -> String? {
|
|
||||||
guard let login = UserDefaults.standard.string(forKey: Keys.currentUser), !login.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return login
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -340,7 +340,17 @@ final class SocketService {
|
|||||||
private func handleNewPrivateMessage(_ data: [Any]) {
|
private func handleNewPrivateMessage(_ data: [Any]) {
|
||||||
guard let payload = data.first else { return }
|
guard let payload = data.first else { return }
|
||||||
|
|
||||||
guard let messageData = normalizeMessagePayload(payload) else { return }
|
let messageData: Data
|
||||||
|
if let dictionary = payload as? [String: Any],
|
||||||
|
JSONSerialization.isValidJSONObject(dictionary),
|
||||||
|
let json = try? JSONSerialization.data(withJSONObject: dictionary, options: []) {
|
||||||
|
messageData = json
|
||||||
|
} else if let string = payload as? String,
|
||||||
|
let data = string.data(using: .utf8) {
|
||||||
|
messageData = data
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
@ -363,31 +373,6 @@ final class SocketService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func normalizeMessagePayload(_ payload: Any) -> Data? {
|
|
||||||
// Server can wrap the actual message in an { event, payload } envelope.
|
|
||||||
if let dictionary = payload as? [String: Any] {
|
|
||||||
let messageBody = dictionary["payload"] ?? dictionary
|
|
||||||
if let messageDict = messageBody as? [String: Any],
|
|
||||||
JSONSerialization.isValidJSONObject(messageDict) {
|
|
||||||
return try? JSONSerialization.data(withJSONObject: messageDict, options: [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let string = payload as? String,
|
|
||||||
let data = string.data(using: .utf8) {
|
|
||||||
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
|
||||||
let nested = jsonObject["payload"] {
|
|
||||||
return normalizeMessagePayload(nested)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
if let data = payload as? Data {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleHeartbeatSuccess() {
|
private func handleHeartbeatSuccess() {
|
||||||
consecutiveHeartbeatMisses = 0
|
consecutiveHeartbeatMisses = 0
|
||||||
heartbeatAckInFlight = false
|
heartbeatAckInFlight = false
|
||||||
|
|||||||
@ -7,69 +7,27 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
class LoginViewModel: ObservableObject {
|
class LoginViewModel: ObservableObject {
|
||||||
// @AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
|
|
||||||
|
|
||||||
@Published var username: String = ""
|
@Published var username: String = ""
|
||||||
@Published var userId: String = ""
|
@Published var userId: String = ""
|
||||||
@Published var password: String = ""
|
@Published var password: String = ""
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = true // сразу true, чтобы показать спиннер при автологине
|
||||||
@Published var isInitialLoading: Bool = true // отдельный флаг для сплэша до завершения автологина
|
|
||||||
@Published var showError: Bool = false
|
@Published var showError: Bool = false
|
||||||
@Published var errorMessage: String = ""
|
@Published var errorMessage: String = ""
|
||||||
@Published var isLoggedIn: Bool = false
|
@Published var isLoggedIn: Bool = false
|
||||||
@Published var socketState: SocketService.ConnectionState
|
@Published var socketState: SocketService.ConnectionState
|
||||||
@Published var chatLoadingState: ChatLoadingState = .idle
|
@Published var chatLoadingState: ChatLoadingState = .idle
|
||||||
@Published var hasAcceptedTerms: Bool = false
|
|
||||||
@Published var isLoadingTerms: Bool = false
|
|
||||||
@Published var termsContent: String = ""
|
|
||||||
@Published var termsErrorMessage: String?
|
|
||||||
@Published var onboardingDestination: OnboardingDestination?
|
|
||||||
@Published var loginFlowStep: LoginFlowStep = .passwordlessRequest
|
|
||||||
@Published var passwordlessLogin: String = "" {
|
|
||||||
didSet {
|
|
||||||
if passwordlessLogin.count > 32 {
|
|
||||||
passwordlessLogin = String(passwordlessLogin.prefix(32))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Published var verificationCode: String = "" {
|
|
||||||
didSet {
|
|
||||||
let filtered = verificationCode
|
|
||||||
.filter { $0.isNumber }
|
|
||||||
.prefix(Constants.verificationCodeLength)
|
|
||||||
if filtered != verificationCode {
|
|
||||||
verificationCode = String(filtered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Published var isSendingCode: Bool = false
|
|
||||||
@Published var isVerifyingCode: Bool = false
|
|
||||||
@Published var resendSecondsRemaining: Int = 0
|
|
||||||
|
|
||||||
private let authService = AuthService()
|
private let authService = AuthService()
|
||||||
private let socketService = SocketService.shared
|
private let socketService = SocketService.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var resendTimer: Timer?
|
|
||||||
|
|
||||||
enum LoginFlowStep: Equatable {
|
|
||||||
case passwordlessRequest
|
|
||||||
case passwordlessVerify
|
|
||||||
case password
|
|
||||||
case registration
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ChatLoadingState: Equatable {
|
enum ChatLoadingState: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case loading
|
case loading
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OnboardingDestination: Equatable {
|
|
||||||
case afterRegister
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum DefaultsKeys {
|
private enum DefaultsKeys {
|
||||||
static let currentUser = "currentUser"
|
static let currentUser = "currentUser"
|
||||||
static let userId = "userId"
|
static let userId = "userId"
|
||||||
@ -85,10 +43,6 @@ class LoginViewModel: ObservableObject {
|
|||||||
autoLogin()
|
autoLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
resendTimer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeSocketState() {
|
private func observeSocketState() {
|
||||||
socketService.connectionStatePublisher
|
socketService.connectionStatePublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
@ -137,7 +91,6 @@ class LoginViewModel: ObservableObject {
|
|||||||
self?.socketService.disconnect()
|
self?.socketService.disconnect()
|
||||||
}
|
}
|
||||||
self?.isLoading = false
|
self?.isLoading = false
|
||||||
self?.isInitialLoading = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -146,13 +99,8 @@ class LoginViewModel: ObservableObject {
|
|||||||
func login() {
|
func login() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
showError = false
|
showError = false
|
||||||
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmedLogin != passwordlessLogin {
|
|
||||||
passwordlessLogin = trimmedLogin
|
|
||||||
}
|
|
||||||
username = trimmedLogin
|
|
||||||
|
|
||||||
authService.login(username: trimmedLogin, password: password) { [weak self] success, error in
|
authService.login(username: username, password: password) { [weak self] success, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.isLoading = false
|
self?.isLoading = false
|
||||||
if success {
|
if success {
|
||||||
@ -168,94 +116,6 @@ class LoginViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestPasswordlessCode() {
|
|
||||||
guard LoginViewModel.isLoginValid(passwordlessLogin) else {
|
|
||||||
errorMessage = NSLocalizedString("Неверный логин", comment: "")
|
|
||||||
showError = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
|
|
||||||
isSendingCode = true
|
|
||||||
showError = false
|
|
||||||
|
|
||||||
authService.requestLoginCode(identifier: trimmedLogin) { [weak self] success, message in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
self.isSendingCode = false
|
|
||||||
|
|
||||||
if success {
|
|
||||||
self.passwordlessLogin = trimmedLogin
|
|
||||||
self.verificationCode = ""
|
|
||||||
self.loginFlowStep = .passwordlessVerify
|
|
||||||
self.startResendTimer()
|
|
||||||
} else {
|
|
||||||
if self.handlePasswordlessRedirect(message: message, login: trimmedLogin) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.errorMessage = message ?? NSLocalizedString("Не удалось отправить код.", comment: "")
|
|
||||||
self.showError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyPasswordlessCode() {
|
|
||||||
guard verificationCode.count == Constants.verificationCodeLength,
|
|
||||||
!passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isVerifyingCode = true
|
|
||||||
showError = false
|
|
||||||
|
|
||||||
authService.loginWithCode(identifier: passwordlessLogin, code: verificationCode) { [weak self] success, message in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
self.isVerifyingCode = false
|
|
||||||
|
|
||||||
if success {
|
|
||||||
self.resendTimer?.invalidate()
|
|
||||||
self.loadStoredUser()
|
|
||||||
self.isLoggedIn = true
|
|
||||||
self.socketService.connectForCurrentUser()
|
|
||||||
self.verificationCode = ""
|
|
||||||
} else {
|
|
||||||
self.errorMessage = message ?? NSLocalizedString("Проверьте введённый код и попробуйте снова.", comment: "")
|
|
||||||
self.showError = true
|
|
||||||
// self.verificationCode = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resendPasswordlessCode() {
|
|
||||||
guard resendSecondsRemaining == 0 else { return }
|
|
||||||
requestPasswordlessCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func showPasswordLogin() {
|
|
||||||
resendTimer?.invalidate()
|
|
||||||
loginFlowStep = .password
|
|
||||||
}
|
|
||||||
|
|
||||||
func showPasswordlessRequest() {
|
|
||||||
loginFlowStep = .passwordlessRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func backToPasswordlessRequest() {
|
|
||||||
verificationCode = ""
|
|
||||||
loginFlowStep = .passwordlessRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func showRegistration() {
|
|
||||||
loginFlowStep = .registration
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
||||||
authService.register(username: username, password: password, invite: invite) { [weak self] success, message in
|
authService.register(username: username, password: password, invite: invite) { [weak self] success, message in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -263,7 +123,6 @@ class LoginViewModel: ObservableObject {
|
|||||||
self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
|
self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
|
||||||
self?.loadStoredUser()
|
self?.loadStoredUser()
|
||||||
self?.socketService.connectForCurrentUser()
|
self?.socketService.connectForCurrentUser()
|
||||||
self?.onboardingDestination = .afterRegister
|
|
||||||
} else {
|
} else {
|
||||||
self?.socketService.disconnect()
|
self?.socketService.disconnect()
|
||||||
}
|
}
|
||||||
@ -310,121 +169,4 @@ class LoginViewModel: ObservableObject {
|
|||||||
|
|
||||||
if AppConfig.DEBUG{ print("username: \(username) | userId: \(userId)")}
|
if AppConfig.DEBUG{ print("username: \(username) | userId: \(userId)")}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTermsIfNeeded() {
|
|
||||||
guard !isLoadingTerms else { return }
|
|
||||||
|
|
||||||
if !termsContent.isEmpty {
|
|
||||||
termsErrorMessage = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingTerms = true
|
|
||||||
termsErrorMessage = nil
|
|
||||||
|
|
||||||
NetworkClient.shared.request(
|
|
||||||
path: "/legal/terms",
|
|
||||||
headers: ["Accept": "text/plain"],
|
|
||||||
requiresAuth: false,
|
|
||||||
callbackQueue: .main
|
|
||||||
) { [weak self] result in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
self.isLoadingTerms = false
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
if let content = String(data: response.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!content.isEmpty {
|
|
||||||
self.termsContent = content
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let jsonObject = try? JSONSerialization.jsonObject(with: response.data, options: []),
|
|
||||||
let json = jsonObject as? [String: Any],
|
|
||||||
let content = (json["content"] as? String) ?? (json["text"] as? String),
|
|
||||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
self.termsContent = content
|
|
||||||
} else {
|
|
||||||
self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "")
|
|
||||||
}
|
|
||||||
case .failure:
|
|
||||||
self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reloadTerms() {
|
|
||||||
termsContent = ""
|
|
||||||
termsErrorMessage = nil
|
|
||||||
loadTermsIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startResendTimer(duration: Int = Constants.defaultResendDelay) {
|
|
||||||
resendTimer?.invalidate()
|
|
||||||
resendSecondsRemaining = duration
|
|
||||||
|
|
||||||
guard duration > 0 else { return }
|
|
||||||
|
|
||||||
resendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
|
|
||||||
guard let self else {
|
|
||||||
timer.invalidate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.resendSecondsRemaining > 0 {
|
|
||||||
self.resendSecondsRemaining -= 1
|
|
||||||
} else {
|
|
||||||
timer.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension LoginViewModel {
|
|
||||||
var isVerificationCodeComplete: Bool {
|
|
||||||
verificationCode.count == Constants.verificationCodeLength
|
|
||||||
}
|
|
||||||
|
|
||||||
var canRequestPasswordlessCode: Bool {
|
|
||||||
LoginViewModel.isLoginValid(passwordlessLogin) && !isSendingCode
|
|
||||||
}
|
|
||||||
|
|
||||||
var canVerifyPasswordlessCode: Bool {
|
|
||||||
isVerificationCodeComplete && !isVerifyingCode
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isLoginValid(_ login: String) -> Bool {
|
|
||||||
let trimmed = login.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard trimmed == login else { return false }
|
|
||||||
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
|
||||||
return trimmed.range(of: pattern, options: .regularExpression) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension LoginViewModel {
|
|
||||||
func handlePasswordlessRedirect(message: String?, login: String) -> Bool {
|
|
||||||
guard let message else { return false }
|
|
||||||
|
|
||||||
switch message {
|
|
||||||
case "otp_not_found":
|
|
||||||
username = login
|
|
||||||
passwordlessLogin = login
|
|
||||||
loginFlowStep = .password
|
|
||||||
return true
|
|
||||||
case "account_not_found":
|
|
||||||
username = login
|
|
||||||
passwordlessLogin = login
|
|
||||||
hasAcceptedTerms = false
|
|
||||||
loginFlowStep = .registration
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Constants {
|
|
||||||
static let verificationCodeLength = 6
|
|
||||||
static let defaultResendDelay = 60
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,14 @@ final class PrivateChatViewModel: ObservableObject {
|
|||||||
@Published private(set) var isInitialLoading: Bool = false
|
@Published private(set) var isInitialLoading: Bool = false
|
||||||
@Published private(set) var isLoadingMore: Bool = false
|
@Published private(set) var isLoadingMore: Bool = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var sendingErrorMessage: String?
|
|
||||||
@Published private(set) var isSending: Bool = false
|
@Published private(set) var isSending: Bool = false
|
||||||
@Published private(set) var hasMore: Bool = true
|
|
||||||
|
|
||||||
private let chatService: ChatService
|
private let chatService: ChatService
|
||||||
private let chatId: String
|
private let chatId: String
|
||||||
private let currentUserId: String?
|
private let currentUserId: String?
|
||||||
private let pageSize: Int
|
private let pageSize: Int
|
||||||
let maxMessageLength: Int = 4096
|
private let maxMessageLength: Int = 4096
|
||||||
|
private var hasMore: Bool = true
|
||||||
private var didLoadInitially: Bool = false
|
private var didLoadInitially: Bool = false
|
||||||
private var messageObserver: NSObjectProtocol?
|
private var messageObserver: NSObjectProtocol?
|
||||||
|
|
||||||
@ -74,9 +73,12 @@ final class PrivateChatViewModel: ObservableObject {
|
|||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard trimmed.count <= maxMessageLength else {
|
||||||
|
errorMessage = NSLocalizedString("Сообщение слишком длинное.", comment: "")
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard !isSending else {
|
guard !isSending else {
|
||||||
sendingErrorMessage = NSLocalizedString("Дождитесь отправки предыдущего сообщения.", comment: "")
|
|
||||||
completion(false)
|
completion(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -86,63 +88,36 @@ final class PrivateChatViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSending = true
|
isSending = true
|
||||||
sendingErrorMessage = nil
|
|
||||||
|
|
||||||
let chunks = splitMessage(trimmed, maxLength: maxMessageLength)
|
chatService.sendPrivateMessage(chatId: chatId, content: trimmed) { [weak self] result in
|
||||||
let dispatchGroup = DispatchGroup()
|
guard let self else { return }
|
||||||
var overallSuccess = true
|
|
||||||
|
|
||||||
for chunk in chunks {
|
switch result {
|
||||||
dispatchGroup.enter()
|
case .success(let data):
|
||||||
chatService.sendPrivateMessage(chatId: chatId, content: chunk) { [weak self] result in
|
let newMessage = MessageItem(
|
||||||
guard let self else {
|
messageId: data.messageId,
|
||||||
dispatchGroup.leave()
|
messageType: "text",
|
||||||
return
|
chatId: data.chatId,
|
||||||
}
|
senderId: currentUserId,
|
||||||
|
senderData: nil,
|
||||||
|
content: trimmed,
|
||||||
|
mediaLink: nil,
|
||||||
|
isViewed: true,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
updatedAt: data.createdAt,
|
||||||
|
forwardMetadata: nil
|
||||||
|
)
|
||||||
|
|
||||||
switch result {
|
self.messages = Self.merge(existing: self.messages, newMessages: [newMessage])
|
||||||
case .success(let data):
|
|
||||||
let newMessage = MessageItem(
|
|
||||||
messageId: data.messageId,
|
|
||||||
messageType: "text",
|
|
||||||
chatId: data.chatId,
|
|
||||||
senderId: currentUserId,
|
|
||||||
senderData: nil,
|
|
||||||
content: chunk,
|
|
||||||
mediaLink: nil,
|
|
||||||
isViewed: true,
|
|
||||||
viewedAt: nil,
|
|
||||||
createdAt: data.createdAt,
|
|
||||||
updatedAt: data.createdAt,
|
|
||||||
forwardMetadata: nil
|
|
||||||
)
|
|
||||||
self.messages = Self.merge(existing: self.messages, newMessages: [newMessage])
|
|
||||||
case .failure(let error):
|
|
||||||
self.sendingErrorMessage = self.message(for: error)
|
|
||||||
overallSuccess = false
|
|
||||||
}
|
|
||||||
dispatchGroup.leave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchGroup.notify(queue: .main) {
|
|
||||||
self.isSending = false
|
|
||||||
if overallSuccess {
|
|
||||||
self.errorMessage = nil
|
self.errorMessage = nil
|
||||||
|
completion(true)
|
||||||
|
case .failure(let error):
|
||||||
|
self.errorMessage = self.message(for: error)
|
||||||
|
completion(false)
|
||||||
}
|
}
|
||||||
completion(overallSuccess)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func splitMessage(_ message: String, maxLength: Int) -> [String] {
|
self.isSending = false
|
||||||
var chunks: [String] = []
|
|
||||||
var remaining = message
|
|
||||||
while !remaining.isEmpty {
|
|
||||||
let chunk = String(remaining.prefix(maxLength))
|
|
||||||
chunks.append(chunk)
|
|
||||||
remaining = String(remaining.dropFirst(maxLength))
|
|
||||||
}
|
}
|
||||||
return chunks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() {
|
func refresh() {
|
||||||
@ -152,21 +127,11 @@ final class PrivateChatViewModel: ObservableObject {
|
|||||||
|
|
||||||
func loadMoreIfNeeded(for message: MessageItem) {
|
func loadMoreIfNeeded(for message: MessageItem) {
|
||||||
guard didLoadInitially, !isInitialLoading, hasMore, !isLoadingMore else { return }
|
guard didLoadInitially, !isInitialLoading, hasMore, !isLoadingMore else { return }
|
||||||
|
guard let first = messages.first, first.id == message.id else { return }
|
||||||
guard let messageIndex = messages.firstIndex(where: { $0.id == message.id }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let threshold = 10
|
|
||||||
guard messageIndex < threshold else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let oldestMessage = messages.first else { return }
|
|
||||||
|
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
|
|
||||||
chatService.fetchPrivateChatHistory(chatId: chatId, beforeMessageId: oldestMessage.id, limit: pageSize) { [weak self] result in
|
chatService.fetchPrivateChatHistory(chatId: chatId, beforeMessageId: message.id, limit: pageSize) { [weak self] result in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
|
|||||||
@ -1,196 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ContactAddView: View {
|
|
||||||
let contact: ContactEditInfo
|
|
||||||
let onContactAdded: ((ContactPayload) -> Void)?
|
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
private let contactsService = ContactsService()
|
|
||||||
private let initialName: String
|
|
||||||
|
|
||||||
@State private var displayName: String
|
|
||||||
@State private var activeAlert: ContactAddAlert?
|
|
||||||
@State private var isSaving = false
|
|
||||||
|
|
||||||
init(contact: ContactEditInfo, onContactAdded: ((ContactPayload) -> Void)? = nil) {
|
|
||||||
self.contact = contact
|
|
||||||
self.onContactAdded = onContactAdded
|
|
||||||
// let initialName = contact.preferredName
|
|
||||||
self.initialName = contact.preferredName
|
|
||||||
_displayName = State(initialValue: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
avatarSection
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Публичная информация", comment: "Contact add public info section title"))) {
|
|
||||||
// TextField(NSLocalizedString("Отображаемое имя", comment: "Display name field placeholder"), text: $displayName)
|
|
||||||
TextField(NSLocalizedString("\(initialName)", comment: "Display name field placeholder"), text: $displayName)
|
|
||||||
.disabled(isSaving)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Новый контакт", comment: "Contact add title"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
if isSaving {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Button(NSLocalizedString("Сохранить", comment: "Contact add save button")) {
|
|
||||||
handleSaveTap()
|
|
||||||
}
|
|
||||||
.disabled(!hasChanges)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert(item: $activeAlert) { item in
|
|
||||||
Alert(
|
|
||||||
title: Text(item.title),
|
|
||||||
message: Text(item.message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("Понятно", comment: "Placeholder alert dismiss")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarSection: some View {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
avatarView
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
.clipShape(Circle())
|
|
||||||
|
|
||||||
Button(NSLocalizedString("Изменить фото", comment: "Edit avatar button title")) {
|
|
||||||
showAvatarUnavailableAlert()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color(UIColor.systemGroupedBackground))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var avatarView: some View {
|
|
||||||
if let url = avatarURL,
|
|
||||||
let fileId = contact.avatarFileId {
|
|
||||||
CachedAvatarView(url: url, fileId: fileId, userId: contact.userId) {
|
|
||||||
avatarPlaceholder
|
|
||||||
}
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
} else {
|
|
||||||
avatarPlaceholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarPlaceholder: some View {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.accentColor.opacity(0.15))
|
|
||||||
.overlay(
|
|
||||||
Text(avatarInitial)
|
|
||||||
.font(.system(size: 48, weight: .semibold))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarInitial: String {
|
|
||||||
let trimmedName = displayName.trimmedNonEmpty ?? contact.preferredName
|
|
||||||
if let initials = initials(from: trimmedName) {
|
|
||||||
return initials
|
|
||||||
}
|
|
||||||
if let login = contact.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
|
|
||||||
return String(login.prefix(1)).uppercased()
|
|
||||||
}
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarURL: URL? {
|
|
||||||
guard let fileId = contact.avatarFileId else { return nil }
|
|
||||||
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(contact.userId)?file_id=\(fileId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hasChanges: Bool {
|
|
||||||
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
// guard !trimmed.isEmpty else { return false }
|
|
||||||
|
|
||||||
if let existing = contact.customName?.trimmedNonEmpty {
|
|
||||||
return trimmed != existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showAvatarUnavailableAlert() {
|
|
||||||
activeAlert = ContactAddAlert(
|
|
||||||
title: NSLocalizedString("Изменение фото недоступно", comment: "Contact add avatar unavailable title"),
|
|
||||||
message: NSLocalizedString("Мы пока не можем обновить фото контакта.", comment: "Contact add avatar unavailable message")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSaveTap() {
|
|
||||||
guard !isSaving else { return }
|
|
||||||
|
|
||||||
guard let userId = UUID(uuidString: contact.userId) else {
|
|
||||||
activeAlert = ContactAddAlert(
|
|
||||||
title: NSLocalizedString("Ошибка", comment: "Common error title"),
|
|
||||||
message: NSLocalizedString("Не удалось определить пользователя для добавления.", comment: "Contact add invalid user id error")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmedName = displayName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
||||||
// guard !trimmedName.isEmpty else {
|
|
||||||
// activeAlert = ContactAddAlert(
|
|
||||||
// title: NSLocalizedString("Ошибка", comment: "Common error title"),
|
|
||||||
// message: NSLocalizedString("Имя не может быть пустым.", comment: "Contact add empty name error")
|
|
||||||
// )
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
let customName = trimmedName
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let payload = try await contactsService.addContact(userId: userId, customName: customName)
|
|
||||||
await MainActor.run {
|
|
||||||
isSaving = false
|
|
||||||
onContactAdded?(payload)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
isSaving = false
|
|
||||||
activeAlert = ContactAddAlert(
|
|
||||||
title: NSLocalizedString("Ошибка", comment: "Common error title"),
|
|
||||||
message: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContactAddAlert: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let title: String
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension String {
|
|
||||||
var trimmedNonEmpty: String? {
|
|
||||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return value.isEmpty ? nil : value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initials(from text: String) -> String? {
|
|
||||||
let components = text
|
|
||||||
.split { $0.isWhitespace }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
let letters = components.prefix(2).compactMap { $0.first }
|
|
||||||
guard !letters.isEmpty else { return nil }
|
|
||||||
return letters.map { String($0).uppercased() }.joined()
|
|
||||||
}
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ContactEditInfo {
|
|
||||||
let userId: String
|
|
||||||
let login: String?
|
|
||||||
let fullName: String?
|
|
||||||
let customName: String?
|
|
||||||
let avatarFileId: String?
|
|
||||||
|
|
||||||
init(userId: String, login: String?, fullName: String?, customName: String?, avatarFileId: String?) {
|
|
||||||
self.userId = userId
|
|
||||||
self.login = login
|
|
||||||
self.fullName = fullName
|
|
||||||
self.customName = customName
|
|
||||||
self.avatarFileId = avatarFileId
|
|
||||||
}
|
|
||||||
|
|
||||||
init(userId: UUID, login: String?, fullName: String?, customName: String?, avatarFileId: String?) {
|
|
||||||
self.init(userId: userId.uuidString, login: login, fullName: fullName, customName: customName, avatarFileId: avatarFileId)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(profile: ChatProfile) {
|
|
||||||
self.init(
|
|
||||||
userId: profile.userId,
|
|
||||||
login: profile.login,
|
|
||||||
fullName: profile.fullName,
|
|
||||||
customName: profile.customName,
|
|
||||||
avatarFileId: profile.avatars?.current?.fileId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(payload: ContactPayload) {
|
|
||||||
self.init(
|
|
||||||
userId: payload.userId,
|
|
||||||
login: payload.login,
|
|
||||||
fullName: payload.fullName,
|
|
||||||
customName: payload.customName,
|
|
||||||
avatarFileId: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var preferredName: String {
|
|
||||||
if let full = fullName?.trimmedNonEmpty {
|
|
||||||
return full
|
|
||||||
}
|
|
||||||
if let login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
return "@\(login)"
|
|
||||||
}
|
|
||||||
return NSLocalizedString("Неизвестный пользователь", comment: "Message profile fallback title")
|
|
||||||
}
|
|
||||||
|
|
||||||
var loadCustomName: String {
|
|
||||||
if let custom = customName?.trimmedNonEmpty {
|
|
||||||
return custom
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContactEditView: View {
|
|
||||||
let contact: ContactEditInfo
|
|
||||||
let onContactDeleted: (() -> Void)?
|
|
||||||
let onContactUpdated: ((String) -> Void)?
|
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
private let contactsService = ContactsService()
|
|
||||||
private let initialName: String
|
|
||||||
|
|
||||||
@State private var displayName: String
|
|
||||||
@State private var activeAlert: ContactEditAlert?
|
|
||||||
@State private var isSaving = false
|
|
||||||
@State private var isDeleting = false
|
|
||||||
@State private var showDeleteConfirmation = false
|
|
||||||
|
|
||||||
init(
|
|
||||||
contact: ContactEditInfo,
|
|
||||||
onContactDeleted: (() -> Void)? = nil,
|
|
||||||
onContactUpdated: ((String) -> Void)? = nil
|
|
||||||
) {
|
|
||||||
self.contact = contact
|
|
||||||
self.onContactDeleted = onContactDeleted
|
|
||||||
self.onContactUpdated = onContactUpdated
|
|
||||||
self.initialName = contact.preferredName
|
|
||||||
let initialCustomName = contact.loadCustomName
|
|
||||||
_displayName = State(initialValue: initialCustomName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
avatarSection
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Публичная информация", comment: "Profile info section title"))) {
|
|
||||||
// TextField(NSLocalizedString("Отображаемое имя", comment: "Display name field placeholder"), text: $displayName)
|
|
||||||
TextField(NSLocalizedString("\(self.initialName)", comment: "Display name field placeholder"), text: $displayName)
|
|
||||||
.disabled(isSaving || isDeleting)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
handleDeleteTap()
|
|
||||||
} label: {
|
|
||||||
deleteButtonLabel
|
|
||||||
}
|
|
||||||
.disabled(isDeleting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Контакт", comment: "Contact edit title"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
if isSaving {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Button(NSLocalizedString("Сохранить", comment: "Contact edit save button")) {
|
|
||||||
handleSaveTap()
|
|
||||||
}
|
|
||||||
.disabled(!hasChanges || isDeleting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert(item: $activeAlert) { item in
|
|
||||||
Alert(
|
|
||||||
title: Text(item.title),
|
|
||||||
message: Text(item.message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("Понятно", comment: "Placeholder alert dismiss")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
NSLocalizedString("Удалить контакт?", comment: "Contact delete confirmation title"),
|
|
||||||
isPresented: $showDeleteConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button(NSLocalizedString("Удалить", comment: "Contact delete confirm action"), role: .destructive) {
|
|
||||||
confirmDelete()
|
|
||||||
}
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
|
|
||||||
showDeleteConfirmation = false
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
Text(String(
|
|
||||||
format: NSLocalizedString("Контакт \"%1$@\" будет удалён из списка.", comment: "Contact delete confirmation message"),
|
|
||||||
contact.preferredName
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var deleteButtonLabel: some View {
|
|
||||||
if isDeleting {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ProgressView()
|
|
||||||
Text(NSLocalizedString("Удаляем...", comment: "Contact delete in progress"))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
} else {
|
|
||||||
Text(NSLocalizedString("Удалить контакт", comment: "Contact edit delete action"))
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarSection: some View {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
avatarView
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
.clipShape(Circle())
|
|
||||||
|
|
||||||
Button(NSLocalizedString("Изменить фото", comment: "Edit avatar button title")) {
|
|
||||||
showAvatarUnavailableAlert()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color(UIColor.systemGroupedBackground))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var avatarView: some View {
|
|
||||||
if let url = avatarURL,
|
|
||||||
let fileId = contact.avatarFileId {
|
|
||||||
CachedAvatarView(url: url, fileId: fileId, userId: contact.userId) {
|
|
||||||
avatarPlaceholder
|
|
||||||
}
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
} else {
|
|
||||||
avatarPlaceholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarPlaceholder: some View {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.accentColor.opacity(0.15))
|
|
||||||
.overlay(
|
|
||||||
Text(avatarInitial)
|
|
||||||
.font(.system(size: 48, weight: .semibold))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarInitial: String {
|
|
||||||
let trimmedName = displayName.trimmedNonEmpty ?? contact.preferredName
|
|
||||||
if let initials = initials(from: trimmedName) {
|
|
||||||
return initials
|
|
||||||
}
|
|
||||||
if let login = contact.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
|
|
||||||
return String(login.prefix(1)).uppercased()
|
|
||||||
}
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarURL: URL? {
|
|
||||||
guard let fileId = contact.avatarFileId else { return nil }
|
|
||||||
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(contact.userId)?file_id=\(fileId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hasChanges: Bool {
|
|
||||||
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
// guard !trimmed.isEmpty else { return false }
|
|
||||||
|
|
||||||
if let existing = contact.customName?.trimmedNonEmpty {
|
|
||||||
return trimmed != existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showAvatarUnavailableAlert() {
|
|
||||||
activeAlert = ContactEditAlert(
|
|
||||||
title: NSLocalizedString("Изменение фото недоступно", comment: "Contact edit avatar unavailable title"),
|
|
||||||
message: NSLocalizedString("Мы пока не можем обновить фото контакта.", comment: "Contact edit avatar unavailable message")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSaveTap() {
|
|
||||||
guard !isSaving, !isDeleting else { return }
|
|
||||||
|
|
||||||
guard let userId = UUID(uuidString: contact.userId) else {
|
|
||||||
activeAlert = ContactEditAlert(
|
|
||||||
title: NSLocalizedString("Ошибка", comment: "Common error title"),
|
|
||||||
message: NSLocalizedString("Не удалось определить контакт.", comment: "Contact edit invalid user id error")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
// guard !trimmed.isEmpty else {
|
|
||||||
// activeAlert = ContactEditAlert(
|
|
||||||
// title: NSLocalizedString("Ошибка", comment: "Common error title"),
|
|
||||||
// message: NSLocalizedString("Имя не может быть пустым.", comment: "Contact edit empty name error")
|
|
||||||
// )
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
if trimmed.count > 32 {
|
|
||||||
activeAlert = ContactEditAlert(
|
|
||||||
title: NSLocalizedString("Слишком длинное имя", comment: "Contact edit name too long title"),
|
|
||||||
message: NSLocalizedString("Имя контакта должно быть короче 32 символов.", comment: "Contact edit name too long message")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await contactsService.updateContact(userId: userId, customName: trimmed)
|
|
||||||
await MainActor.run {
|
|
||||||
isSaving = false
|
|
||||||
onContactUpdated?(trimmed)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
isSaving = false
|
|
||||||
activeAlert = ContactEditAlert(
|
|
||||||
title: NSLocalizedString("Ошибка", comment: "Common error title"),
|
|
||||||
message: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleDeleteTap() {
|
|
||||||
guard !isDeleting else { return }
|
|
||||||
showDeleteConfirmation = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func confirmDelete() {
|
|
||||||
guard !isDeleting else { return }
|
|
||||||
guard let userId = UUID(uuidString: contact.userId) else {
|
|
||||||
activeAlert = ContactEditAlert(
|
|
||||||
title: NSLocalizedString("Ошибка", comment: "Common error title"),
|
|
||||||
message: NSLocalizedString("Не удалось определить контакт.", comment: "Contact delete invalid user id error")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isDeleting = true
|
|
||||||
showDeleteConfirmation = false
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await contactsService.removeContact(userId: userId)
|
|
||||||
await MainActor.run {
|
|
||||||
isDeleting = false
|
|
||||||
onContactDeleted?()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
isDeleting = false
|
|
||||||
activeAlert = ContactEditAlert(
|
|
||||||
title: NSLocalizedString("Ошибка", comment: "Common error title"),
|
|
||||||
message: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContactEditAlert: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let title: String
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension String {
|
|
||||||
var trimmedNonEmpty: String? {
|
|
||||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return value.isEmpty ? nil : value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initials(from text: String) -> String? {
|
|
||||||
let components = text
|
|
||||||
.split { $0.isWhitespace }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
let letters = components.prefix(2).compactMap { $0.first }
|
|
||||||
guard !letters.isEmpty else { return nil }
|
|
||||||
return letters.map { String($0).uppercased() }.joined()
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct LoginTopBar: View {
|
|
||||||
let openLanguageSettings: () -> Void
|
|
||||||
let onShowModePrompt: (() -> Void)?
|
|
||||||
@EnvironmentObject private var themeManager: ThemeManager
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
private let themeOptions = ThemeOption.ordered
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Button(action: openLanguageSettings) {
|
|
||||||
Text("🌍")
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if let onShowModePrompt {
|
|
||||||
Button(action: onShowModePrompt) {
|
|
||||||
Text(NSLocalizedString("Режим", comment: ""))
|
|
||||||
.font(.footnote.bold())
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
Menu {
|
|
||||||
ForEach(themeOptions) { option in
|
|
||||||
Button(action: { selectTheme(option) }) {
|
|
||||||
themeMenuContent(for: option)
|
|
||||||
.opacity(option.isEnabled ? 1.0 : 0.5)
|
|
||||||
}
|
|
||||||
.disabled(!option.isEnabled)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: themeIconName)
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var selectedThemeOption: ThemeOption {
|
|
||||||
ThemeOption.option(for: themeManager.theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var themeIconName: String {
|
|
||||||
switch themeManager.theme {
|
|
||||||
case .system:
|
|
||||||
return colorScheme == .dark ? "moon.fill" : "sun.max.fill"
|
|
||||||
case .light:
|
|
||||||
return "sun.max.fill"
|
|
||||||
case .oledDark:
|
|
||||||
return "moon.fill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func themeMenuContent(for option: ThemeOption) -> some View {
|
|
||||||
let isSelected = option == selectedThemeOption
|
|
||||||
|
|
||||||
return HStack(spacing: 8) {
|
|
||||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundColor(isSelected ? .accentColor : .secondary)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(option.title)
|
|
||||||
if let note = option.note {
|
|
||||||
Text(note)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectTheme(_ option: ThemeOption) {
|
|
||||||
guard let mappedTheme = option.mappedTheme else { return }
|
|
||||||
themeManager.setTheme(mappedTheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,124 +9,11 @@ import SwiftUI
|
|||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
|
||||||
@State private var isShowingMessengerPrompt: Bool = true
|
|
||||||
@State private var pendingMessengerMode: Bool = UserDefaults.standard.bool(forKey: "messengerModeEnabled")
|
|
||||||
@State private var showLegacySupportNotice = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
content
|
|
||||||
.animation(.easeInOut(duration: 0.25), value: viewModel.loginFlowStep)
|
|
||||||
.allowsHitTesting(!isAnyBlockingOverlayPresented)
|
|
||||||
.blur(radius: isAnyBlockingOverlayPresented ? 3 : 0)
|
|
||||||
|
|
||||||
if showLegacySupportNotice {
|
|
||||||
LegacySupportNoticeView(isPresented: $showLegacySupportNotice)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isShowingMessengerPrompt && !showLegacySupportNotice {
|
|
||||||
Color.black.opacity(0.35)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.transition(.opacity)
|
|
||||||
|
|
||||||
MessengerModePrompt(
|
|
||||||
selection: $pendingMessengerMode,
|
|
||||||
onAccept: applyMessengerModeSelection,
|
|
||||||
onSkip: dismissMessengerPrompt
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.transition(.scale.combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
showModePrompt()
|
|
||||||
showLegacySupportNoticeIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var content: some View {
|
|
||||||
ZStack {
|
|
||||||
switch viewModel.loginFlowStep {
|
|
||||||
case .passwordlessRequest:
|
|
||||||
PasswordlessRequestView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
shouldAutofocus: !isShowingMessengerPrompt,
|
|
||||||
onShowModePrompt: showModePrompt
|
|
||||||
)
|
|
||||||
.transition(unifiedTransition)
|
|
||||||
case .passwordlessVerify:
|
|
||||||
PasswordlessVerifyView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
shouldAutofocus: !isShowingMessengerPrompt,
|
|
||||||
onShowModePrompt: showModePrompt
|
|
||||||
)
|
|
||||||
.transition(unifiedTransition)
|
|
||||||
case .password:
|
|
||||||
PasswordLoginView(viewModel: viewModel, onShowModePrompt: showModePrompt)
|
|
||||||
.transition(unifiedTransition)
|
|
||||||
case .registration:
|
|
||||||
RegistrationView(viewModel: viewModel, onShowModePrompt: showModePrompt)
|
|
||||||
.transition(unifiedTransition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showModePrompt() {
|
|
||||||
pendingMessengerMode = isMessengerModeEnabled
|
|
||||||
withAnimation {
|
|
||||||
isShowingMessengerPrompt = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isAnyBlockingOverlayPresented: Bool {
|
|
||||||
isShowingMessengerPrompt || showLegacySupportNotice
|
|
||||||
}
|
|
||||||
|
|
||||||
private var unifiedTransition: AnyTransition {
|
|
||||||
.opacity.combined(with: .scale(scale: 0.98, anchor: .center))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showLegacySupportNoticeIfNeeded() {
|
|
||||||
guard shouldShowLegacySupportNotice else { return }
|
|
||||||
withAnimation {
|
|
||||||
showLegacySupportNotice = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shouldShowLegacySupportNotice: Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
let requiredVersion = OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)
|
|
||||||
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
|
|
||||||
#else
|
|
||||||
return false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyMessengerModeSelection() {
|
|
||||||
isMessengerModeEnabled = pendingMessengerMode
|
|
||||||
dismissMessengerPrompt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dismissMessengerPrompt() {
|
|
||||||
withAnimation {
|
|
||||||
isShowingMessengerPrompt = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PasswordLoginView: View {
|
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
|
||||||
let onShowModePrompt: () -> Void
|
|
||||||
@EnvironmentObject private var themeManager: ThemeManager
|
@EnvironmentObject private var themeManager: ThemeManager
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
private let themeOptions = ThemeOption.ordered
|
private let themeOptions = ThemeOption.ordered
|
||||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
|
||||||
|
|
||||||
@State private var isShowingTerms = false
|
@State private var isShowingRegistration = false
|
||||||
@State private var hasResetTermsOnAppear = false
|
|
||||||
@State private var isShowingForgotPassword = false
|
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
private enum Field: Hashable {
|
private enum Field: Hashable {
|
||||||
@ -135,91 +22,93 @@ struct PasswordLoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var isUsernameValid: Bool {
|
private var isUsernameValid: Bool {
|
||||||
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
|
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
||||||
|
return viewModel.username.range(of: pattern, options: .regularExpression) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPasswordValid: Bool {
|
private var isPasswordValid: Bool {
|
||||||
return viewModel.password.count >= 8 && viewModel.password.count <= 128
|
return viewModel.password.count >= 8 && viewModel.password.count <= 128
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isLoginButtonEnabled: Bool {
|
|
||||||
// !viewModel.isLoading && isUsernameValid && isPasswordValid && viewModel.hasAcceptedTerms
|
|
||||||
!viewModel.isLoading && isUsernameValid && isPasswordValid
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
|
||||||
|
|
||||||
Button {
|
ZStack {
|
||||||
|
Color.clear // чтобы поймать тап
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
withAnimation {
|
|
||||||
viewModel.showPasswordlessRequest()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "arrow.left")
|
|
||||||
Text(NSLocalizedString("Назад", comment: ""))
|
|
||||||
}
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack {
|
||||||
Text(NSLocalizedString("Вход по паролю", comment: ""))
|
HStack {
|
||||||
.font(.largeTitle).bold()
|
|
||||||
// Text(NSLocalizedString("Если предпочитаете классический вход, используйте логин и пароль.", comment: ""))
|
Button(action: openLanguageSettings) {
|
||||||
// .foregroundColor(.secondary)
|
Text("🌍")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Menu {
|
||||||
|
ForEach(themeOptions) { option in
|
||||||
|
Button(action: { selectTheme(option) }) {
|
||||||
|
themeMenuContent(for: option)
|
||||||
|
.opacity(option.isEnabled ? 1.0 : 0.5)
|
||||||
|
}
|
||||||
|
.disabled(!option.isEnabled)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: themeIconName)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
focusedField = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
Spacer()
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text("@")
|
TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.focused($focusedField, equals: .username)
|
|
||||||
}
|
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
|
.autocapitalization(.none)
|
||||||
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
|
.disableAutocorrection(true)
|
||||||
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
|
.focused($focusedField, equals: .username)
|
||||||
.foregroundColor(.red)
|
.onChange(of: viewModel.username) { newValue in
|
||||||
.font(.caption)
|
if newValue.count > 32 {
|
||||||
}
|
viewModel.username = String(newValue.prefix(32))
|
||||||
|
|
||||||
SecureField(NSLocalizedString("Введите пароль", comment: ""), text: $viewModel.password)
|
|
||||||
.padding()
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.cornerRadius(12)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.focused($focusedField, equals: .password)
|
|
||||||
.onChange(of: viewModel.password) { newValue in
|
|
||||||
if newValue.count > 32 {
|
|
||||||
viewModel.password = String(newValue.prefix(32))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isPasswordValid && !viewModel.password.isEmpty {
|
|
||||||
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Показываем ошибку для логина
|
||||||
|
if !isUsernameValid && !viewModel.username.isEmpty {
|
||||||
|
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VStack(alignment: .leading, spacing: 4) {
|
// Показываем поле пароля
|
||||||
// Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
|
SecureField(NSLocalizedString("Пароль", comment: ""), text: $viewModel.password)
|
||||||
// .toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
.padding()
|
||||||
// Text(isMessengerModeEnabled
|
.background(Color(.secondarySystemBackground))
|
||||||
// ? "Мессенджер-режим сейчас проработан примерно на 60%."
|
.cornerRadius(8)
|
||||||
// : "Основной режим находится в ранней разработке (около 10%).")
|
.autocapitalization(.none)
|
||||||
// .font(.footnote)
|
.focused($focusedField, equals: .password)
|
||||||
// .foregroundColor(.secondary)
|
.onChange(of: viewModel.password) { newValue in
|
||||||
// }
|
if newValue.count > 32 {
|
||||||
|
viewModel.password = String(newValue.prefix(32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем ошибку для пароля
|
||||||
|
if !isPasswordValid && !viewModel.password.isEmpty {
|
||||||
|
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isButtonEnabled: Bool {
|
||||||
|
!viewModel.isLoading && isUsernameValid && isPasswordValid
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.login()
|
viewModel.login()
|
||||||
@ -227,70 +116,48 @@ struct PasswordLoginView: View {
|
|||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle())
|
.progressViewStyle(CircularProgressViewStyle())
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.gray.opacity(0.6))
|
||||||
|
.cornerRadius(8)
|
||||||
} else {
|
} else {
|
||||||
Text(NSLocalizedString("Войти", comment: ""))
|
Text(NSLocalizedString("Войти", comment: ""))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(isButtonEnabled ? Color.blue : Color.gray)
|
||||||
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
|
.disabled(!isButtonEnabled)
|
||||||
.cornerRadius(12)
|
|
||||||
.disabled(!isLoginButtonEnabled)
|
|
||||||
|
|
||||||
|
// Spacer()
|
||||||
|
|
||||||
|
// Кнопка регистрации
|
||||||
Button(action: {
|
Button(action: {
|
||||||
isShowingForgotPassword = true
|
isShowingRegistration = true
|
||||||
}) {
|
}) {
|
||||||
Text(NSLocalizedString("Забыли пароль? Сбросить", comment: ""))
|
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.padding(.top, 10)
|
||||||
|
.sheet(isPresented: $isShowingRegistration) {
|
||||||
|
RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 32)
|
.padding()
|
||||||
}
|
.alert(isPresented: $viewModel.showError) {
|
||||||
.padding(.horizontal, 24)
|
Alert(
|
||||||
.background(Color(.systemBackground).ignoresSafeArea())
|
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
|
||||||
.contentShape(Rectangle())
|
message: Text(viewModel.errorMessage),
|
||||||
.onTapGesture {
|
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||||
focusedField = nil
|
)
|
||||||
}
|
|
||||||
.loginErrorAlert(viewModel: viewModel)
|
|
||||||
.onAppear {
|
|
||||||
if !hasResetTermsOnAppear {
|
|
||||||
viewModel.hasAcceptedTerms = false
|
|
||||||
hasResetTermsOnAppear = true
|
|
||||||
}
|
}
|
||||||
}
|
.onTapGesture {
|
||||||
.fullScreenCover(isPresented: $isShowingTerms) {
|
focusedField = nil
|
||||||
TermsFullScreenView(
|
|
||||||
isPresented: $isShowingTerms,
|
|
||||||
title: NSLocalizedString("Правила сервиса", comment: ""),
|
|
||||||
content: viewModel.termsContent,
|
|
||||||
isLoading: viewModel.isLoadingTerms,
|
|
||||||
errorMessage: viewModel.termsErrorMessage,
|
|
||||||
onRetry: {
|
|
||||||
viewModel.reloadTerms()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.onAppear {
|
|
||||||
if viewModel.termsContent.isEmpty {
|
|
||||||
viewModel.loadTermsIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $isShowingForgotPassword) {
|
|
||||||
ForgotPasswordInfoView {
|
|
||||||
isShowingForgotPassword = false
|
|
||||||
withAnimation {
|
|
||||||
viewModel.showPasswordlessRequest()
|
|
||||||
}
|
|
||||||
} onDismiss: {
|
|
||||||
isShowingForgotPassword = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -305,11 +172,6 @@ struct PasswordLoginView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hideKeyboardAndShowModePrompt() {
|
|
||||||
focusedField = nil
|
|
||||||
onShowModePrompt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openLanguageSettings() {
|
private func openLanguageSettings() {
|
||||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
@ -343,542 +205,10 @@ struct PasswordLoginView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PasswordlessRequestView: View {
|
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
|
||||||
let shouldAutofocus: Bool
|
|
||||||
let onShowModePrompt: () -> Void
|
|
||||||
@FocusState private var isFieldFocused: Bool
|
|
||||||
|
|
||||||
private var isLoginValid: Bool {
|
|
||||||
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
|
|
||||||
Text(NSLocalizedString("Yobble Passport", comment: ""))
|
|
||||||
.font(.largeTitle).bold()
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text("@")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
|
|
||||||
.textContentType(.username)
|
|
||||||
.keyboardType(.default)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.focused($isFieldFocused)
|
|
||||||
.onChange(of: viewModel.passwordlessLogin) { newValue in
|
|
||||||
if newValue.count > 32 {
|
|
||||||
viewModel.passwordlessLogin = String(newValue.prefix(32))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
viewModel.requestPasswordlessCode()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if viewModel.isSendingCode {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
Text(NSLocalizedString("Войти", comment: ""))
|
|
||||||
.bold()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(viewModel.canRequestPasswordlessCode ? Color.blue : Color.gray)
|
|
||||||
.cornerRadius(12)
|
|
||||||
.disabled(!viewModel.canRequestPasswordlessCode)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.hasAcceptedTerms = false
|
|
||||||
withAnimation {
|
|
||||||
viewModel.showRegistration()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: ""))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
.padding(.vertical, 32)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.background(Color(.systemBackground).ignoresSafeArea())
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
isFieldFocused = false
|
|
||||||
}
|
|
||||||
.onAppear(perform: scheduleFocusIfNeeded)
|
|
||||||
.onChange(of: shouldAutofocus) { newValue in
|
|
||||||
if newValue {
|
|
||||||
scheduleFocusIfNeeded()
|
|
||||||
} else {
|
|
||||||
isFieldFocused = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.loginErrorAlert(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hideKeyboardAndShowModePrompt() {
|
|
||||||
isFieldFocused = false
|
|
||||||
onShowModePrompt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openLanguageSettings() {
|
|
||||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleFocusIfNeeded() {
|
|
||||||
guard shouldAutofocus else { return }
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
|
||||||
if shouldAutofocus {
|
|
||||||
isFieldFocused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LegacySupportNoticeView: View {
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Color.black.opacity(0.5)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.onTapGesture {
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.font(.system(size: 40, weight: .bold))
|
|
||||||
.foregroundColor(.yellow)
|
|
||||||
|
|
||||||
Text("Экспериментальная поддержка iOS 15")
|
|
||||||
.font(.headline)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Text("Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
isPresented = false
|
|
||||||
} label: {
|
|
||||||
Text("Понятно")
|
|
||||||
.bold()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
||||||
.fill(Color(.systemBackground))
|
|
||||||
)
|
|
||||||
.frame(maxWidth: 320)
|
|
||||||
.shadow(radius: 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PasswordlessVerifyView: View {
|
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
|
||||||
let shouldAutofocus: Bool
|
|
||||||
let onShowModePrompt: () -> Void
|
|
||||||
@FocusState private var isCodeFieldFocused: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
// focusedField = nil
|
|
||||||
withAnimation {
|
|
||||||
viewModel.showPasswordlessRequest()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "arrow.left")
|
|
||||||
Text(NSLocalizedString("Назад", comment: ""))
|
|
||||||
}
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(NSLocalizedString("Вход в аккаунт", comment: ""))
|
|
||||||
.font(.largeTitle).bold()
|
|
||||||
Text(String(format: NSLocalizedString("@%@", comment: ""), viewModel.passwordlessLogin))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
// Text(NSLocalizedString("Введите код", comment: ""))
|
|
||||||
// .font(.largeTitle).bold()
|
|
||||||
//
|
|
||||||
// Text(String(format: NSLocalizedString("Код отправлен. Аккаунт: @%@", comment: ""), viewModel.passwordlessLogin))
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
OTPInputView(code: $viewModel.verificationCode, isFocused: $isCodeFieldFocused)
|
|
||||||
|
|
||||||
if viewModel.isVerifyingCode {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ProgressView()
|
|
||||||
Text(NSLocalizedString("Проверяем код…", comment: ""))
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(NSLocalizedString("Не получили код?", comment: ""))
|
|
||||||
.font(.subheadline)
|
|
||||||
if viewModel.resendSecondsRemaining > 0 {
|
|
||||||
Text(String(format: NSLocalizedString("Попробовать снова можно через %d сек", comment: ""), viewModel.resendSecondsRemaining))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
viewModel.resendPasswordlessCode()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if viewModel.isSendingCode {
|
|
||||||
ProgressView()
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
} else {
|
|
||||||
Text(NSLocalizedString("Отправить код ещё раз", comment: ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(viewModel.resendSecondsRemaining > 0 || viewModel.isSendingCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Button {
|
|
||||||
// withAnimation {
|
|
||||||
// viewModel.backToPasswordlessRequest()
|
|
||||||
// }
|
|
||||||
// } label: {
|
|
||||||
// Text(NSLocalizedString("Изменить способ входа", comment: ""))
|
|
||||||
// .frame(maxWidth: .infinity)
|
|
||||||
// }
|
|
||||||
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
viewModel.showPasswordLogin()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text(NSLocalizedString("Войти по паролю", comment: ""))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 32)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.background(Color(.systemBackground).ignoresSafeArea())
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
isCodeFieldFocused = true
|
|
||||||
}
|
|
||||||
.onAppear(perform: scheduleFocusIfNeeded)
|
|
||||||
.onChange(of: shouldAutofocus) { newValue in
|
|
||||||
if newValue {
|
|
||||||
scheduleFocusIfNeeded()
|
|
||||||
} else {
|
|
||||||
isCodeFieldFocused = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
triggerAutoVerificationIfNeeded()
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.verificationCode) { _ in
|
|
||||||
triggerAutoVerificationIfNeeded()
|
|
||||||
}
|
|
||||||
.loginErrorAlert(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hideKeyboardAndShowModePrompt() {
|
|
||||||
isCodeFieldFocused = false
|
|
||||||
onShowModePrompt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openLanguageSettings() {
|
|
||||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleFocusIfNeeded() {
|
|
||||||
guard shouldAutofocus else { return }
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
|
||||||
if shouldAutofocus {
|
|
||||||
isCodeFieldFocused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func triggerAutoVerificationIfNeeded() {
|
|
||||||
guard viewModel.canVerifyPasswordlessCode else { return }
|
|
||||||
viewModel.verifyPasswordlessCode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct OTPInputView: View {
|
|
||||||
@Binding var code: String
|
|
||||||
var length: Int = 6
|
|
||||||
let isFocused: FocusState<Bool>.Binding
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
ForEach(0..<length, id: \.self) { index in
|
|
||||||
Text(symbol(at: index))
|
|
||||||
.font(.title2.monospacedDigit())
|
|
||||||
.frame(width: 48, height: 56)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(borderColor(for: index), lineWidth: 1.5)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextField("", text: textBinding)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.textContentType(.oneTimeCode)
|
|
||||||
.focused(isFocused)
|
|
||||||
.frame(width: 0, height: 0)
|
|
||||||
.opacity(0.01)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
isFocused.wrappedValue = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textBinding: Binding<String> {
|
|
||||||
Binding(
|
|
||||||
get: { code },
|
|
||||||
set: { newValue in
|
|
||||||
let filtered = newValue.filter { $0.isNumber }
|
|
||||||
let trimmed = String(filtered.prefix(length))
|
|
||||||
|
|
||||||
// избегаем nested updates
|
|
||||||
if code != trimmed {
|
|
||||||
// отключаем анимации и делаем обновление вне view update фазы
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
code = trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func symbol(at index: Int) -> String {
|
|
||||||
guard index < code.count else { return "" }
|
|
||||||
let idx = code.index(code.startIndex, offsetBy: index)
|
|
||||||
return String(code[idx])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func borderColor(for index: Int) -> Color {
|
|
||||||
if index == code.count && code.count < length {
|
|
||||||
return .blue
|
|
||||||
}
|
|
||||||
return .gray.opacity(0.6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MessengerModePrompt: View {
|
|
||||||
@Binding var selection: Bool
|
|
||||||
let onAccept: () -> Void
|
|
||||||
let onSkip: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
Text(NSLocalizedString("Какой режим попробовать?", comment: ""))
|
|
||||||
.font(.title3.bold())
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Text(NSLocalizedString("По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент.", comment: ""))
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
optionButton(
|
|
||||||
title: NSLocalizedString("Соцсеть (готово 10%)", comment: ""),
|
|
||||||
subtitle: NSLocalizedString("Лента, истории, подписки", comment: ""),
|
|
||||||
isMessenger: false
|
|
||||||
)
|
|
||||||
|
|
||||||
optionButton(
|
|
||||||
title: NSLocalizedString("Только чаты (готово 60%)", comment: ""),
|
|
||||||
subtitle: NSLocalizedString("Минимум отвлечений, чистый мессенджер", comment: ""),
|
|
||||||
isMessenger: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
// Button(action: onSkip) {
|
|
||||||
// Text(NSLocalizedString("Позже", comment: ""))
|
|
||||||
// .font(.callout)
|
|
||||||
// .frame(maxWidth: .infinity)
|
|
||||||
// .padding()
|
|
||||||
// .background(
|
|
||||||
// RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
||||||
// .stroke(Color.secondary.opacity(0.3))
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
Button(action: onAccept) {
|
|
||||||
Text(NSLocalizedString("Применить", comment: ""))
|
|
||||||
.font(.callout.bold())
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
||||||
.fill(Color.accentColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
||||||
.fill(Color(.systemBackground))
|
|
||||||
)
|
|
||||||
.shadow(color: Color.black.opacity(0.2), radius: 30, x: 0, y: 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func optionButton(title: String, subtitle: String, isMessenger: Bool) -> some View {
|
|
||||||
Button {
|
|
||||||
selection = isMessenger
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack {
|
|
||||||
Text(title)
|
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
|
||||||
if selection == isMessenger {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(subtitle)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
||||||
.fill(selection == isMessenger ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension View {
|
|
||||||
func loginErrorAlert(viewModel: LoginViewModel) -> some View {
|
|
||||||
alert(isPresented: Binding(
|
|
||||||
get: { viewModel.showError },
|
|
||||||
set: { viewModel.showError = $0 }
|
|
||||||
)) {
|
|
||||||
Alert(
|
|
||||||
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
|
|
||||||
message: Text(viewModel.errorMessage.isEmpty ? NSLocalizedString("Произошла ошибка.", comment: "") : viewModel.errorMessage),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LoginView_Previews: PreviewProvider {
|
struct LoginView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
|
||||||
preview(step: .passwordlessRequest)
|
|
||||||
preview(step: .passwordlessVerify)
|
|
||||||
preview(step: .password)
|
|
||||||
preview(step: .registration)
|
|
||||||
}
|
|
||||||
.environmentObject(ThemeManager())
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func preview(step: LoginViewModel.LoginFlowStep) -> some View {
|
|
||||||
let viewModel = LoginViewModel()
|
let viewModel = LoginViewModel()
|
||||||
viewModel.isLoading = false
|
viewModel.isLoading = false // чтобы убрать спиннер
|
||||||
viewModel.isInitialLoading = false
|
|
||||||
viewModel.loginFlowStep = step
|
|
||||||
viewModel.passwordlessLogin = "preview@yobble.app"
|
|
||||||
viewModel.verificationCode = "123456"
|
|
||||||
return LoginView(viewModel: viewModel)
|
return LoginView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ForgotPasswordInfoView: View {
|
|
||||||
let onUseCode: () -> Void
|
|
||||||
let onDismiss: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text(NSLocalizedString("Сброс пароля", comment: ""))
|
|
||||||
.font(.title2.bold())
|
|
||||||
|
|
||||||
Text(NSLocalizedString("Прямого сброса пароля нет: сменить его можно только из настроек, уже будучи в аккаунте. Если привязана почта или другое 2FA-устройство, воспользуйтесь входом по коду - он подтвердит вашу личность и пустит в аккаунт. После входа откройте настройки → безопасность и задайте новый пароль.", comment: ""))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Button(action: onUseCode) {
|
|
||||||
Text(NSLocalizedString("Войти", comment: ""))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: onDismiss) {
|
|
||||||
Text(NSLocalizedString("Закрыть", comment: ""))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,8 +9,10 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RegistrationView: View {
|
struct RegistrationView: View {
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
let onShowModePrompt: (() -> Void)?
|
@Binding var isPresented: Bool
|
||||||
|
@Environment(\.presentationMode) private var presentationMode
|
||||||
|
|
||||||
|
@State private var username: String = ""
|
||||||
@State private var password: String = ""
|
@State private var password: String = ""
|
||||||
@State private var confirmPassword: String = ""
|
@State private var confirmPassword: String = ""
|
||||||
@State private var inviteCode: String = ""
|
@State private var inviteCode: String = ""
|
||||||
@ -18,7 +20,6 @@ struct RegistrationView: View {
|
|||||||
@State private var isLoading: Bool = false
|
@State private var isLoading: Bool = false
|
||||||
@State private var showError: Bool = false
|
@State private var showError: Bool = false
|
||||||
@State private var errorMessage: String = ""
|
@State private var errorMessage: String = ""
|
||||||
@State private var isShowingTerms: Bool = false
|
|
||||||
|
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
@ -31,7 +32,7 @@ struct RegistrationView: View {
|
|||||||
|
|
||||||
private var isUsernameValid: Bool {
|
private var isUsernameValid: Bool {
|
||||||
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
||||||
return viewModel.passwordlessLogin.range(of: pattern, options: .regularExpression) != nil
|
return username.range(of: pattern, options: .regularExpression) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPasswordValid: Bool {
|
private var isPasswordValid: Bool {
|
||||||
@ -43,170 +44,155 @@ struct RegistrationView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var isFormValid: Bool {
|
private var isFormValid: Bool {
|
||||||
isUsernameValid && isPasswordValid && isConfirmPasswordValid && viewModel.hasAcceptedTerms
|
isUsernameValid && isPasswordValid && isConfirmPasswordValid
|
||||||
}
|
|
||||||
|
|
||||||
init(viewModel: LoginViewModel, onShowModePrompt: (() -> Void)? = nil) {
|
|
||||||
self._viewModel = ObservedObject(initialValue: viewModel)
|
|
||||||
self.onShowModePrompt = onShowModePrompt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
NavigationView {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
ScrollView {
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: keyboardDismissingModePrompt)
|
ZStack(alignment: .top) {
|
||||||
|
Color.clear
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { focusedField = nil }
|
||||||
|
|
||||||
Button(action: goBack) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack(spacing: 6) {
|
Group {
|
||||||
Image(systemName: "arrow.left")
|
HStack {
|
||||||
Text(NSLocalizedString("Назад", comment: ""))
|
TextField(NSLocalizedString("Логин", comment: "Логин"), text: $username)
|
||||||
}
|
.autocapitalization(.none)
|
||||||
.font(.footnote)
|
.disableAutocorrection(true)
|
||||||
.foregroundColor(.blue)
|
.focused($focusedField, equals: .username)
|
||||||
}
|
Spacer()
|
||||||
|
if !username.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
Image(systemName: isUsernameValid ? "checkmark.circle" : "xmark.circle")
|
||||||
Text(NSLocalizedString("Регистрация", comment: "Регистрация"))
|
.foregroundColor(isUsernameValid ? .green : .red)
|
||||||
.font(.largeTitle).bold()
|
}
|
||||||
// Text(NSLocalizedString("Создайте логин и пароль. При желании добавьте инвайт.", comment: ""))
|
}
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Group {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text("@")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
TextField(NSLocalizedString("Введите логин", comment: "Логин"), text: $viewModel.passwordlessLogin)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.focused($focusedField, equals: .username)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.cornerRadius(12)
|
|
||||||
|
|
||||||
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
|
|
||||||
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: ""))
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
SecureField(NSLocalizedString("Введите пароль", comment: "Пароль"), text: $password)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.focused($focusedField, equals: .password)
|
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.onChange(of: username) { newValue in
|
||||||
|
if newValue.count > 32 {
|
||||||
|
username = String(newValue.prefix(32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isUsernameValid && !username.isEmpty {
|
||||||
|
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: "Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)"))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
SecureField(NSLocalizedString("Пароль", comment: "Пароль"), text: $password)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
Spacer()
|
||||||
|
if !password.isEmpty {
|
||||||
|
Image(systemName: isPasswordValid ? "checkmark.circle" : "xmark.circle")
|
||||||
|
.foregroundColor(isPasswordValid ? .green : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.autocapitalization(.none)
|
||||||
.onChange(of: password) { newValue in
|
.onChange(of: password) { newValue in
|
||||||
if newValue.count > 128 {
|
if newValue.count > 128 {
|
||||||
password = String(newValue.prefix(128))
|
password = String(newValue.prefix(128))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isPasswordValid && !password.isEmpty {
|
if !isPasswordValid && !password.isEmpty {
|
||||||
Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: ""))
|
Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: "Пароль должен быть от 6 до 32 символов"))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack {
|
||||||
SecureField(NSLocalizedString("Подтвердите пароль", comment: ""), text: $confirmPassword)
|
SecureField(NSLocalizedString("Подтверждение пароля", comment: "Подтверждение пароля"), text: $confirmPassword)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.focused($focusedField, equals: .confirmPassword)
|
.focused($focusedField, equals: .confirmPassword)
|
||||||
|
Spacer()
|
||||||
|
if !confirmPassword.isEmpty {
|
||||||
|
Image(systemName: isConfirmPasswordValid ? "checkmark.circle" : "xmark.circle")
|
||||||
|
.foregroundColor(isConfirmPasswordValid ? .green : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
|
.autocapitalization(.none)
|
||||||
.onChange(of: confirmPassword) { newValue in
|
.onChange(of: confirmPassword) { newValue in
|
||||||
if newValue.count > 32 {
|
if newValue.count > 32 {
|
||||||
confirmPassword = String(newValue.prefix(32))
|
confirmPassword = String(newValue.prefix(32))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isConfirmPasswordValid && !confirmPassword.isEmpty {
|
if !isConfirmPasswordValid && !confirmPassword.isEmpty {
|
||||||
Text(NSLocalizedString("Пароли не совпадают", comment: ""))
|
Text(NSLocalizedString("Пароли не совпадают", comment: "Пароли не совпадают"))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: "Инвайт-код"), text: $inviteCode)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.focused($focusedField, equals: .invite)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: ""), text: $inviteCode)
|
Button(action: registerUser) {
|
||||||
.autocapitalization(.none)
|
if isLoading {
|
||||||
.disableAutocorrection(true)
|
ProgressView()
|
||||||
.focused($focusedField, equals: .invite)
|
.padding()
|
||||||
.padding()
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color.gray.opacity(0.6))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
|
} else {
|
||||||
|
Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться"))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(isFormValid ? Color.blue : Color.gray.opacity(0.6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!isFormValid)
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
TermsAgreementCard(
|
|
||||||
isAccepted: $viewModel.hasAcceptedTerms,
|
|
||||||
openTerms: {
|
|
||||||
viewModel.loadTermsIfNeeded()
|
|
||||||
isShowingTerms = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(action: registerUser) {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться"))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(isFormValid ? Color.blue : Color.gray.opacity(0.6))
|
|
||||||
.cornerRadius(12)
|
|
||||||
.disabled(!isFormValid)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 32)
|
.navigationTitle(Text(NSLocalizedString("Регистрация", comment: "Регистрация")))
|
||||||
}
|
.toolbar {
|
||||||
.padding(.horizontal, 24)
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
.background(Color(.systemBackground).ignoresSafeArea())
|
Button(action: dismissSheet) {
|
||||||
.contentShape(Rectangle())
|
Text(NSLocalizedString("Закрыть", comment: "Закрыть"))
|
||||||
.onTapGesture { focusedField = nil }
|
}
|
||||||
.alert(isPresented: $showError) {
|
|
||||||
Alert(
|
|
||||||
title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")),
|
|
||||||
message: Text(errorMessage),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.fullScreenCover(isPresented: $isShowingTerms) {
|
|
||||||
TermsFullScreenView(
|
|
||||||
isPresented: $isShowingTerms,
|
|
||||||
title: NSLocalizedString("Правила сервиса", comment: ""),
|
|
||||||
content: viewModel.termsContent,
|
|
||||||
isLoading: viewModel.isLoadingTerms,
|
|
||||||
errorMessage: viewModel.termsErrorMessage,
|
|
||||||
onRetry: {
|
|
||||||
viewModel.reloadTerms()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.onAppear {
|
|
||||||
if viewModel.termsContent.isEmpty {
|
|
||||||
viewModel.loadTermsIfNeeded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(isPresented: $showError) {
|
||||||
|
Alert(
|
||||||
|
title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")),
|
||||||
|
message: Text(errorMessage),
|
||||||
|
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func registerUser() {
|
private func registerUser() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = ""
|
errorMessage = ""
|
||||||
let trimmedLogin = viewModel.passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
viewModel.registerUser(username: username, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
|
||||||
viewModel.passwordlessLogin = trimmedLogin
|
|
||||||
viewModel.registerUser(username: trimmedLogin, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
if success {
|
if success {
|
||||||
viewModel.hasAcceptedTerms = false
|
dismissSheet()
|
||||||
} else {
|
} else {
|
||||||
errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "")
|
errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "")
|
||||||
showError = true
|
showError = true
|
||||||
@ -214,25 +200,10 @@ struct RegistrationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func goBack() {
|
private func dismissSheet() {
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
viewModel.hasAcceptedTerms = false
|
isPresented = false
|
||||||
withAnimation {
|
presentationMode.wrappedValue.dismiss()
|
||||||
viewModel.showPasswordlessRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var keyboardDismissingModePrompt: (() -> Void)? {
|
|
||||||
guard let onShowModePrompt else { return nil }
|
|
||||||
return {
|
|
||||||
focusedField = nil
|
|
||||||
onShowModePrompt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openLanguageSettings() {
|
|
||||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,7 +212,6 @@ struct RegistrationView_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let viewModel = LoginViewModel()
|
let viewModel = LoginViewModel()
|
||||||
viewModel.isLoading = false // чтобы убрать спиннер
|
viewModel.isLoading = false // чтобы убрать спиннер
|
||||||
viewModel.isInitialLoading = false
|
return RegistrationView(viewModel: viewModel, isPresented: .constant(true))
|
||||||
return RegistrationView(viewModel: viewModel, onShowModePrompt: nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct TermsAgreementCard: View {
|
|
||||||
@Binding var isAccepted: Bool
|
|
||||||
var openTerms: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
Button {
|
|
||||||
isAccepted.toggle()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: isAccepted ? "checkmark.square.fill" : "square")
|
|
||||||
.font(.system(size: 24, weight: .semibold))
|
|
||||||
.foregroundColor(isAccepted ? .blue : .secondary)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(NSLocalizedString("Согласиться с правилами", comment: ""))
|
|
||||||
.accessibilityValue(isAccepted ? NSLocalizedString("Включено", comment: "") : NSLocalizedString("Выключено", comment: ""))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text(NSLocalizedString("Я ознакомился и принимаю правила сервиса", comment: ""))
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Button(action: openTerms) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(NSLocalizedString("Открыть правила", comment: ""))
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(16)
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.cornerRadius(14)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TermsFullScreenView: View {
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
var title: String
|
|
||||||
var content: String
|
|
||||||
var isLoading: Bool
|
|
||||||
var errorMessage: String?
|
|
||||||
var onRetry: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
Group {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
} else if let errorMessage {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Text(errorMessage)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal)
|
|
||||||
Button(action: onRetry) {
|
|
||||||
Text(NSLocalizedString("Повторить", comment: ""))
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
if let attributed = try? AttributedString(markdown: content) {
|
|
||||||
Text(attributed)
|
|
||||||
} else {
|
|
||||||
Text(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.navigationTitle(title)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button(action: { isPresented = false }) {
|
|
||||||
Text(NSLocalizedString("Закрыть", comment: ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationViewStyle(StackNavigationViewStyle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct NeedUpdateView: View {
|
|
||||||
let title: String
|
|
||||||
let message: String
|
|
||||||
let onUpdate: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color(.systemBackground), Color(.systemGray6)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.font(.system(size: 56, weight: .bold))
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Text(title)
|
|
||||||
.font(.title2.weight(.semibold))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Text(message)
|
|
||||||
.font(.body)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: onUpdate) {
|
|
||||||
Text(NSLocalizedString("Обновить приложение", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(14)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(32)
|
|
||||||
.frame(maxWidth: 480)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NeedUpdateView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NeedUpdateView(title: "Требуется обновление",
|
|
||||||
message: "Эта версия приложения устарела и больше не поддерживается.",
|
|
||||||
onUpdate: {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
//
|
|
||||||
// AfterRegisterView.swift
|
|
||||||
// yobble
|
|
||||||
//
|
|
||||||
// Created by cheykrym on 24.10.2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AfterRegisterView: View {
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
@State private var isTwoFactorActive = false
|
|
||||||
@State private var isEmailSettingsActive = false
|
|
||||||
@State private var isAppLockActive = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
Form {
|
|
||||||
Section(header: Text(NSLocalizedString("Добро пожаловать в Yobble!", comment: ""))) {
|
|
||||||
Text(NSLocalizedString("Для начала, мы рекомендуем настроить параметры безопасности вашего аккаунта.", comment: ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Безопасность аккаунта", comment: ""))) {
|
|
||||||
NavigationLink(destination: TwoFactorAuthView()) {
|
|
||||||
Label(NSLocalizedString("Двухфакторная аутентификация", comment: ""), systemImage: "lock.shield")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(destination: EmailSecuritySettingsView()) {
|
|
||||||
Label(NSLocalizedString("Настройки email", comment: ""), systemImage: "envelope")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Приложение", comment: ""))) {
|
|
||||||
NavigationLink(destination: AppLockSettingsView()) {
|
|
||||||
Label(NSLocalizedString("Пароль на приложение", comment: ""), systemImage: "lock.square")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
|
|
||||||
NavigationLink(destination: EditProfileView()) {
|
|
||||||
Label(NSLocalizedString("Редактировать профиль", comment: ""), systemImage: "person.crop.circle")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(destination: EditPrivacyView()) {
|
|
||||||
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button(action: { isPresented = false }) {
|
|
||||||
Text(NSLocalizedString("Продолжить", comment: ""))
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Начальная настройка", comment: ""))
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button(NSLocalizedString("Пропустить", comment: "")) {
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AfterRegisterView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
AfterRegisterView(isPresented: .constant(true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -18,6 +18,7 @@ struct ChatsTab: View {
|
|||||||
private let chatService = ChatService()
|
private let chatService = ChatService()
|
||||||
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
|
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
|
||||||
@StateObject private var viewModel = PrivateChatsViewModel()
|
@StateObject private var viewModel = PrivateChatsViewModel()
|
||||||
|
@State private var selectedChatId: String?
|
||||||
@State private var searchDragStartProgress: CGFloat = 0
|
@State private var searchDragStartProgress: CGFloat = 0
|
||||||
@State private var isSearchGestureActive: Bool = false
|
@State private var isSearchGestureActive: Bool = false
|
||||||
@State private var globalSearchResults: [UserSearchResult] = []
|
@State private var globalSearchResults: [UserSearchResult] = []
|
||||||
@ -31,7 +32,6 @@ struct ChatsTab: View {
|
|||||||
@State private var isPendingChatActive: Bool = false
|
@State private var isPendingChatActive: Bool = false
|
||||||
|
|
||||||
private let searchRevealDistance: CGFloat = 90
|
private let searchRevealDistance: CGFloat = 90
|
||||||
private let scrollToTopAnchorId = "ChatsListTopAnchor"
|
|
||||||
|
|
||||||
private var currentUserId: String? {
|
private var currentUserId: String? {
|
||||||
let userId = loginViewModel.userId
|
let userId = loginViewModel.userId
|
||||||
@ -50,7 +50,6 @@ struct ChatsTab: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.navigationTitle(NSLocalizedString("Чаты", comment: "Chats tab title"))
|
|
||||||
.background(Color(UIColor.systemBackground))
|
.background(Color(UIColor.systemBackground))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.loadInitialChats()
|
viewModel.loadInitialChats()
|
||||||
@ -102,17 +101,16 @@ struct ChatsTab: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
// if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||||||
// loadingState
|
loadingState
|
||||||
// }
|
} else {
|
||||||
chatList
|
chatList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var chatList: some View {
|
private var chatList: some View {
|
||||||
ScrollViewReader { proxy in
|
ZStack {
|
||||||
ZStack {
|
List {
|
||||||
List {
|
|
||||||
// VStack(spacing: 0) {
|
// VStack(spacing: 0) {
|
||||||
// searchBar
|
// searchBar
|
||||||
// .padding(.horizontal, 16)
|
// .padding(.horizontal, 16)
|
||||||
@ -120,75 +118,63 @@ struct ChatsTab: View {
|
|||||||
// }
|
// }
|
||||||
// .background(Color(UIColor.systemBackground))
|
// .background(Color(UIColor.systemBackground))
|
||||||
|
|
||||||
if let message = viewModel.errorMessage {
|
if let message = viewModel.errorMessage {
|
||||||
Section {
|
Section {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
Text(message)
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button(action: triggerChatsReload) {
|
||||||
|
Text(NSLocalizedString("Обновить", comment: ""))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.orange)
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Button(action: triggerChatsReload) {
|
|
||||||
Text(NSLocalizedString("Обновить", comment: ""))
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSearching {
|
||||||
|
Section(header: localSearchHeader) {
|
||||||
|
if localSearchResults.isEmpty {
|
||||||
|
emptySearchResultView
|
||||||
|
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
} else {
|
||||||
|
ForEach(localSearchResults) { chat in
|
||||||
|
chatRowItem(for: chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSearching {
|
Section(header: globalSearchHeader) {
|
||||||
Section(header: localSearchHeader) {
|
globalSearchContent
|
||||||
if localSearchResults.isEmpty {
|
}
|
||||||
emptySearchResultView
|
} else {
|
||||||
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
} else {
|
|
||||||
let firstLocalChatId = localSearchResults.first?.chatId
|
|
||||||
ForEach(localSearchResults) { chat in
|
|
||||||
chatRowItem(for: chat)
|
|
||||||
.id(chat.chatId == firstLocalChatId ? scrollToTopAnchorId : chat.chatId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: globalSearchHeader) {
|
|
||||||
globalSearchContent
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
||||||
// errorState(message: message)
|
// errorState(message: message)
|
||||||
// } else
|
// } else
|
||||||
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
if viewModel.chats.isEmpty {
|
||||||
loadingState
|
emptyState
|
||||||
|
} else {
|
||||||
|
|
||||||
|
ForEach(viewModel.chats) { chat in
|
||||||
|
chatRowItem(for: chat)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.chats.isEmpty {
|
if viewModel.isLoadingMore {
|
||||||
emptyState
|
loadingMoreRow
|
||||||
} else {
|
|
||||||
|
|
||||||
let firstChatId = viewModel.chats.first?.chatId
|
|
||||||
ForEach(viewModel.chats) { chat in
|
|
||||||
chatRowItem(for: chat)
|
|
||||||
.id(chat.chatId == firstChatId ? scrollToTopAnchorId : chat.chatId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.isLoadingMore {
|
|
||||||
loadingMoreRow
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
}
|
||||||
.modifier(ScrollDismissesKeyboardModifier())
|
.listStyle(.plain)
|
||||||
.simultaneousGesture(searchBarGesture)
|
.modifier(ScrollDismissesKeyboardModifier())
|
||||||
.simultaneousGesture(tapToDismissKeyboardGesture)
|
.simultaneousGesture(searchBarGesture)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldScrollToTop)) { _ in
|
.simultaneousGesture(tapToDismissKeyboardGesture)
|
||||||
scrollChatsToTop(using: proxy)
|
|
||||||
}
|
|
||||||
// .safeAreaInset(edge: .top) {
|
// .safeAreaInset(edge: .top) {
|
||||||
// VStack(spacing: 0) {
|
// VStack(spacing: 0) {
|
||||||
// searchBar
|
// searchBar
|
||||||
@ -200,8 +186,7 @@ struct ChatsTab: View {
|
|||||||
// .background(Color(UIColor.systemBackground))
|
// .background(Color(UIColor.systemBackground))
|
||||||
// }
|
// }
|
||||||
|
|
||||||
pendingChatNavigationLink
|
pendingChatNavigationLink
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,14 +217,6 @@ struct ChatsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollChatsToTop(using proxy: ScrollViewProxy) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
|
||||||
proxy.scrollTo(scrollToTopAnchorId, anchor: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var searchBarGesture: some Gesture {
|
private var searchBarGesture: some Gesture {
|
||||||
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
@ -342,10 +319,8 @@ struct ChatsTab: View {
|
|||||||
if globalSearchResults.isEmpty {
|
if globalSearchResults.isEmpty {
|
||||||
globalSearchEmptyRow
|
globalSearchEmptyRow
|
||||||
} else {
|
} else {
|
||||||
let firstGlobalUserId = globalSearchResults.first?.id
|
|
||||||
ForEach(globalSearchResults) { user in
|
ForEach(globalSearchResults) { user in
|
||||||
globalSearchRow(for: user)
|
globalSearchRow(for: user)
|
||||||
.id(user.id == firstGlobalUserId ? AnyHashable(scrollToTopAnchorId) : AnyHashable(user.id))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -365,26 +340,14 @@ struct ChatsTab: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// private var loadingState: some View {
|
|
||||||
// VStack(spacing: 12) {
|
|
||||||
// ProgressView()
|
|
||||||
// Text(NSLocalizedString("Загружаем чаты…", comment: ""))
|
|
||||||
// .font(.subheadline)
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
// }
|
|
||||||
// .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
// }
|
|
||||||
|
|
||||||
private var loadingState: some View {
|
private var loadingState: some View {
|
||||||
HStack {
|
VStack(spacing: 12) {
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle())
|
Text(NSLocalizedString("Загружаем чаты…", comment: ""))
|
||||||
Spacer()
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 18)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func errorState(message: String) -> some View {
|
private func errorState(message: String) -> some View {
|
||||||
@ -408,15 +371,15 @@ struct ChatsTab: View {
|
|||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
// Image(systemName: "bubble.left")
|
Image(systemName: "bubble.left")
|
||||||
// .font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
// .foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
// Button(action: triggerChatsReload) {
|
Button(action: triggerChatsReload) {
|
||||||
// Text(NSLocalizedString("Обновить", comment: ""))
|
Text(NSLocalizedString("Обновить", comment: ""))
|
||||||
// }
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@ -446,7 +409,7 @@ struct ChatsTab: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
|
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
|
||||||
Button {
|
Button {
|
||||||
openChat(chat)
|
selectedChatId = chat.chatId
|
||||||
} label: {
|
} label: {
|
||||||
ChatRowView(
|
ChatRowView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
@ -469,7 +432,17 @@ struct ChatsTab: View {
|
|||||||
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
|
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
.background(
|
||||||
|
NavigationLink(
|
||||||
|
destination: PrivateChatView(chat: chat, currentUserId: currentUserId),
|
||||||
|
tag: chat.chatId,
|
||||||
|
selection: $selectedChatId
|
||||||
|
) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.hidden()
|
||||||
|
)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
// .listRowSeparator(.hidden)
|
// .listRowSeparator(.hidden)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !isSearching else { return }
|
guard !isSearching else { return }
|
||||||
@ -477,12 +450,6 @@ struct ChatsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func openChat(_ chat: PrivateChatListItem) {
|
|
||||||
pendingChatItem = chat
|
|
||||||
isPendingChatActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private var globalSearchLoadingRow: some View {
|
private var globalSearchLoadingRow: some View {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@ -973,25 +940,22 @@ private struct ChatRowView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var initial: String {
|
private var initial: String {
|
||||||
let nameSource: String?
|
let sourceName: String
|
||||||
if let customName = chat.chatData?.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
nameSource = customName
|
if let custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
} else if let fullName = chat.chatData?.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
sourceName = custom
|
||||||
nameSource = fullName
|
} else if let displayName = officialDisplayName {
|
||||||
|
sourceName = displayName
|
||||||
|
} else if let full = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !full.isEmpty {
|
||||||
|
sourceName = full
|
||||||
|
} else if let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
|
||||||
|
sourceName = login
|
||||||
} else {
|
} else {
|
||||||
nameSource = nil
|
sourceName = NSLocalizedString("Неизвестный пользователь", comment: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let name = nameSource {
|
if let character = sourceName.first(where: { !$0.isWhitespace && $0 != "@" }) {
|
||||||
let components = name.split(separator: " ")
|
return String(character).uppercased()
|
||||||
let nameInitials = components.prefix(2).compactMap { $0.first }
|
|
||||||
if !nameInitials.isEmpty {
|
|
||||||
return nameInitials.map { String($0) }.joined().uppercased()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let login = chat.chatData?.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
return String(login.prefix(1)).uppercased()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "?"
|
return "?"
|
||||||
@ -1021,158 +985,111 @@ private struct ChatRowView: View {
|
|||||||
return message.isViewed == true ? Color.accentColor : Color.secondary
|
return message.isViewed == true ? Color.accentColor : Color.secondary
|
||||||
}
|
}
|
||||||
|
|
||||||
private var avatarUrl: URL? {
|
|
||||||
guard let chatData = chat.chatData,
|
|
||||||
let fileId = chatData.avatars?.current?.fileId else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let userId = chatData.userId
|
|
||||||
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
if let url = avatarUrl, let fileId = chat.chatData?.avatars?.current?.fileId, let loggedInUserId = currentUserId {
|
Circle()
|
||||||
CachedAvatarView(url: url, fileId: fileId, userId: loggedInUserId) {
|
.fill(avatarBackgroundColor)
|
||||||
placeholderAvatar
|
|
||||||
}
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: avatarSize, height: avatarSize)
|
.frame(width: avatarSize, height: avatarSize)
|
||||||
.clipShape(Circle())
|
.overlay(
|
||||||
} else {
|
Group {
|
||||||
placeholderAvatar
|
if isDeletedUser {
|
||||||
}
|
Image(systemName: deletedUserSymbolName)
|
||||||
|
.symbolRenderingMode(.hierarchical)
|
||||||
|
.font(.system(size: avatarSize * 0.45, weight: .semibold))
|
||||||
|
.foregroundColor(avatarTextColor)
|
||||||
|
} else {
|
||||||
|
Text(initial)
|
||||||
|
.font(.system(size: avatarSize * 0.5, weight: .semibold))
|
||||||
|
.foregroundColor(avatarTextColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack{
|
if let officialName = officialDisplayName {
|
||||||
if let officialName = officialDisplayName {
|
HStack(spacing: 6) {
|
||||||
HStack(spacing: 6) {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
Text(officialName)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
.strikethrough(isDeletedUser, color: Color.secondary)
|
|
||||||
} else {
|
|
||||||
Text(officialName)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: "checkmark.seal.fill")
|
|
||||||
.foregroundColor(Color.accentColor)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
Text(title)
|
Text(officialName)
|
||||||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
.fontWeight(.semibold)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
.strikethrough(isDeletedUser, color: Color.secondary)
|
.strikethrough(isDeletedUser, color: Color.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text(title)
|
Text(officialName)
|
||||||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
.fontWeight(.semibold)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.foregroundColor(Color.accentColor)
|
||||||
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let timestamp {
|
// if let login = loginDisplay {
|
||||||
Spacer()
|
// Text(login)
|
||||||
HStack(spacing: 4) {
|
// .font(.footnote)
|
||||||
if shouldShowReadStatus {
|
// .foregroundColor(.secondary)
|
||||||
Image(systemName: readStatusIconName)
|
// .lineLimit(1)
|
||||||
.foregroundColor(readStatusColor)
|
// .truncationMode(.tail)
|
||||||
.font(.caption2)
|
// }
|
||||||
}
|
} else {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
Text(timestamp)
|
Text(title)
|
||||||
.font(.caption)
|
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.primary)
|
||||||
}
|
.lineLimit(1)
|
||||||
}
|
.truncationMode(.tail)
|
||||||
|
.strikethrough(isDeletedUser, color: Color.secondary)
|
||||||
}
|
} else {
|
||||||
|
Text(title)
|
||||||
HStack {
|
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||||||
Text(messagePreview)
|
.foregroundColor(.primary)
|
||||||
.font(.subheadline)
|
.lineLimit(1)
|
||||||
.foregroundColor(subtitleColor)
|
.truncationMode(.tail)
|
||||||
.lineLimit(messageLimitLine)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
|
|
||||||
if chat.unreadCount > 0 {
|
|
||||||
Spacer()
|
|
||||||
Text("\(chat.unreadCount)")
|
|
||||||
.font(.caption2.bold())
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(
|
|
||||||
Capsule().fill(Color.accentColor)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(messagePreview)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(subtitleColor)
|
||||||
|
.lineLimit(messageLimitLine)
|
||||||
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
// Spacer()
|
Spacer()
|
||||||
|
|
||||||
// VStack(alignment: .trailing, spacing: 6) {
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
//// if let timestamp {
|
if let timestamp {
|
||||||
//// HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
//// if shouldShowReadStatus {
|
if shouldShowReadStatus {
|
||||||
//// Image(systemName: readStatusIconName)
|
Image(systemName: readStatusIconName)
|
||||||
//// .foregroundColor(readStatusColor)
|
.foregroundColor(readStatusColor)
|
||||||
//// .font(.caption2)
|
.font(.caption2)
|
||||||
//// }
|
}
|
||||||
////
|
|
||||||
//// Text(timestamp)
|
|
||||||
//// .font(.caption)
|
|
||||||
//// .foregroundColor(.secondary)
|
|
||||||
//// }
|
|
||||||
//// }
|
|
||||||
//
|
|
||||||
// if chat.unreadCount > 0 {
|
|
||||||
// Text("\(chat.unreadCount)")
|
|
||||||
// .font(.caption2.bold())
|
|
||||||
// .foregroundColor(.white)
|
|
||||||
// .padding(.horizontal, 8)
|
|
||||||
// .padding(.vertical, 4)
|
|
||||||
// .background(
|
|
||||||
// Capsule().fill(Color.accentColor)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
Text(timestamp)
|
||||||
private var placeholderAvatar: some View {
|
.font(.caption)
|
||||||
Circle()
|
.foregroundColor(.secondary)
|
||||||
.fill(avatarBackgroundColor)
|
|
||||||
.frame(width: avatarSize, height: avatarSize)
|
|
||||||
.overlay(
|
|
||||||
Group {
|
|
||||||
if isDeletedUser {
|
|
||||||
Image(systemName: deletedUserSymbolName)
|
|
||||||
.symbolRenderingMode(.hierarchical)
|
|
||||||
.font(.system(size: avatarSize * 0.45, weight: .semibold))
|
|
||||||
.foregroundColor(avatarTextColor)
|
|
||||||
} else {
|
|
||||||
Text(initial)
|
|
||||||
.font(.system(size: avatarSize * 0.5, weight: .semibold))
|
|
||||||
.foregroundColor(avatarTextColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
if chat.unreadCount > 0 {
|
||||||
|
Text("\(chat.unreadCount)")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(Color.accentColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func formattedTimestamp(for date: Date) -> String {
|
private static func formattedTimestamp(for date: Date) -> String {
|
||||||
@ -1264,5 +1181,4 @@ extension Notification.Name {
|
|||||||
static let debugRefreshChats = Notification.Name("debugRefreshChats")
|
static let debugRefreshChats = Notification.Name("debugRefreshChats")
|
||||||
static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh")
|
static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh")
|
||||||
static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted")
|
static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted")
|
||||||
static let chatsShouldScrollToTop = Notification.Name("chatsShouldScrollToTop")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,767 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct ContactsTab: View {
|
|
||||||
@ObservedObject private var loginViewModel: LoginViewModel
|
|
||||||
@State private var contacts: [Contact] = []
|
|
||||||
@State private var isLoading = false
|
|
||||||
@State private var loadError: String?
|
|
||||||
@State private var pagingError: String?
|
|
||||||
@State private var activeAlert: ContactsAlert?
|
|
||||||
@State private var hasMore = true
|
|
||||||
@State private var offset = 0
|
|
||||||
@State private var creatingChatForContactId: UUID?
|
|
||||||
@State private var pendingChatItem: PrivateChatListItem?
|
|
||||||
@State private var isPendingChatActive = false
|
|
||||||
@State private var contactAvatars: [UUID: AvatarInfo] = [:]
|
|
||||||
@State private var avatarLoadedIds: Set<UUID> = []
|
|
||||||
@State private var avatarLoadingIds: Set<UUID> = []
|
|
||||||
@State private var contactToEdit: Contact?
|
|
||||||
@State private var contactPendingBlock: Contact?
|
|
||||||
@State private var contactPendingDelete: Contact?
|
|
||||||
@State private var showBlockConfirmation = false
|
|
||||||
@State private var showDeleteConfirmation = false
|
|
||||||
@State private var blockingContactIds: Set<UUID> = []
|
|
||||||
@State private var deletingContactIds: Set<UUID> = []
|
|
||||||
|
|
||||||
private let contactsService = ContactsService()
|
|
||||||
private let chatService = ChatService()
|
|
||||||
private let profileService = ProfileService()
|
|
||||||
private let blockedUsersService = BlockedUsersService()
|
|
||||||
private let pageSize = 25
|
|
||||||
|
|
||||||
private var currentUserId: String? {
|
|
||||||
let identifier = loginViewModel.userId
|
|
||||||
return identifier.isEmpty ? nil : identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
init(viewModel: LoginViewModel) {
|
|
||||||
self._loginViewModel = ObservedObject(wrappedValue: viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
if isLoading && contacts.isEmpty {
|
|
||||||
loadingState
|
|
||||||
}
|
|
||||||
|
|
||||||
if let loadError, contacts.isEmpty {
|
|
||||||
errorState(loadError)
|
|
||||||
} else if contacts.isEmpty {
|
|
||||||
emptyState
|
|
||||||
} else {
|
|
||||||
ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in
|
|
||||||
Button {
|
|
||||||
openChat(for: contact)
|
|
||||||
} label: {
|
|
||||||
ContactRow(
|
|
||||||
contact: contact,
|
|
||||||
avatarInfo: contactAvatars[contact.id],
|
|
||||||
currentUserId: currentUserId,
|
|
||||||
isLoading: isRowBusy(contact)
|
|
||||||
)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(isRowBusy(contact))
|
|
||||||
.contextMenu {
|
|
||||||
Button {
|
|
||||||
handleContactAction(.edit, for: contact)
|
|
||||||
} label: {
|
|
||||||
Label(
|
|
||||||
NSLocalizedString("Изменить контакт", comment: "Contacts context action edit"),
|
|
||||||
systemImage: "square.and.pencil"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.disabled(contact.isDeleted)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
handleContactAction(.block, for: contact)
|
|
||||||
} label: {
|
|
||||||
Label(
|
|
||||||
NSLocalizedString("Заблокировать контакт", comment: "Contacts context action block"),
|
|
||||||
systemImage: "hand.raised.fill"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.disabled(contact.isDeleted)
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
handleContactAction(.delete, for: contact)
|
|
||||||
} label: {
|
|
||||||
Label(
|
|
||||||
NSLocalizedString("Удалить контакт", comment: "Contacts context action delete"),
|
|
||||||
systemImage: "trash"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12))
|
|
||||||
.onAppear {
|
|
||||||
loadAvatarIfNeeded(for: contact)
|
|
||||||
if index >= contacts.count - 5 {
|
|
||||||
Task {
|
|
||||||
await loadContacts(reset: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLoading && !contacts.isEmpty {
|
|
||||||
loadingState
|
|
||||||
} else if let pagingError, !contacts.isEmpty {
|
|
||||||
pagingErrorState(pagingError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Контакты", comment: "Contacts tab title"))
|
|
||||||
.background(Color(UIColor.systemBackground))
|
|
||||||
.listStyle(.plain)
|
|
||||||
.task {
|
|
||||||
await loadContacts(reset: false)
|
|
||||||
}
|
|
||||||
.alert(item: $activeAlert) { alert in
|
|
||||||
switch alert {
|
|
||||||
case .error(_, let message):
|
|
||||||
return Alert(
|
|
||||||
title: Text(NSLocalizedString("Ошибка", comment: "Contacts load error title")),
|
|
||||||
message: Text(message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
|
|
||||||
)
|
|
||||||
case .info(_, let title, let message):
|
|
||||||
return Alert(
|
|
||||||
title: Text(title),
|
|
||||||
message: Text(message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(item: $contactToEdit) { contact in
|
|
||||||
NavigationView {
|
|
||||||
ContactEditView(
|
|
||||||
contact: contactEditInfo(for: contact),
|
|
||||||
onContactDeleted: {
|
|
||||||
handleContactRemoved(contact.id)
|
|
||||||
},
|
|
||||||
onContactUpdated: { newName in
|
|
||||||
handleContactRenamed(contact.id, newName: newName)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
NSLocalizedString("Заблокировать контакт?", comment: "Contacts block confirmation title"),
|
|
||||||
isPresented: $showBlockConfirmation,
|
|
||||||
presenting: contactPendingBlock
|
|
||||||
) { contact in
|
|
||||||
Button(NSLocalizedString("Заблокировать", comment: "Contacts block confirm action"), role: .destructive) {
|
|
||||||
showBlockConfirmation = false
|
|
||||||
contactPendingBlock = nil
|
|
||||||
performBlockContact(contact)
|
|
||||||
}
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
|
|
||||||
showBlockConfirmation = false
|
|
||||||
contactPendingBlock = nil
|
|
||||||
}
|
|
||||||
} message: { contact in
|
|
||||||
Text(String(
|
|
||||||
format: NSLocalizedString("Пользователь \"%1$@\" будет добавлен в чёрный список.", comment: "Contacts block confirmation message"),
|
|
||||||
contact.displayName
|
|
||||||
))
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
NSLocalizedString("Удалить контакт?", comment: "Contacts delete confirmation title"),
|
|
||||||
isPresented: $showDeleteConfirmation,
|
|
||||||
presenting: contactPendingDelete
|
|
||||||
) { contact in
|
|
||||||
Button(NSLocalizedString("Удалить", comment: "Contacts delete confirm action"), role: .destructive) {
|
|
||||||
showDeleteConfirmation = false
|
|
||||||
contactPendingDelete = nil
|
|
||||||
performDeleteContact(contact)
|
|
||||||
}
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
|
|
||||||
showDeleteConfirmation = false
|
|
||||||
contactPendingDelete = nil
|
|
||||||
}
|
|
||||||
} message: { contact in
|
|
||||||
Text(String(
|
|
||||||
format: NSLocalizedString("Контакт \"%1$@\" будет удалён из списка.", comment: "Contacts delete confirmation message"),
|
|
||||||
contact.displayName
|
|
||||||
))
|
|
||||||
}
|
|
||||||
.overlay(pendingChatNavigationLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var loadingState: some View {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle())
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 18)
|
|
||||||
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func errorState(_ message: String) -> some View {
|
|
||||||
HStack(alignment: .center, spacing: 8) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text(message)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Spacer()
|
|
||||||
Button(action: { Task { await refreshContacts() } }) {
|
|
||||||
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pagingErrorState(_ message: String) -> some View {
|
|
||||||
HStack(alignment: .center, spacing: 8) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text(message)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Spacer()
|
|
||||||
Button(action: { Task { await loadContacts(reset: false) } }) {
|
|
||||||
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyState: some View {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "person.crop.circle.badge.questionmark")
|
|
||||||
.font(.system(size: 52))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(NSLocalizedString("Контактов пока нет", comment: "Contacts empty state title"))
|
|
||||||
.font(.headline)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Text(NSLocalizedString("Добавьте контакты, чтобы быстрее выходить на связь.", comment: "Contacts empty state subtitle"))
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 28)
|
|
||||||
.listRowInsets(EdgeInsets(top: 20, leading: 12, bottom: 20, trailing: 12))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func refreshContacts() async {
|
|
||||||
hasMore = true
|
|
||||||
offset = 0
|
|
||||||
pagingError = nil
|
|
||||||
loadError = nil
|
|
||||||
contacts.removeAll()
|
|
||||||
await loadContacts(reset: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadContacts(reset: Bool) async {
|
|
||||||
if isLoading { return }
|
|
||||||
if !reset && !hasMore { return }
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
if offset == 0 {
|
|
||||||
loadError = nil
|
|
||||||
}
|
|
||||||
pagingError = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
let payload = try await contactsService.fetchContacts(limit: pageSize, offset: offset)
|
|
||||||
let newContacts = payload.items.map(Contact.init)
|
|
||||||
if reset {
|
|
||||||
contacts = newContacts
|
|
||||||
} else {
|
|
||||||
contacts.append(contentsOf: newContacts)
|
|
||||||
}
|
|
||||||
offset += newContacts.count
|
|
||||||
hasMore = payload.hasMore
|
|
||||||
} catch {
|
|
||||||
let message = error.localizedDescription
|
|
||||||
if contacts.isEmpty {
|
|
||||||
loadError = message
|
|
||||||
} else {
|
|
||||||
pagingError = message
|
|
||||||
}
|
|
||||||
if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") }
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleContactAction(_ action: ContactAction, for contact: Contact) {
|
|
||||||
guard !isRowBusy(contact) else { return }
|
|
||||||
switch action {
|
|
||||||
case .edit:
|
|
||||||
contactToEdit = contact
|
|
||||||
case .block:
|
|
||||||
contactPendingBlock = contact
|
|
||||||
showBlockConfirmation = true
|
|
||||||
case .delete:
|
|
||||||
contactPendingDelete = contact
|
|
||||||
showDeleteConfirmation = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var pendingChatNavigationLink: some View {
|
|
||||||
NavigationLink(
|
|
||||||
destination: pendingChatDestination,
|
|
||||||
isActive: Binding(
|
|
||||||
get: { isPendingChatActive && pendingChatItem != nil },
|
|
||||||
set: { newValue in
|
|
||||||
if !newValue {
|
|
||||||
isPendingChatActive = false
|
|
||||||
pendingChatItem = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
.hidden()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var pendingChatDestination: some View {
|
|
||||||
if let pendingChatItem {
|
|
||||||
PrivateChatView(chat: pendingChatItem, currentUserId: currentUserId)
|
|
||||||
} else {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openChat(for contact: Contact) {
|
|
||||||
guard creatingChatForContactId == nil else { return }
|
|
||||||
|
|
||||||
creatingChatForContactId = contact.id
|
|
||||||
|
|
||||||
chatService.createOrFindPrivateChat(targetUserId: contact.id.uuidString) { result in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
creatingChatForContactId = nil
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .success(let data):
|
|
||||||
let chatItem = PrivateChatListItem(
|
|
||||||
chatId: data.chatId,
|
|
||||||
chatType: data.chatType,
|
|
||||||
chatData: chatProfile(for: contact),
|
|
||||||
lastMessage: nil,
|
|
||||||
createdAt: nil,
|
|
||||||
unreadCount: 0
|
|
||||||
)
|
|
||||||
pendingChatItem = chatItem
|
|
||||||
isPendingChatActive = true
|
|
||||||
case .failure(let error):
|
|
||||||
activeAlert = .error(message: friendlyChatCreationMessage(for: error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func chatProfile(for contact: Contact) -> ChatProfile {
|
|
||||||
ChatProfile(
|
|
||||||
userId: contact.id.uuidString,
|
|
||||||
login: contact.login,
|
|
||||||
fullName: contact.fullName,
|
|
||||||
customName: contact.customName,
|
|
||||||
createdAt: contact.createdAt,
|
|
||||||
isOfficial: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func friendlyChatCreationMessage(for error: Error) -> String {
|
|
||||||
if let chatError = error as? ChatServiceError {
|
|
||||||
return chatError.errorDescription ?? NSLocalizedString("Не удалось открыть чат.", comment: "Chat creation fallback")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let networkError = error as? NetworkError {
|
|
||||||
switch networkError {
|
|
||||||
case .unauthorized:
|
|
||||||
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Chat creation unauthorized")
|
|
||||||
case .invalidURL, .noResponse:
|
|
||||||
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Chat creation connection")
|
|
||||||
case .network(let underlying):
|
|
||||||
return String(format: NSLocalizedString("Ошибка сети: %@", comment: "Chat creation network error"), underlying.localizedDescription)
|
|
||||||
case .server(let statusCode, let data):
|
|
||||||
if let data {
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
if let payload = try? decoder.decode(ErrorResponse.self, from: data) {
|
|
||||||
if let detail = payload.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty {
|
|
||||||
return detail
|
|
||||||
}
|
|
||||||
if let message = payload.data?.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: "Chat creation server status"), "\(statusCode)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NSLocalizedString("Произошла неизвестная ошибка. Попробуйте позже.", comment: "Chat creation unknown error")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadAvatarIfNeeded(for contact: Contact) {
|
|
||||||
guard !contact.isDeleted else { return }
|
|
||||||
let contactId = contact.id
|
|
||||||
if avatarLoadedIds.contains(contactId) || avatarLoadingIds.contains(contactId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
avatarLoadingIds.insert(contactId)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let profile = try await profileService.fetchProfile(userId: contactId)
|
|
||||||
await MainActor.run {
|
|
||||||
if let info = profile.avatars?.current {
|
|
||||||
contactAvatars[contactId] = info
|
|
||||||
}
|
|
||||||
avatarLoadedIds.insert(contactId)
|
|
||||||
avatarLoadingIds.remove(contactId)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[ContactsTab] load avatar failed for \(contactId): \(error)")
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
|
||||||
avatarLoadingIds.remove(contactId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performBlockContact(_ contact: Contact) {
|
|
||||||
let contactId = contact.id
|
|
||||||
guard !blockingContactIds.contains(contactId) else { return }
|
|
||||||
blockingContactIds.insert(contactId)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
_ = try await blockedUsersService.add(userId: contactId)
|
|
||||||
await MainActor.run {
|
|
||||||
blockingContactIds.remove(contactId)
|
|
||||||
handleContactRemoved(contactId)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
blockingContactIds.remove(contactId)
|
|
||||||
activeAlert = .error(message: error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performDeleteContact(_ contact: Contact) {
|
|
||||||
let contactId = contact.id
|
|
||||||
guard !deletingContactIds.contains(contactId) else { return }
|
|
||||||
deletingContactIds.insert(contactId)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await contactsService.removeContact(userId: contactId)
|
|
||||||
await MainActor.run {
|
|
||||||
deletingContactIds.remove(contactId)
|
|
||||||
handleContactRemoved(contactId)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
deletingContactIds.remove(contactId)
|
|
||||||
activeAlert = .error(message: error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func contactEditInfo(for contact: Contact) -> ContactEditInfo {
|
|
||||||
ContactEditInfo(
|
|
||||||
userId: contact.id,
|
|
||||||
login: contact.login,
|
|
||||||
fullName: contact.fullName,
|
|
||||||
customName: contact.customName,
|
|
||||||
avatarFileId: contactAvatars[contact.id]?.fileId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleContactRenamed(_ contactId: UUID, newName: String) {
|
|
||||||
guard let index = contacts.firstIndex(where: { $0.id == contactId }) else { return }
|
|
||||||
contacts[index] = contacts[index].updatingCustomName(newName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleContactRemoved(_ contactId: UUID) {
|
|
||||||
contacts.removeAll { $0.id == contactId }
|
|
||||||
contactAvatars.removeValue(forKey: contactId)
|
|
||||||
avatarLoadedIds.remove(contactId)
|
|
||||||
avatarLoadingIds.remove(contactId)
|
|
||||||
if creatingChatForContactId == contactId {
|
|
||||||
creatingChatForContactId = nil
|
|
||||||
}
|
|
||||||
blockingContactIds.remove(contactId)
|
|
||||||
deletingContactIds.remove(contactId)
|
|
||||||
if contactToEdit?.id == contactId {
|
|
||||||
contactToEdit = nil
|
|
||||||
}
|
|
||||||
if contactPendingBlock?.id == contactId {
|
|
||||||
contactPendingBlock = nil
|
|
||||||
showBlockConfirmation = false
|
|
||||||
}
|
|
||||||
if contactPendingDelete?.id == contactId {
|
|
||||||
contactPendingDelete = nil
|
|
||||||
showDeleteConfirmation = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isRowBusy(_ contact: Contact) -> Bool {
|
|
||||||
creatingChatForContactId == contact.id
|
|
||||||
|| blockingContactIds.contains(contact.id)
|
|
||||||
|| deletingContactIds.contains(contact.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContactRow: View {
|
|
||||||
let contact: Contact
|
|
||||||
let avatarInfo: AvatarInfo?
|
|
||||||
let currentUserId: String?
|
|
||||||
let isLoading: Bool
|
|
||||||
|
|
||||||
private let avatarSize: CGFloat = 40
|
|
||||||
|
|
||||||
init(contact: Contact, avatarInfo: AvatarInfo? = nil, currentUserId: String? = nil, isLoading: Bool = false) {
|
|
||||||
self.contact = contact
|
|
||||||
self.avatarInfo = avatarInfo
|
|
||||||
self.currentUserId = currentUserId
|
|
||||||
self.isLoading = isLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
avatarView
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
HStack(alignment: .firstTextBaseline) {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
Text(contact.displayName)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.strikethrough(contact.isDeleted, color: .secondary)
|
|
||||||
} else {
|
|
||||||
Text(contact.displayName)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.strikethrough(contact.isDeleted)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Text(contact.formattedCreatedAt)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let handle = contact.handle {
|
|
||||||
Text(handle)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if contact.friendCode {
|
|
||||||
friendCodeBadge
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var avatarView: some View {
|
|
||||||
if let fileId = avatarInfo?.fileId,
|
|
||||||
let url = avatarURL(for: fileId),
|
|
||||||
let currentUserId {
|
|
||||||
CachedAvatarView(url: url, fileId: fileId, userId: currentUserId) {
|
|
||||||
placeholderAvatar
|
|
||||||
}
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: avatarSize, height: avatarSize)
|
|
||||||
.clipShape(Circle())
|
|
||||||
} else {
|
|
||||||
placeholderAvatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func avatarURL(for fileId: String) -> URL? {
|
|
||||||
let userId = contact.id.uuidString
|
|
||||||
let path = "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)"
|
|
||||||
return URL(string: path)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var placeholderAvatar: some View {
|
|
||||||
Circle()
|
|
||||||
.fill(avatarBackgroundColor)
|
|
||||||
.frame(width: avatarSize, height: avatarSize)
|
|
||||||
.overlay(
|
|
||||||
Group {
|
|
||||||
if contact.isDeleted {
|
|
||||||
Image(systemName: "person.slash")
|
|
||||||
.symbolRenderingMode(.hierarchical)
|
|
||||||
.font(.system(size: avatarSize * 0.45, weight: .semibold))
|
|
||||||
.foregroundColor(avatarTextColor)
|
|
||||||
} else {
|
|
||||||
Text(contact.initials)
|
|
||||||
.font(.system(size: avatarSize * 0.5, weight: .semibold))
|
|
||||||
.foregroundColor(avatarTextColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarBackgroundColor: Color {
|
|
||||||
contact.isDeleted ? Color(.systemGray5) : Color.accentColor.opacity(0.15)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var avatarTextColor: Color {
|
|
||||||
contact.isDeleted ? Color.accentColor : Color.accentColor
|
|
||||||
}
|
|
||||||
|
|
||||||
private var friendCodeBadge: some View {
|
|
||||||
Text(NSLocalizedString("Код дружбы", comment: "Friend code badge"))
|
|
||||||
.font(.caption2.weight(.medium))
|
|
||||||
.foregroundColor(Color.accentColor)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(Color.accentColor.opacity(0.12))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Contact: Identifiable, Equatable {
|
|
||||||
let id: UUID
|
|
||||||
let login: String?
|
|
||||||
let fullName: String?
|
|
||||||
let customName: String?
|
|
||||||
let friendCode: Bool
|
|
||||||
let createdAt: Date
|
|
||||||
|
|
||||||
let displayName: String
|
|
||||||
let handle: String?
|
|
||||||
|
|
||||||
var isDeleted: Bool {
|
|
||||||
login?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
|
||||||
}
|
|
||||||
|
|
||||||
var initials: String {
|
|
||||||
if isDeleted { return "" }
|
|
||||||
|
|
||||||
let nameSource: String?
|
|
||||||
if let customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
nameSource = customName
|
|
||||||
} else if let fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
nameSource = fullName
|
|
||||||
} else {
|
|
||||||
nameSource = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let name = nameSource {
|
|
||||||
let components = name.split(separator: " ")
|
|
||||||
let nameInitials = components.prefix(2).compactMap { $0.first }
|
|
||||||
if !nameInitials.isEmpty {
|
|
||||||
return nameInitials.map { String($0) }.joined().uppercased()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(login!.prefix(1)).uppercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
var formattedCreatedAt: String {
|
|
||||||
Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
init(payload: ContactPayload) {
|
|
||||||
self.id = payload.userId
|
|
||||||
self.login = payload.login
|
|
||||||
self.fullName = payload.fullName
|
|
||||||
self.customName = payload.customName
|
|
||||||
self.friendCode = payload.friendCode
|
|
||||||
self.createdAt = payload.createdAt
|
|
||||||
|
|
||||||
let isUserDeleted = payload.login?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
|
||||||
|
|
||||||
if isUserDeleted {
|
|
||||||
self.displayName = NSLocalizedString("Неизвестный пользователь", comment: "Deleted user display name")
|
|
||||||
self.handle = nil
|
|
||||||
} else {
|
|
||||||
if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
self.displayName = customName
|
|
||||||
} else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
self.displayName = fullName
|
|
||||||
} else {
|
|
||||||
self.displayName = payload.login!
|
|
||||||
}
|
|
||||||
|
|
||||||
if let login = payload.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
self.handle = "@\(login)"
|
|
||||||
} else {
|
|
||||||
self.handle = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatingCustomName(_ newName: String) -> Contact {
|
|
||||||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let updatedCustomName = trimmed.isEmpty ? nil : trimmed
|
|
||||||
let payload = ContactPayload(
|
|
||||||
userId: id,
|
|
||||||
login: login,
|
|
||||||
fullName: fullName,
|
|
||||||
customName: updatedCustomName,
|
|
||||||
friendCode: friendCode,
|
|
||||||
createdAt: createdAt
|
|
||||||
)
|
|
||||||
return Contact(payload: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
|
||||||
let formatter = RelativeDateTimeFormatter()
|
|
||||||
formatter.unitsStyle = .short
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ContactsAlert: Identifiable {
|
|
||||||
case error(id: UUID = UUID(), message: String)
|
|
||||||
case info(id: UUID = UUID(), title: String, message: String)
|
|
||||||
|
|
||||||
var id: String {
|
|
||||||
switch self {
|
|
||||||
case .error(let id, _), .info(let id, _, _):
|
|
||||||
return id.uuidString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ContactAction {
|
|
||||||
case edit
|
|
||||||
case block
|
|
||||||
case delete
|
|
||||||
}
|
|
||||||
@ -2,48 +2,37 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CustomTabBar: View {
|
struct CustomTabBar: View {
|
||||||
@Binding var selectedTab: Int
|
@Binding var selectedTab: Int
|
||||||
let isMessengerModeEnabled: Bool
|
|
||||||
var onCreate: () -> Void
|
var onCreate: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
if isMessengerModeEnabled {
|
// Tab 1: Feed
|
||||||
|
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
|
||||||
|
selectedTab = 0
|
||||||
|
}
|
||||||
|
|
||||||
TabBarButton(systemName: "person.2.fill", text: NSLocalizedString("Контакты", comment: ""), isSelected: selectedTab == 4) {
|
// Tab 2: Search
|
||||||
selectedTab = 4
|
TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) {
|
||||||
}
|
selectedTab = 1
|
||||||
|
}
|
||||||
|
|
||||||
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
|
// Create Button
|
||||||
handleChatsTabTap()
|
CreateButton {
|
||||||
}
|
onCreate()
|
||||||
|
}
|
||||||
|
|
||||||
TabBarButton(systemName: "gearshape.fill", text: NSLocalizedString("Настройки", comment: ""), isSelected: selectedTab == 5) {
|
// Tab 3: Chats
|
||||||
selectedTab = 5
|
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
|
||||||
}
|
selectedTab = 2
|
||||||
} else {
|
}
|
||||||
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
|
|
||||||
selectedTab = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) {
|
// Tab 4: Profile
|
||||||
selectedTab = 1
|
TabBarButton(systemName: "person.crop.square", text: NSLocalizedString("Лицо", comment: ""), isSelected: selectedTab == 3) {
|
||||||
}
|
selectedTab = 3
|
||||||
|
|
||||||
CreateButton {
|
|
||||||
onCreate()
|
|
||||||
}
|
|
||||||
|
|
||||||
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
|
|
||||||
handleChatsTabTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
TabBarButton(systemName: "person.crop.square", text: NSLocalizedString("Лицо", comment: ""), isSelected: selectedTab == 3) {
|
|
||||||
selectedTab = 3
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, isMessengerModeEnabled ? 6 : 1)
|
.padding(.top, 1)
|
||||||
.padding(.bottom, 30) // Добавляем отступ снизу
|
.padding(.bottom, 30) // Добавляем отступ снизу
|
||||||
// .background(Color(.systemGray6))
|
// .background(Color(.systemGray6))
|
||||||
}
|
}
|
||||||
@ -93,13 +82,3 @@ struct CreateButton: View {
|
|||||||
.offset(y: -3)
|
.offset(y: -3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension CustomTabBar {
|
|
||||||
func handleChatsTabTap() {
|
|
||||||
if selectedTab == 2 {
|
|
||||||
NotificationCenter.default.post(name: .chatsShouldScrollToTop, object: nil)
|
|
||||||
} else {
|
|
||||||
selectedTab = 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,9 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
@EnvironmentObject private var messageCenter: IncomingMessageCenter
|
|
||||||
@State private var selectedTab: Int = 0
|
@State private var selectedTab: Int = 0
|
||||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
|
||||||
// @StateObject private var newHomeTabViewModel = NewHomeTabViewModel()
|
// @StateObject private var newHomeTabViewModel = NewHomeTabViewModel()
|
||||||
|
|
||||||
// Состояния для TopBarView
|
// Состояния для TopBarView
|
||||||
@ -18,21 +16,14 @@ struct MainView: View {
|
|||||||
@State private var chatSearchRevealProgress: CGFloat = 0
|
@State private var chatSearchRevealProgress: CGFloat = 0
|
||||||
@State private var chatSearchText: String = ""
|
@State private var chatSearchText: String = ""
|
||||||
@State private var isSettingsPresented = false
|
@State private var isSettingsPresented = false
|
||||||
@State private var isQrPresented = false
|
|
||||||
@State private var deepLinkChatItem: PrivateChatListItem?
|
|
||||||
@State private var isDeepLinkChatActive = false
|
|
||||||
@State private var hasTriggeredSecuritySettingsOnboarding = false
|
|
||||||
@State private var isAfterRegisterPresented = false
|
|
||||||
|
|
||||||
private var tabTitle: String {
|
private var tabTitle: String {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case 0: return NSLocalizedString("Home", comment: "")
|
case 0: return "Home"
|
||||||
case 1: return NSLocalizedString("Concept", comment: "")
|
case 1: return "Concept"
|
||||||
case 2: return NSLocalizedString("Чаты", comment: "")
|
case 2: return "Chats"
|
||||||
case 3: return NSLocalizedString("Profile", comment: "")
|
case 3: return "Profile"
|
||||||
case 4: return NSLocalizedString("Контакты", comment: "")
|
default: return "Home"
|
||||||
case 5: return NSLocalizedString("Настройки", comment: "")
|
|
||||||
default: return NSLocalizedString("Home", comment: "")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,54 +39,36 @@ struct MainView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
TopBarView(
|
TopBarView(
|
||||||
title: tabTitle,
|
title: tabTitle,
|
||||||
isMessengerModeEnabled: isMessengerModeEnabled,
|
|
||||||
selectedAccount: $selectedAccount,
|
selectedAccount: $selectedAccount,
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
isSettingsPresented: $isSettingsPresented,
|
isSettingsPresented: $isSettingsPresented,
|
||||||
isQrPresented: $isQrPresented,
|
|
||||||
isSideMenuPresented: $isSideMenuPresented,
|
isSideMenuPresented: $isSideMenuPresented,
|
||||||
chatSearchRevealProgress: $chatSearchRevealProgress,
|
chatSearchRevealProgress: $chatSearchRevealProgress,
|
||||||
chatSearchText: $chatSearchText
|
chatSearchText: $chatSearchText
|
||||||
)
|
)
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
if isMessengerModeEnabled {
|
NewHomeTab()
|
||||||
ChatsTab(
|
.opacity(selectedTab == 0 ? 1 : 0)
|
||||||
loginViewModel: viewModel,
|
|
||||||
searchRevealProgress: $chatSearchRevealProgress,
|
ConceptTab()
|
||||||
searchText: $chatSearchText
|
.opacity(selectedTab == 1 ? 1 : 0)
|
||||||
)
|
|
||||||
|
ChatsTab(
|
||||||
|
loginViewModel: viewModel,
|
||||||
|
searchRevealProgress: $chatSearchRevealProgress,
|
||||||
|
searchText: $chatSearchText
|
||||||
|
)
|
||||||
.opacity(selectedTab == 2 ? 1 : 0)
|
.opacity(selectedTab == 2 ? 1 : 0)
|
||||||
.allowsHitTesting(selectedTab == 2)
|
.allowsHitTesting(selectedTab == 2)
|
||||||
|
|
||||||
ContactsTab(viewModel: viewModel)
|
ProfileTab()
|
||||||
.opacity(selectedTab == 4 ? 1 : 0)
|
.opacity(selectedTab == 3 ? 1 : 0)
|
||||||
|
|
||||||
SettingsView(viewModel: viewModel)
|
|
||||||
.opacity(selectedTab == 5 ? 1 : 0)
|
|
||||||
} else {
|
|
||||||
NewHomeTab()
|
|
||||||
.opacity(selectedTab == 0 ? 1 : 0)
|
|
||||||
|
|
||||||
ConceptTab()
|
|
||||||
.opacity(selectedTab == 1 ? 1 : 0)
|
|
||||||
|
|
||||||
ChatsTab(
|
|
||||||
loginViewModel: viewModel,
|
|
||||||
searchRevealProgress: $chatSearchRevealProgress,
|
|
||||||
searchText: $chatSearchText
|
|
||||||
)
|
|
||||||
.opacity(selectedTab == 2 ? 1 : 0)
|
|
||||||
.allowsHitTesting(selectedTab == 2)
|
|
||||||
|
|
||||||
ProfileTab()
|
|
||||||
.opacity(selectedTab == 3 ? 1 : 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
CustomTabBar(selectedTab: $selectedTab, isMessengerModeEnabled: isMessengerModeEnabled) {
|
CustomTabBar(selectedTab: $selectedTab) {
|
||||||
print("Create button tapped")
|
print("Create button tapped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -118,49 +91,41 @@ struct MainView: View {
|
|||||||
.allowsHitTesting(menuOffset > 0)
|
.allowsHitTesting(menuOffset > 0)
|
||||||
|
|
||||||
// Боковое меню
|
// Боковое меню
|
||||||
if !isMessengerModeEnabled {
|
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
|
||||||
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
|
.frame(width: menuWidth)
|
||||||
.frame(width: menuWidth)
|
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
||||||
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
.ignoresSafeArea(edges: .vertical)
|
||||||
.ignoresSafeArea(edges: .vertical)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deepLinkNavigationLink
|
|
||||||
}
|
}
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onChanged { gesture in
|
.onChanged { gesture in
|
||||||
if !isMessengerModeEnabled {
|
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
|
||||||
|
|
||||||
let translation = gesture.translation.width
|
let translation = gesture.translation.width
|
||||||
|
|
||||||
// Определяем базовое смещение в зависимости от того, открыто меню или нет
|
// Определяем базовое смещение в зависимости от того, открыто меню или нет
|
||||||
let baseOffset = isSideMenuPresented ? menuWidth : 0
|
let baseOffset = isSideMenuPresented ? menuWidth : 0
|
||||||
|
|
||||||
// Новое смещение — это база плюс текущий свайп
|
// Новое смещение — это база плюс текущий свайп
|
||||||
let newOffset = baseOffset + translation
|
let newOffset = baseOffset + translation
|
||||||
|
|
||||||
// Жестко ограничиваем итоговое смещение между 0 и шириной меню
|
// Жестко ограничиваем итоговое смещение между 0 и шириной меню
|
||||||
self.menuOffset = max(0, min(menuWidth, newOffset))
|
self.menuOffset = max(0, min(menuWidth, newOffset))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onEnded { gesture in
|
.onEnded { gesture in
|
||||||
if !isMessengerModeEnabled {
|
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
|
||||||
|
|
||||||
let threshold = menuWidth * 0.4
|
let threshold = menuWidth * 0.4
|
||||||
|
|
||||||
withAnimation(.easeInOut) {
|
withAnimation(.easeInOut) {
|
||||||
if self.menuOffset > threshold {
|
if self.menuOffset > threshold {
|
||||||
isSideMenuPresented = true
|
isSideMenuPresented = true
|
||||||
} else {
|
} else {
|
||||||
isSideMenuPresented = false
|
isSideMenuPresented = false
|
||||||
}
|
|
||||||
// Устанавливаем финальное смещение после анимации
|
|
||||||
self.menuOffset = isSideMenuPresented ? menuWidth : 0
|
|
||||||
}
|
}
|
||||||
|
// Устанавливаем финальное смещение после анимации
|
||||||
|
self.menuOffset = isSideMenuPresented ? menuWidth : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -172,100 +137,11 @@ struct MainView: View {
|
|||||||
menuOffset = presented ? menuWidth : 0
|
menuOffset = presented ? menuWidth : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
enforceTabSelectionForMessengerMode()
|
|
||||||
handleAfterRegisterOnboardingIfNeeded()
|
|
||||||
}
|
|
||||||
.onChange(of: isMessengerModeEnabled) { _ in
|
|
||||||
enforceTabSelectionForMessengerMode()
|
|
||||||
handleAfterRegisterOnboardingIfNeeded()
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.onboardingDestination) { _ in
|
|
||||||
handleAfterRegisterOnboardingIfNeeded()
|
|
||||||
}
|
|
||||||
.onChange(of: messageCenter.pendingNavigation?.id) { _ in
|
|
||||||
guard !AppConfig.PRESENT_CHAT_AS_SHEET,
|
|
||||||
let target = messageCenter.pendingNavigation else { return }
|
|
||||||
withAnimation(.easeInOut) {
|
|
||||||
isSideMenuPresented = false
|
|
||||||
menuOffset = 0
|
|
||||||
}
|
|
||||||
if !chatSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
chatSearchText = ""
|
|
||||||
}
|
|
||||||
if chatSearchRevealProgress > 0 {
|
|
||||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
|
||||||
chatSearchRevealProgress = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deepLinkChatItem = target.chat
|
|
||||||
isDeepLinkChatActive = true
|
|
||||||
NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
messageCenter.pendingNavigation = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: selectedTab) { newValue in
|
.onChange(of: selectedTab) { newValue in
|
||||||
if newValue != 3 {
|
if newValue != 3 {
|
||||||
isSettingsPresented = false
|
isSettingsPresented = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $isAfterRegisterPresented) {
|
|
||||||
AfterRegisterView(isPresented: $isAfterRegisterPresented)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension MainView {
|
|
||||||
func enforceTabSelectionForMessengerMode() {
|
|
||||||
if isMessengerModeEnabled {
|
|
||||||
if selectedTab < 2 {
|
|
||||||
selectedTab = 2
|
|
||||||
}
|
|
||||||
} else if selectedTab > 3 {
|
|
||||||
selectedTab = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func handleAfterRegisterOnboardingIfNeeded() {
|
|
||||||
guard viewModel.onboardingDestination == .afterRegister else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isAfterRegisterPresented = true
|
|
||||||
viewModel.onboardingDestination = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var deepLinkNavigationLink: some View {
|
|
||||||
NavigationLink(
|
|
||||||
destination: deepLinkChatDestination,
|
|
||||||
isActive: Binding(
|
|
||||||
get: { isDeepLinkChatActive && deepLinkChatItem != nil },
|
|
||||||
set: { newValue in
|
|
||||||
if !newValue {
|
|
||||||
isDeepLinkChatActive = false
|
|
||||||
deepLinkChatItem = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
.hidden()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var deepLinkChatDestination: some View {
|
|
||||||
if let chatItem = deepLinkChatItem {
|
|
||||||
PrivateChatView(
|
|
||||||
chat: chatItem,
|
|
||||||
currentUserId: messageCenter.currentUserId
|
|
||||||
)
|
|
||||||
.id(chatItem.chatId)
|
|
||||||
} else {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,7 +149,6 @@ struct MainView_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let mockViewModel = LoginViewModel()
|
let mockViewModel = LoginViewModel()
|
||||||
MainView(viewModel: mockViewModel)
|
MainView(viewModel: mockViewModel)
|
||||||
.environmentObject(IncomingMessageCenter())
|
|
||||||
.environmentObject(ThemeManager())
|
.environmentObject(ThemeManager())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct QrView: View {
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
|
|
||||||
}
|
|
||||||
.navigationTitle("Qr")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,416 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct BlockedUsersView: View {
|
|
||||||
@State private var blockedUsers: [BlockedUser] = []
|
|
||||||
@State private var isLoading = false
|
|
||||||
@State private var hasMore = true
|
|
||||||
@State private var offset = 0
|
|
||||||
@State private var loadError: String?
|
|
||||||
@State private var pendingUnblock: BlockedUser?
|
|
||||||
@State private var showUnblockConfirmation = false
|
|
||||||
@State private var removingUserIds: Set<UUID> = []
|
|
||||||
@State private var activeAlert: ActiveAlert?
|
|
||||||
@State private var errorMessageDown: String?
|
|
||||||
@State private var isAddUserSheetPresented = false
|
|
||||||
@State private var newBlockedUserLogin = ""
|
|
||||||
@State private var addBlockedUserError: String?
|
|
||||||
@State private var isProcessingAddBlockedUser = false
|
|
||||||
@FocusState private var isAddBlockedUserFieldFocused: Bool
|
|
||||||
|
|
||||||
private let blockedUsersService = BlockedUsersService()
|
|
||||||
private let limit = 20
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
if isLoading && blockedUsers.isEmpty {
|
|
||||||
initialLoadingState
|
|
||||||
} else if let loadError, blockedUsers.isEmpty {
|
|
||||||
errorState(loadError)
|
|
||||||
} else if blockedUsers.isEmpty {
|
|
||||||
emptyState
|
|
||||||
} else {
|
|
||||||
usersSection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Чёрный список", comment: ""))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button {
|
|
||||||
isAddUserSheetPresented = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await loadBlockedUsers()
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $isAddUserSheetPresented, onDismiss: resetAddBlockedUserForm) {
|
|
||||||
addBlockedUserSheet
|
|
||||||
}
|
|
||||||
.alert(item: $activeAlert) { alert in
|
|
||||||
switch alert {
|
|
||||||
case .error(_, let message):
|
|
||||||
return Alert(
|
|
||||||
title: Text(NSLocalizedString("Ошибка", comment: "Common error title")),
|
|
||||||
message: Text(message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
NSLocalizedString("Удалить из заблокированных?", comment: "Unblock confirmation title"),
|
|
||||||
isPresented: $showUnblockConfirmation,
|
|
||||||
presenting: pendingUnblock
|
|
||||||
) { user in
|
|
||||||
Button(NSLocalizedString("Разблокировать", comment: "Unblock confirmation action"), role: .destructive) {
|
|
||||||
pendingUnblock = nil
|
|
||||||
showUnblockConfirmation = false
|
|
||||||
Task {
|
|
||||||
await unblock(user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
|
|
||||||
pendingUnblock = nil
|
|
||||||
showUnblockConfirmation = false
|
|
||||||
}
|
|
||||||
} message: { user in
|
|
||||||
Text(String(format: NSLocalizedString("Пользователь \"%1$@\" будет удалён из списка заблокированных.", comment: "Unblock confirmation message"), user.displayName))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var addBlockedUserSheet: some View {
|
|
||||||
NavigationView {
|
|
||||||
Form {
|
|
||||||
Section(
|
|
||||||
header: Text(NSLocalizedString("Логин пользователя", comment: "Blocked users add login header")),
|
|
||||||
footer: Text(NSLocalizedString("Введите юзернейм человека, которого нужно заблокировать. Символ @ указывать не нужно.", comment: "Blocked users add login footer"))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
) {
|
|
||||||
TextField(NSLocalizedString("Например, username", comment: "Blocked users add login placeholder"), text: $newBlockedUserLogin)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.keyboardType(.asciiCapable)
|
|
||||||
.focused($isAddBlockedUserFieldFocused)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let addBlockedUserError {
|
|
||||||
Section {
|
|
||||||
Text(addBlockedUserError)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Заблокировать", comment: "Blocked users add title"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Common cancel")) {
|
|
||||||
isAddUserSheetPresented = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
if isProcessingAddBlockedUser {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Button(NSLocalizedString("Заблокировать", comment: "Blocked users add confirm")) {
|
|
||||||
submitAddBlockedUser()
|
|
||||||
}
|
|
||||||
.disabled(!canSubmitNewBlockedUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
isAddBlockedUserFieldFocused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var usersSection: some View {
|
|
||||||
Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) {
|
|
||||||
ForEach(Array(blockedUsers.enumerated()), id: \.element.id) { index, user in
|
|
||||||
userRow(user, index: index)
|
|
||||||
}
|
|
||||||
if isLoading && !blockedUsers.isEmpty {
|
|
||||||
Text("Идет загрузка...")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
} else if let errorMessage = errorMessageDown {
|
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var trimmedNewBlockedUserLogin: String {
|
|
||||||
newBlockedUserLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var canSubmitNewBlockedUser: Bool {
|
|
||||||
!trimmedNewBlockedUserLogin.isEmpty && !isProcessingAddBlockedUser
|
|
||||||
}
|
|
||||||
|
|
||||||
private func userRow(_ user: BlockedUser, index: Int) -> some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Circle()
|
|
||||||
.fill(user.isDeleted ? Color(.systemGray5) : Color.accentColor.opacity(0.15))
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.overlay(
|
|
||||||
Group {
|
|
||||||
if user.isDeleted {
|
|
||||||
Image(systemName: "person.slash")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(Color(.systemGray2))
|
|
||||||
} else {
|
|
||||||
Text(user.initials)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
Text(user.displayName)
|
|
||||||
.font(.body)
|
|
||||||
.strikethrough(user.isDeleted, color: .secondary)
|
|
||||||
} else {
|
|
||||||
Text(user.displayName)
|
|
||||||
.font(.body)
|
|
||||||
.strikethrough(user.isDeleted)
|
|
||||||
}
|
|
||||||
if let handle = user.handle {
|
|
||||||
Text(handle)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 0)
|
|
||||||
.swipeActions(edge: .trailing) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
pendingUnblock = user
|
|
||||||
showUnblockConfirmation = true
|
|
||||||
} label: {
|
|
||||||
Label(NSLocalizedString("Разблокировать", comment: ""), systemImage: "person.crop.circle.badge.xmark")
|
|
||||||
}
|
|
||||||
// .disabled(removingUserIds.contains(user.id) || user.isDeleted)
|
|
||||||
.disabled(removingUserIds.contains(user.id))
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
if index >= blockedUsers.count - 5 {
|
|
||||||
Task {
|
|
||||||
await loadBlockedUsers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyState: some View {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "hand.raised")
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(NSLocalizedString("У вас нет заблокированных пользователей", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.padding(.vertical, 32)
|
|
||||||
.listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var initialLoadingState: some View {
|
|
||||||
Section {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func errorState(_ message: String) -> some View {
|
|
||||||
Section {
|
|
||||||
Text(message)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func submitAddBlockedUser() {
|
|
||||||
guard canSubmitNewBlockedUser else { return }
|
|
||||||
|
|
||||||
let login = trimmedNewBlockedUserLogin
|
|
||||||
isProcessingAddBlockedUser = true
|
|
||||||
addBlockedUserError = nil
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await performAddBlockedUser(login: login)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetAddBlockedUserForm() {
|
|
||||||
newBlockedUserLogin = ""
|
|
||||||
addBlockedUserError = nil
|
|
||||||
isProcessingAddBlockedUser = false
|
|
||||||
isAddBlockedUserFieldFocused = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performAddBlockedUser(login: String) async {
|
|
||||||
do {
|
|
||||||
let payload = try await blockedUsersService.add(login: login)
|
|
||||||
let newUser = BlockedUser(payload: payload)
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
let existed = blockedUsers.contains(where: { $0.id == newUser.id })
|
|
||||||
blockedUsers.removeAll { $0.id == newUser.id }
|
|
||||||
blockedUsers.insert(newUser, at: 0)
|
|
||||||
if !existed {
|
|
||||||
offset += 1
|
|
||||||
}
|
|
||||||
isAddUserSheetPresented = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[BlockedUsersView] add blocked user failed: \(error)")
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
|
||||||
addBlockedUserError = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
isProcessingAddBlockedUser = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadBlockedUsers() async {
|
|
||||||
errorMessageDown = nil
|
|
||||||
guard !isLoading, hasMore else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
defer { isLoading = false }
|
|
||||||
|
|
||||||
if offset == 0 {
|
|
||||||
loadError = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let payload = try await blockedUsersService.fetchBlockedUsers(limit: limit, offset: offset)
|
|
||||||
blockedUsers.append(contentsOf: payload.items.map(BlockedUser.init))
|
|
||||||
offset += payload.items.count
|
|
||||||
hasMore = payload.hasMore
|
|
||||||
} catch {
|
|
||||||
let message = error.localizedDescription
|
|
||||||
if offset == 0 {
|
|
||||||
loadError = message
|
|
||||||
}
|
|
||||||
// activeAlert = .error(message: message)
|
|
||||||
errorMessageDown = message
|
|
||||||
if AppConfig.DEBUG { print("[BlockedUsersView] load blocked users failed: \(error)") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func unblock(_ user: BlockedUser) async {
|
|
||||||
guard !removingUserIds.contains(user.id) else { return }
|
|
||||||
|
|
||||||
removingUserIds.insert(user.id)
|
|
||||||
defer { removingUserIds.remove(user.id) }
|
|
||||||
|
|
||||||
do {
|
|
||||||
_ = try await blockedUsersService.remove(userId: user.id)
|
|
||||||
blockedUsers.removeAll { $0.id == user.id }
|
|
||||||
} catch {
|
|
||||||
activeAlert = .error(message: error.localizedDescription)
|
|
||||||
if AppConfig.DEBUG { print("[BlockedUsersView] unblock failed: \(error)") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct BlockedUser: Identifiable, Equatable {
|
|
||||||
let id: UUID
|
|
||||||
let login: String?
|
|
||||||
let fullName: String?
|
|
||||||
let customName: String?
|
|
||||||
let createdAt: Date
|
|
||||||
|
|
||||||
private(set) var displayName: String
|
|
||||||
private(set) var handle: String?
|
|
||||||
|
|
||||||
var isDeleted: Bool {
|
|
||||||
login?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
|
||||||
}
|
|
||||||
|
|
||||||
var initials: String {
|
|
||||||
if isDeleted { return "" }
|
|
||||||
|
|
||||||
let nameSource: String?
|
|
||||||
if let customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
nameSource = customName
|
|
||||||
} else if let fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
nameSource = fullName
|
|
||||||
} else {
|
|
||||||
nameSource = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let name = nameSource {
|
|
||||||
let components = name.split(separator: " ")
|
|
||||||
let nameInitials = components.prefix(2).compactMap { $0.first }
|
|
||||||
if !nameInitials.isEmpty {
|
|
||||||
return nameInitials.map { String($0) }.joined().uppercased()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(login!.prefix(1)).uppercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(payload: BlockedUserInfo) {
|
|
||||||
self.id = payload.userId
|
|
||||||
self.login = payload.login
|
|
||||||
self.fullName = payload.fullName
|
|
||||||
self.customName = payload.customName
|
|
||||||
self.createdAt = payload.createdAt
|
|
||||||
|
|
||||||
let isUserDeleted = payload.login?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
|
||||||
|
|
||||||
if isUserDeleted {
|
|
||||||
self.displayName = NSLocalizedString("Неизвестный пользователь", comment: "Deleted user display name")
|
|
||||||
self.handle = nil
|
|
||||||
} else {
|
|
||||||
if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
self.displayName = customName
|
|
||||||
} else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
self.displayName = fullName
|
|
||||||
} else {
|
|
||||||
self.displayName = payload.login!
|
|
||||||
}
|
|
||||||
|
|
||||||
self.handle = "@\(payload.login!)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ActiveAlert: Identifiable {
|
|
||||||
case error(id: UUID = UUID(), message: String)
|
|
||||||
|
|
||||||
var id: String {
|
|
||||||
switch self {
|
|
||||||
case .error(let id, _):
|
|
||||||
return id.uuidString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
//
|
|
||||||
// DataSettingsView.swift
|
|
||||||
// yobble
|
|
||||||
//
|
|
||||||
// Created by cheykrym on 10.12.2025.
|
|
||||||
//
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
|
|
||||||
struct DataSettingsView: View {
|
|
||||||
let currentUserId: String
|
|
||||||
private let cacheService = AvatarCacheService.shared
|
|
||||||
|
|
||||||
@State private var cachedUsers: [CachedUserInfo] = []
|
|
||||||
@State private var totalCacheSize: Int64 = 0
|
|
||||||
@State private var showClearAllConfirmation = false
|
|
||||||
@State private var showClearOthersConfirmation = false
|
|
||||||
@State private var showClearCurrentConfirmation = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section(header: Text("Общая информация")) {
|
|
||||||
HStack {
|
|
||||||
Text("Общий размер")
|
|
||||||
Spacer()
|
|
||||||
Text(format(bytes: totalCacheSize))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text("Массовая отчистка")) {
|
|
||||||
Button("Очистить кэш текущего пользователя", role: .destructive) {
|
|
||||||
showClearCurrentConfirmation = true
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
"Вы уверены, что хотите очистить кэш для текущего пользователя?",
|
|
||||||
isPresented: $showClearCurrentConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button("Очистить", role: .destructive) {
|
|
||||||
clearCache(for: currentUserId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Очистить кэш (кроме текущего)", role: .destructive) {
|
|
||||||
showClearOthersConfirmation = true
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
"Вы уверены, что хотите очистить кэш для всех, кроме текущего пользователя?",
|
|
||||||
isPresented: $showClearOthersConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button("Очистить", role: .destructive, action: clearOtherUsersCache)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Очистить весь кэш", role: .destructive) {
|
|
||||||
showClearAllConfirmation = true
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
"Вы уверены, что хотите очистить весь кэш аватаров? Это действие необратимо.",
|
|
||||||
isPresented: $showClearAllConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button("Очистить всё", role: .destructive, action: clearAllCache)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text("Кэш по пользователям")) {
|
|
||||||
if cachedUsers.isEmpty {
|
|
||||||
Text("Кэш пуст")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
} else {
|
|
||||||
ForEach(cachedUsers) { user in
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(user.id)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.middle)
|
|
||||||
if user.id == currentUserId {
|
|
||||||
Text("Текущий")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Text(format(bytes: user.size))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Button("Очистить") {
|
|
||||||
clearCache(for: user.id)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Данные и кэш")
|
|
||||||
.onAppear(perform: refreshCacheStats)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshCacheStats() {
|
|
||||||
let userIds = cacheService.getAllCachedUserIds()
|
|
||||||
self.cachedUsers = userIds.map { id in
|
|
||||||
let size = cacheService.sizeOfCache(forUserId: id)
|
|
||||||
return CachedUserInfo(id: id, size: size)
|
|
||||||
}.sorted { $0.size > $1.size }
|
|
||||||
|
|
||||||
self.totalCacheSize = cacheService.sizeOfAllCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearCache(for userId: String) {
|
|
||||||
cacheService.clearCache(forUserId: userId)
|
|
||||||
refreshCacheStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearAllCache() {
|
|
||||||
cacheService.clearAllCache()
|
|
||||||
refreshCacheStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearOtherUsersCache() {
|
|
||||||
let otherUsers = cachedUsers.filter { $0.id != currentUserId }
|
|
||||||
for user in otherUsers {
|
|
||||||
cacheService.clearCache(forUserId: user.id)
|
|
||||||
}
|
|
||||||
refreshCacheStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func format(bytes: Int64) -> String {
|
|
||||||
let formatter = ByteCountFormatter()
|
|
||||||
formatter.allowedUnits = [.useAll]
|
|
||||||
formatter.countStyle = .file
|
|
||||||
return formatter.string(fromByteCount: bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CachedUserInfo: Identifiable {
|
|
||||||
let id: String
|
|
||||||
let size: Int64
|
|
||||||
}
|
|
||||||
@ -70,27 +70,35 @@ struct EditPrivacyView: View {
|
|||||||
Toggle(NSLocalizedString("Показывать био не-контактам", comment: ""), isOn: $profilePermissions.showBioToNonContacts)
|
Toggle(NSLocalizedString("Показывать био не-контактам", comment: ""), isOn: $profilePermissions.showBioToNonContacts)
|
||||||
Toggle(NSLocalizedString("Показывать сторисы не-контактам", comment: ""), isOn: $profilePermissions.showStoriesToNonContacts)
|
Toggle(NSLocalizedString("Показывать сторисы не-контактам", comment: ""), isOn: $profilePermissions.showStoriesToNonContacts)
|
||||||
|
|
||||||
privacyScopePicker(
|
Picker(NSLocalizedString("Видимость статуса 'был в сети'", comment: ""), selection: $profilePermissions.lastSeenVisibility) {
|
||||||
title: NSLocalizedString("Видимость статуса 'был в сети'", comment: ""),
|
ForEach(privacyScopeOptions) { scope in
|
||||||
selection: $profilePermissions.lastSeenVisibility
|
Text(scope.title).tag(scope.rawValue)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Приглашения и звонки", comment: ""))) {
|
Section(header: Text(NSLocalizedString("Приглашения и звонки", comment: ""))) {
|
||||||
privacyScopePicker(
|
Picker(NSLocalizedString("Кто может приглашать в паблики", comment: ""), selection: $profilePermissions.publicInvitePermission) {
|
||||||
title: NSLocalizedString("Кто может приглашать в паблики", comment: ""),
|
ForEach(privacyScopeOptions) { scope in
|
||||||
selection: $profilePermissions.publicInvitePermission
|
Text(scope.title).tag(scope.rawValue)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
privacyScopePicker(
|
Picker(NSLocalizedString("Кто может приглашать в беседы", comment: ""), selection: $profilePermissions.groupInvitePermission) {
|
||||||
title: NSLocalizedString("Кто может приглашать в беседы", comment: ""),
|
ForEach(privacyScopeOptions) { scope in
|
||||||
selection: $profilePermissions.groupInvitePermission
|
Text(scope.title).tag(scope.rawValue)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
privacyScopePicker(
|
Picker(NSLocalizedString("Кто может звонить", comment: ""), selection: $profilePermissions.callPermission) {
|
||||||
title: NSLocalizedString("Кто может звонить", comment: ""),
|
ForEach(privacyScopeOptions) { scope in
|
||||||
selection: $profilePermissions.callPermission
|
Text(scope.title).tag(scope.rawValue)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Чаты и хранение", comment: ""))) {
|
Section(header: Text(NSLocalizedString("Чаты и хранение", comment: ""))) {
|
||||||
@ -184,24 +192,6 @@ struct EditPrivacyView: View {
|
|||||||
return "\(secondsString) (≈ \(formattedHours) ч.)"
|
return "\(secondsString) (≈ \(formattedHours) ч.)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func privacyScopePicker(title: String, selection: Binding<Int>) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text(title)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Picker("", selection: selection) {
|
|
||||||
ForEach(privacyScopeOptions) { scope in
|
|
||||||
Text(scope.title).tag(scope.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum PrivacyScope: Int, CaseIterable, Identifiable {
|
private enum PrivacyScope: Int, CaseIterable, Identifiable {
|
||||||
@ -1,729 +1,24 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct EditProfileView: View {
|
struct EditProfileView: View {
|
||||||
// State for form fields
|
|
||||||
@State private var displayName = ""
|
@State private var displayName = ""
|
||||||
@State private var description = ""
|
@State private var description = ""
|
||||||
@State private var originalDisplayName = ""
|
|
||||||
@State private var originalDescription = ""
|
|
||||||
|
|
||||||
// State for profile data and avatar
|
|
||||||
@State private var profile: ProfileDataPayload?
|
|
||||||
@State private var avatarImage: UIImage?
|
|
||||||
@State private var showImagePicker = false
|
|
||||||
|
|
||||||
// State for loading and errors
|
|
||||||
@State private var isLoading = false
|
|
||||||
@State private var isSaving = false
|
|
||||||
@State private var isUploadingAvatar = false
|
|
||||||
@State private var isPreparingDownload = false
|
|
||||||
@State private var alertMessage: String?
|
|
||||||
@State private var showAlert = false
|
|
||||||
@State private var avatarViewerState: AvatarViewerState?
|
|
||||||
@State private var shareItems: [Any] = []
|
|
||||||
@State private var showShareSheet = false
|
|
||||||
|
|
||||||
private let profileService = ProfileService()
|
|
||||||
private let descriptionLimit = 1024
|
|
||||||
private let nameLimit = 32
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
Form {
|
||||||
Form {
|
Section(header: Text("Публичная информация")) {
|
||||||
Section {
|
TextField("Отображаемое имя", text: $displayName)
|
||||||
HStack {
|
TextField("Описание", text: $description)
|
||||||
Spacer()
|
|
||||||
VStack {
|
|
||||||
if let image = avatarImage {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
.clipShape(Circle())
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
presentAvatarViewer()
|
|
||||||
}
|
|
||||||
} else if let profile = profile,
|
|
||||||
let fileId = profile.avatars?.current?.fileId,
|
|
||||||
let url = avatarUrl(for: profile, fileId: fileId) {
|
|
||||||
CachedAvatarView(url: url, fileId: fileId, userId: profile.userId.uuidString) {
|
|
||||||
avatarPlaceholder
|
|
||||||
}
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
.clipShape(Circle())
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
presentAvatarViewer()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
avatarPlaceholder
|
|
||||||
.onTapGesture {
|
|
||||||
presentAvatarViewer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Изменить фото") {
|
|
||||||
showImagePicker = true
|
|
||||||
}
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color(UIColor.systemGroupedBackground))
|
|
||||||
|
|
||||||
Section(header: Text("Публичная информация")) {
|
|
||||||
TextField("Отображаемое имя", text: $displayName)
|
|
||||||
.onChange(of: displayName) { newValue in
|
|
||||||
if newValue.count > nameLimit {
|
|
||||||
displayName = String(newValue.prefix(nameLimit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
|
||||||
Text("Описание")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
TextEditor(text: $description)
|
|
||||||
.frame(height: 150)
|
|
||||||
.onChange(of: description) { newValue in
|
|
||||||
if newValue.count > descriptionLimit {
|
|
||||||
description = String(newValue.prefix(descriptionLimit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Text("\(description.count) / \(descriptionLimit)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(description.count > descriptionLimit ? .red : .secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
Task {
|
|
||||||
await applyProfileChanges()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
if isSaving {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
} else {
|
|
||||||
Text("Применить")
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(!hasProfileChanges || isBusy)
|
|
||||||
}
|
|
||||||
.navigationTitle("Профиль")
|
|
||||||
.onAppear(perform: loadProfile)
|
|
||||||
.sheet(isPresented: $showImagePicker) {
|
|
||||||
ImagePicker(image: $avatarImage, allowsEditing: true)
|
|
||||||
}
|
|
||||||
.onChange(of: avatarImage) { newValue in
|
|
||||||
guard let image = newValue else { return }
|
|
||||||
Task {
|
|
||||||
await uploadAvatarImage(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert("Ошибка", isPresented: $showAlert, presenting: alertMessage) { _ in
|
|
||||||
Button("OK") {}
|
|
||||||
} message: { message in
|
|
||||||
Text(message)
|
|
||||||
}
|
|
||||||
.fullScreenCover(item: $avatarViewerState) { state in
|
|
||||||
AvatarViewerView(
|
|
||||||
state: state,
|
|
||||||
onClose: { avatarViewerState = nil },
|
|
||||||
onDownload: { handleAvatarDownload(for: state) },
|
|
||||||
onDelete: handleAvatarDeletion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showShareSheet) {
|
|
||||||
ActivityView(activityItems: shareItems)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if isBusy {
|
Button(action: {
|
||||||
Color.black.opacity(0.4).ignoresSafeArea()
|
// Действие для сохранения профиля
|
||||||
ProgressView(busyMessage)
|
print("DisplayName: \(displayName)")
|
||||||
.padding()
|
print("Description: \(description)")
|
||||||
.background(Color.secondary.colorInvert())
|
}) {
|
||||||
.cornerRadius(10)
|
Text("Применить")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.navigationTitle("Редактировать профиль")
|
||||||
|
|
||||||
private var avatarPlaceholder: some View {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.secondary.opacity(0.2))
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
.overlay(
|
|
||||||
Text(profileInitials)
|
|
||||||
.font(.system(size: 48, weight: .semibold))
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var profileInitials: String {
|
|
||||||
if let initials = initials(from: displayName) {
|
|
||||||
return initials
|
|
||||||
}
|
|
||||||
if let profile = profile,
|
|
||||||
let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!name.isEmpty,
|
|
||||||
let initials = initials(from: name) {
|
|
||||||
return initials
|
|
||||||
}
|
|
||||||
if let username = profile?.login.trimmingCharacters(in: .whitespacesAndNewlines), !username.isEmpty {
|
|
||||||
return String(username.prefix(1)).uppercased()
|
|
||||||
}
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func avatarUrl(for profile: ProfileDataPayload, fileId: String) -> URL? {
|
|
||||||
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(profile.userId)?file_id=\(fileId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadProfile() {
|
|
||||||
isLoading = true
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let profile = try await profileService.fetchMyProfile()
|
|
||||||
await MainActor.run {
|
|
||||||
self.updateForm(with: profile)
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
self.alertMessage = error.localizedDescription
|
|
||||||
self.showAlert = true
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hasProfileChanges: Bool {
|
|
||||||
displayName != originalDisplayName || description != originalDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isBusy: Bool {
|
|
||||||
isLoading || isSaving || isUploadingAvatar || isPreparingDownload
|
|
||||||
}
|
|
||||||
|
|
||||||
private var busyMessage: String {
|
|
||||||
if isUploadingAvatar {
|
|
||||||
return "Обновление аватара..."
|
|
||||||
}
|
|
||||||
if isPreparingDownload {
|
|
||||||
return "Подготовка изображения..."
|
|
||||||
}
|
|
||||||
if isSaving {
|
|
||||||
return "Сохранение..."
|
|
||||||
}
|
|
||||||
return "Загрузка..."
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func applyProfileChanges() async {
|
|
||||||
guard !isSaving else { return }
|
|
||||||
guard let currentProfile = profile else {
|
|
||||||
alertMessage = NSLocalizedString("Профиль пока не загружен. Попробуйте позже.", comment: "Profile not ready error")
|
|
||||||
showAlert = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
|
|
||||||
let request = ProfileUpdateRequestPayload(
|
|
||||||
fullName: displayName,
|
|
||||||
bio: description,
|
|
||||||
profilePermissions: ProfilePermissionsRequestPayload(payload: currentProfile.profilePermissions)
|
|
||||||
)
|
|
||||||
|
|
||||||
do {
|
|
||||||
_ = try await profileService.updateProfile(request)
|
|
||||||
let refreshedProfile = try await profileService.fetchMyProfile()
|
|
||||||
updateForm(with: refreshedProfile)
|
|
||||||
} catch {
|
|
||||||
let message: String
|
|
||||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
|
||||||
message = description
|
|
||||||
} else {
|
|
||||||
message = error.localizedDescription
|
|
||||||
}
|
|
||||||
alertMessage = message
|
|
||||||
showAlert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func uploadAvatarImage(_ image: UIImage) async {
|
|
||||||
guard !isUploadingAvatar else { return }
|
|
||||||
isUploadingAvatar = true
|
|
||||||
defer { isUploadingAvatar = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
_ = try await profileService.uploadAvatar(image: image)
|
|
||||||
let refreshedProfile = try await profileService.fetchMyProfile()
|
|
||||||
updateFormPreservingFields(profile: refreshedProfile)
|
|
||||||
avatarImage = nil
|
|
||||||
} catch {
|
|
||||||
let message: String
|
|
||||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
|
||||||
message = description
|
|
||||||
} else {
|
|
||||||
message = error.localizedDescription
|
|
||||||
}
|
|
||||||
alertMessage = message
|
|
||||||
showAlert = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func updateForm(with profile: ProfileDataPayload) {
|
|
||||||
self.profile = profile
|
|
||||||
applyProfileTexts(from: profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func updateFormPreservingFields(profile: ProfileDataPayload) {
|
|
||||||
self.profile = profile
|
|
||||||
if !hasProfileChanges {
|
|
||||||
applyProfileTexts(from: profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func applyProfileTexts(from profile: ProfileDataPayload) {
|
|
||||||
let loadedName = profile.fullName ?? ""
|
|
||||||
let loadedBio = profile.bio ?? ""
|
|
||||||
self.displayName = loadedName
|
|
||||||
self.description = loadedBio
|
|
||||||
self.originalDisplayName = loadedName
|
|
||||||
self.originalDescription = loadedBio
|
|
||||||
}
|
|
||||||
|
|
||||||
private func presentAvatarViewer() {
|
|
||||||
if let image = avatarImage {
|
|
||||||
avatarViewerState = AvatarViewerState(
|
|
||||||
source: .local(image),
|
|
||||||
intrinsicSize: image.size
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let profile,
|
|
||||||
let fileId = profile.avatars?.current?.fileId,
|
|
||||||
let url = avatarUrl(for: profile, fileId: fileId) else { return }
|
|
||||||
let intrinsicSize: CGSize?
|
|
||||||
if let width = profile.avatars?.current?.width,
|
|
||||||
let height = profile.avatars?.current?.height,
|
|
||||||
width > 0,
|
|
||||||
height > 0 {
|
|
||||||
intrinsicSize = CGSize(width: CGFloat(width), height: CGFloat(height))
|
|
||||||
} else {
|
|
||||||
intrinsicSize = nil
|
|
||||||
}
|
|
||||||
avatarViewerState = AvatarViewerState(
|
|
||||||
source: .remote(url: url, fileId: fileId, userId: profile.userId.uuidString),
|
|
||||||
intrinsicSize: intrinsicSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleAvatarDownload(for state: AvatarViewerState) {
|
|
||||||
guard !isPreparingDownload else { return }
|
|
||||||
isPreparingDownload = true
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let image = try await resolveImage(for: state)
|
|
||||||
await MainActor.run {
|
|
||||||
shareItems = [image]
|
|
||||||
showShareSheet = true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
alertMessage = error.localizedDescription
|
|
||||||
showAlert = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
isPreparingDownload = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleAvatarDeletion() {
|
|
||||||
alertMessage = NSLocalizedString("Удаление аватара пока недоступно.", comment: "Avatar delete placeholder")
|
|
||||||
showAlert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveImage(for state: AvatarViewerState) async throws -> UIImage {
|
|
||||||
switch state.source {
|
|
||||||
case .local(let image):
|
|
||||||
return image
|
|
||||||
case .remote(let url, let fileId, let userId):
|
|
||||||
if let cached = AvatarCacheService.shared.getImage(forKey: fileId, userId: userId) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
|
||||||
guard let image = UIImage(data: data) else {
|
|
||||||
throw AvatarViewerError.imageDecodingFailed
|
|
||||||
}
|
|
||||||
AvatarCacheService.shared.saveImage(image, forKey: fileId, userId: userId)
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initials(from text: String) -> String? {
|
|
||||||
let components = text
|
|
||||||
.split { $0.isWhitespace }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
let letters = components.prefix(2).compactMap { $0.first }
|
|
||||||
guard !letters.isEmpty else { return nil }
|
|
||||||
return letters.map { String($0).uppercased() }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ImagePicker: UIViewControllerRepresentable {
|
|
||||||
@Binding var image: UIImage?
|
|
||||||
var allowsEditing: Bool = false
|
|
||||||
@Environment(\.presentationMode) private var presentationMode
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
|
||||||
let picker = UIImagePickerController()
|
|
||||||
picker.delegate = context.coordinator
|
|
||||||
picker.allowsEditing = allowsEditing
|
|
||||||
return picker
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
|
||||||
let parent: ImagePicker
|
|
||||||
|
|
||||||
init(_ parent: ImagePicker) {
|
|
||||||
self.parent = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
|
||||||
if let editedImage = info[.editedImage] as? UIImage {
|
|
||||||
parent.image = editedImage
|
|
||||||
} else if let uiImage = info[.originalImage] as? UIImage {
|
|
||||||
parent.image = uiImage
|
|
||||||
}
|
|
||||||
parent.presentationMode.wrappedValue.dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AvatarViewerState: Identifiable {
|
|
||||||
enum Source {
|
|
||||||
case local(UIImage)
|
|
||||||
case remote(url: URL, fileId: String, userId: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = UUID()
|
|
||||||
let source: Source
|
|
||||||
let intrinsicSize: CGSize?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AvatarViewerError: LocalizedError {
|
|
||||||
case imageDecodingFailed
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .imageDecodingFailed:
|
|
||||||
return NSLocalizedString("Не удалось подготовить изображение.", comment: "Avatar decoding error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AvatarViewerView: View {
|
|
||||||
let state: AvatarViewerState
|
|
||||||
let onClose: () -> Void
|
|
||||||
let onDownload: () -> Void
|
|
||||||
let onDelete: () -> Void
|
|
||||||
|
|
||||||
@State private var scale: CGFloat = 1.0
|
|
||||||
@State private var baseScale: CGFloat = 1.0
|
|
||||||
@State private var panOffset: CGSize = .zero
|
|
||||||
@State private var storedPanOffset: CGSize = .zero
|
|
||||||
@State private var dismissOffset: CGSize = .zero
|
|
||||||
@State private var dragMode: DragMode?
|
|
||||||
@State private var containerSize: CGSize = .zero
|
|
||||||
@State private var loadedImageSize: CGSize?
|
|
||||||
|
|
||||||
private enum DragMode {
|
|
||||||
case vertical
|
|
||||||
case horizontal
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentOffset: CGSize {
|
|
||||||
scale > 1.05 ? panOffset : dismissOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
private var dragProgress: CGFloat {
|
|
||||||
guard scale <= 1.05 else { return 0 }
|
|
||||||
let progress = min(1, abs(dismissOffset.height) / 220)
|
|
||||||
return progress
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backgroundOpacity: Double {
|
|
||||||
Double(1 - dragProgress * 0.6)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var overlayOpacity: Double {
|
|
||||||
Double(1 - dragProgress * 0.8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var effectiveImageSize: CGSize? {
|
|
||||||
loadedImageSize ?? state.intrinsicSize
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Color.black.opacity(backgroundOpacity).ignoresSafeArea()
|
|
||||||
|
|
||||||
zoomableContent
|
|
||||||
|
|
||||||
topOverlay
|
|
||||||
.opacity(overlayOpacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var topOverlay: some View {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
HStack {
|
|
||||||
Button(action: onClose) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.imageScale(.large)
|
|
||||||
}
|
|
||||||
.tint(.white)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text("1 из 1")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Menu {
|
|
||||||
Button(action: onDownload) {
|
|
||||||
Label(NSLocalizedString("Скачать", comment: "Avatar download"), systemImage: "square.and.arrow.down")
|
|
||||||
}
|
|
||||||
Button(role: .destructive, action: onDelete) {
|
|
||||||
Label(NSLocalizedString("Удалить фото", comment: "Avatar delete"), systemImage: "trash")
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "ellipsis.circle")
|
|
||||||
.imageScale(.large)
|
|
||||||
}
|
|
||||||
.tint(.white)
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 24)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var zoomableContent: some View {
|
|
||||||
GeometryReader { proxy in
|
|
||||||
let size = proxy.size
|
|
||||||
Color.clear
|
|
||||||
.onAppear { containerSize = size }
|
|
||||||
.onChange(of: size) { newValue in
|
|
||||||
containerSize = newValue
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
content(for: size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func content(for size: CGSize) -> some View {
|
|
||||||
switch state.source {
|
|
||||||
case .local(let image):
|
|
||||||
zoomableImage(Image(uiImage: image))
|
|
||||||
.onAppear {
|
|
||||||
loadedImageSize = image.size
|
|
||||||
}
|
|
||||||
case .remote(let url, let fileId, let userId):
|
|
||||||
RemoteZoomableImage(url: url, fileId: fileId, userId: userId) { uiImage in
|
|
||||||
loadedImageSize = uiImage.size
|
|
||||||
}
|
|
||||||
.offset(currentOffset)
|
|
||||||
.scaleEffect(scale, anchor: .center)
|
|
||||||
.gesture(dragGesture)
|
|
||||||
.simultaneousGesture(magnificationGesture)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func zoomableImage(_ image: Image) -> some View {
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.offset(currentOffset)
|
|
||||||
.scaleEffect(scale, anchor: .center)
|
|
||||||
.gesture(dragGesture)
|
|
||||||
.simultaneousGesture(magnificationGesture)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var dragGesture: some Gesture {
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
if scale > 1.05 {
|
|
||||||
dismissOffset = .zero
|
|
||||||
let adjustedTranslation = CGSize(
|
|
||||||
width: value.translation.width / scale,
|
|
||||||
height: value.translation.height / scale
|
|
||||||
)
|
|
||||||
panOffset = CGSize(
|
|
||||||
width: storedPanOffset.width + adjustedTranslation.width,
|
|
||||||
height: storedPanOffset.height + adjustedTranslation.height
|
|
||||||
)
|
|
||||||
panOffset = clampedOffset(panOffset)
|
|
||||||
} else {
|
|
||||||
if dragMode == nil {
|
|
||||||
if abs(value.translation.height) > abs(value.translation.width) {
|
|
||||||
dragMode = .vertical
|
|
||||||
} else {
|
|
||||||
dragMode = .horizontal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch dragMode {
|
|
||||||
case .horizontal:
|
|
||||||
let limitedWidth = min(max(value.translation.width, -80), 80)
|
|
||||||
dismissOffset = CGSize(width: limitedWidth, height: 0)
|
|
||||||
case .vertical, .none:
|
|
||||||
dismissOffset = CGSize(width: 0, height: value.translation.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
if scale > 1.05 {
|
|
||||||
let adjustedTranslation = CGSize(
|
|
||||||
width: value.translation.width / scale,
|
|
||||||
height: value.translation.height / scale
|
|
||||||
)
|
|
||||||
var newOffset = CGSize(
|
|
||||||
width: storedPanOffset.width + adjustedTranslation.width,
|
|
||||||
height: storedPanOffset.height + adjustedTranslation.height
|
|
||||||
)
|
|
||||||
newOffset = clampedOffset(newOffset)
|
|
||||||
storedPanOffset = newOffset
|
|
||||||
} else {
|
|
||||||
if abs(value.translation.height) > 120 {
|
|
||||||
onClose()
|
|
||||||
} else {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
dismissOffset = .zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragMode = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var magnificationGesture: some Gesture {
|
|
||||||
MagnificationGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
let newScale = baseScale * value
|
|
||||||
scale = min(max(newScale, 1), 4)
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
baseScale = scale
|
|
||||||
if baseScale <= 1.02 {
|
|
||||||
baseScale = 1
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
scale = 1
|
|
||||||
storedPanOffset = .zero
|
|
||||||
panOffset = .zero
|
|
||||||
dismissOffset = .zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clampedOffset(_ offset: CGSize) -> CGSize {
|
|
||||||
guard scale > 1.01,
|
|
||||||
containerSize != .zero else { return offset }
|
|
||||||
|
|
||||||
let fittedSize = fittedContentSize(in: containerSize)
|
|
||||||
let scaledWidth = fittedSize.width * scale
|
|
||||||
let scaledHeight = fittedSize.height * scale
|
|
||||||
let maxX = max(0, (scaledWidth - containerSize.width) / 2)
|
|
||||||
let maxY = max(0, (scaledHeight - containerSize.height) / 2)
|
|
||||||
|
|
||||||
let clampedX = max(-maxX, min(offset.width, maxX))
|
|
||||||
let clampedY = max(-maxY, min(offset.height, maxY))
|
|
||||||
return CGSize(width: clampedX, height: clampedY)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fittedContentSize(in container: CGSize) -> CGSize {
|
|
||||||
guard let imageSize = effectiveImageSize,
|
|
||||||
imageSize.width > 0,
|
|
||||||
imageSize.height > 0 else {
|
|
||||||
return container
|
|
||||||
}
|
|
||||||
|
|
||||||
let widthRatio = container.width / imageSize.width
|
|
||||||
let heightRatio = container.height / imageSize.height
|
|
||||||
let ratio = min(widthRatio, heightRatio)
|
|
||||||
return CGSize(width: imageSize.width * ratio, height: imageSize.height * ratio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RemoteZoomableImage: View {
|
|
||||||
@StateObject private var loader: ImageLoader
|
|
||||||
let onImageLoaded: (UIImage) -> Void
|
|
||||||
|
|
||||||
init(url: URL, fileId: String, userId: String, onImageLoaded: @escaping (UIImage) -> Void) {
|
|
||||||
_loader = StateObject(wrappedValue: ImageLoader(url: url, fileId: fileId, userId: userId))
|
|
||||||
self.onImageLoaded = onImageLoaded
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if let image = loader.image {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.onAppear {
|
|
||||||
onImageLoaded(image)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.onAppear(perform: loader.load)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ActivityView: UIViewControllerRepresentable {
|
|
||||||
let activityItems: [Any]
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
||||||
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -25,7 +25,6 @@ struct FeedbackView: View {
|
|||||||
ratingSection
|
ratingSection
|
||||||
suggestionSection
|
suggestionSection
|
||||||
contactSection
|
contactSection
|
||||||
infoSection2
|
|
||||||
|
|
||||||
Button(action: submitSuggestion) {
|
Button(action: submitSuggestion) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@ -57,7 +56,7 @@ struct FeedbackView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle(NSLocalizedString("Обратная связь", comment: "feedback: navigation title"))
|
.navigationTitle(NSLocalizedString("Обратная связь (не работает)", comment: "feedback: navigation title"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
TapGesture().onEnded {
|
TapGesture().onEnded {
|
||||||
@ -99,24 +98,6 @@ struct FeedbackView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var infoSection2: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Label {
|
|
||||||
Text(NSLocalizedString("Ваш отзыв создаст чат с командой поддержки, который появится в общем списке чатов.", comment: "feedback: info detail chat"))
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "lock.shield.fill")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
.font(.callout)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
||||||
.fill(Color.accentColor.opacity(0.08))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var categorySection: some View {
|
private var categorySection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
sectionTitle(NSLocalizedString("Что вы хотите обсудить?", comment: "feedback: category title"))
|
sectionTitle(NSLocalizedString("Что вы хотите обсудить?", comment: "feedback: category title"))
|
||||||
@ -196,9 +177,9 @@ struct FeedbackView: View {
|
|||||||
|
|
||||||
private var contactSection: some View {
|
private var contactSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
// sectionTitle(NSLocalizedString("Нужно ли вам ответить?", comment: "feedback: contact title"))
|
sectionTitle(NSLocalizedString("Нужно ли вам ответить?", comment: "feedback: contact title"))
|
||||||
|
|
||||||
Toggle(NSLocalizedString("Уведомить об ответе по e-mail", comment: "feedback: contact toggle"), isOn: $wantsResponse)
|
Toggle(NSLocalizedString("Получить ответ от команды", comment: "feedback: contact toggle"), isOn: $wantsResponse)
|
||||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||||
|
|
||||||
if wantsResponse {
|
if wantsResponse {
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct OtherSettingsView: View {
|
|
||||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
|
||||||
@AppStorage("chatBubbleDecorationsEnabled") private var areBubbleDecorationsEnabled: Bool = true
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
|
|
||||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
|
||||||
Text(isMessengerModeEnabled
|
|
||||||
? "Мессенджер-режим сейчас проработан примерно на 50%."
|
|
||||||
: "Основной режим находится в ранней разработке (около 10%).")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Toggle(NSLocalizedString("Рожки и ножки у сообщений", comment: ""), isOn: $areBubbleDecorationsEnabled)
|
|
||||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
|
||||||
Text(areBubbleDecorationsEnabled
|
|
||||||
? NSLocalizedString("Сообщения будут с рожками и ножками.", comment: "")
|
|
||||||
: NSLocalizedString("Сообщения станут обычными закругленными облачками.", comment: ""))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
.navigationTitle(Text(NSLocalizedString("Другое", comment: "")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
NavigationView {
|
|
||||||
OtherSettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,382 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ActiveSessionsView: View {
|
|
||||||
@State private var sessions: [SessionViewData] = []
|
|
||||||
@State private var isLoading = false
|
|
||||||
@State private var loadError: String?
|
|
||||||
@State private var revokeInProgress = false
|
|
||||||
@State private var activeAlert: SessionsAlert?
|
|
||||||
@State private var showRevokeConfirmation = false
|
|
||||||
@State private var sessionPendingRevoke: SessionViewData?
|
|
||||||
@State private var revokingSessionIds: Set<UUID> = []
|
|
||||||
|
|
||||||
private let sessionsService = SessionsService()
|
|
||||||
private var currentSession: SessionViewData? {
|
|
||||||
sessions.first { $0.isCurrent }
|
|
||||||
}
|
|
||||||
private var otherSessions: [SessionViewData] {
|
|
||||||
sessions.filter { !$0.isCurrent }
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
if isLoading && sessions.isEmpty {
|
|
||||||
loadingState
|
|
||||||
} else if let loadError, sessions.isEmpty {
|
|
||||||
errorState(loadError)
|
|
||||||
} else if sessions.isEmpty {
|
|
||||||
emptyState
|
|
||||||
} else {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Всего сессий", comment: "Сводка по количеству сессий"))
|
|
||||||
.font(.subheadline)
|
|
||||||
Spacer()
|
|
||||||
Text("\(sessions.count)")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
}
|
|
||||||
if !otherSessions.isEmpty {
|
|
||||||
Text(String(format: NSLocalizedString("Сессий на других устройствах: %d", comment: "Количество сессий на других устройствах"), otherSessions.count))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Это устройство", comment: "Заголовок секции текущего устройства"))) {
|
|
||||||
if let currentSession {
|
|
||||||
sessionRow(for: currentSession)
|
|
||||||
} else {
|
|
||||||
Text(NSLocalizedString("Текущая сессия не найдена", comment: "Сообщение об отсутствии текущей сессии"))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !otherSessions.isEmpty{
|
|
||||||
Section {
|
|
||||||
revokeOtherSessionsButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !otherSessions.isEmpty {
|
|
||||||
Section(header: Text(String(format: NSLocalizedString("Другие устройства (%d)", comment: "Заголовок секции других устройств с количеством"), otherSessions.count))) {
|
|
||||||
ForEach(otherSessions) { session in
|
|
||||||
let isRevoking = isRevoking(session: session)
|
|
||||||
|
|
||||||
sessionRow(for: session, isRevoking: isRevoking)
|
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
sessionPendingRevoke = session
|
|
||||||
} label: {
|
|
||||||
Label(NSLocalizedString("Завершить", comment: "Кнопка завершения конкретной сессии"), systemImage: "trash")
|
|
||||||
}
|
|
||||||
.disabled(isRevoking)
|
|
||||||
}
|
|
||||||
.disabled(isRevoking)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.task {
|
|
||||||
await loadSessions()
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
await loadSessions(force: true)
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
NSLocalizedString("Завершить эту сессию?", comment: "Заголовок подтверждения завершения отдельной сессии"),
|
|
||||||
isPresented: Binding(
|
|
||||||
get: { sessionPendingRevoke != nil },
|
|
||||||
set: { if !$0 { sessionPendingRevoke = nil } }
|
|
||||||
),
|
|
||||||
presenting: sessionPendingRevoke
|
|
||||||
) { session in
|
|
||||||
Button(NSLocalizedString("Завершить", comment: "Подтверждение завершения конкретной сессии"), role: .destructive) {
|
|
||||||
sessionPendingRevoke = nil
|
|
||||||
Task { await revoke(session: session) }
|
|
||||||
}
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {
|
|
||||||
sessionPendingRevoke = nil
|
|
||||||
}
|
|
||||||
} message: { _ in
|
|
||||||
Text(NSLocalizedString("Вы выйдете из выбранной сессии.", comment: "Описание подтверждения завершения конкретной сессии"))
|
|
||||||
}
|
|
||||||
.alert(item: $activeAlert) { alert in
|
|
||||||
Alert(
|
|
||||||
title: Text(alert.title),
|
|
||||||
message: Text(alert.message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
NSLocalizedString("Завершить сессии на других устройствах?", comment: "Заголовок подтверждения завершения сессий"),
|
|
||||||
isPresented: $showRevokeConfirmation
|
|
||||||
) {
|
|
||||||
Button(NSLocalizedString("Завершить", comment: "Подтверждение завершения других сессий"), role: .destructive) {
|
|
||||||
Task { await revokeOtherSessions() }
|
|
||||||
}
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text(NSLocalizedString("Вы выйдете со всех устройств, кроме текущего.", comment: "Описание подтверждения завершения сессий"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var loadingState: some View {
|
|
||||||
Section {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func errorState(_ message: String) -> some View {
|
|
||||||
Section {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "exclamationmark.triangle")
|
|
||||||
.font(.system(size: 40))
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text(message)
|
|
||||||
.font(.body)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 24)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyState: some View {
|
|
||||||
Section {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "iphone")
|
|
||||||
.font(.system(size: 40))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(NSLocalizedString("Активные сессии не найдены", comment: "Пустой список активных сессий"))
|
|
||||||
.font(.headline)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Text(NSLocalizedString("Войдите с другого устройства, чтобы увидеть его здесь.", comment: "Подсказка при отсутствии активных сессий"))
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 24)
|
|
||||||
}
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sessionRow(for session: SessionViewData, isRevoking: Bool = false) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text(session.clientTypeDisplay)
|
|
||||||
.font(.headline)
|
|
||||||
if let ip = session.ipAddress, !ip.isEmpty {
|
|
||||||
Label(ip, systemImage: "globe")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if session.isCurrent {
|
|
||||||
Text(NSLocalizedString("Текущая", comment: "Маркер текущей сессии"))
|
|
||||||
.font(.caption2.weight(.semibold))
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.accentColor.opacity(0.15))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
} else if isRevoking {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let userAgent = session.userAgent, !userAgent.isEmpty {
|
|
||||||
Text(userAgent)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Label(session.firstLoginText, systemImage: "calendar")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Label(session.lastLoginText, systemImage: "arrow.clockwise")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadSessions(force: Bool = false) async {
|
|
||||||
if isLoading && !force {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
loadError = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
let payloads = try await sessionsService.fetchSessions()
|
|
||||||
sessions = payloads.map(SessionViewData.init)
|
|
||||||
} catch {
|
|
||||||
loadError = error.localizedDescription
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[ActiveSessionsView] load sessions failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func revokeOtherSessions() async {
|
|
||||||
if revokeInProgress {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
revokeInProgress = true
|
|
||||||
defer { revokeInProgress = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let message = try await sessionsService.revokeAllExceptCurrent()
|
|
||||||
activeAlert = SessionsAlert(
|
|
||||||
title: NSLocalizedString("Готово", comment: "Заголовок успешного уведомления"),
|
|
||||||
message: message
|
|
||||||
)
|
|
||||||
await loadSessions(force: true)
|
|
||||||
} catch {
|
|
||||||
activeAlert = SessionsAlert(
|
|
||||||
title: NSLocalizedString("Ошибка", comment: "Заголовок сообщения об ошибке"),
|
|
||||||
message: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func revoke(session: SessionViewData) async {
|
|
||||||
guard !session.isCurrent, !isRevoking(session: session) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
revokingSessionIds.insert(session.id)
|
|
||||||
defer { revokingSessionIds.remove(session.id) }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let message = try await sessionsService.revoke(sessionId: session.id)
|
|
||||||
sessions.removeAll { $0.id == session.id }
|
|
||||||
activeAlert = SessionsAlert(
|
|
||||||
title: NSLocalizedString("Готово", comment: "Заголовок успешного уведомления"),
|
|
||||||
message: message
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
activeAlert = SessionsAlert(
|
|
||||||
title: NSLocalizedString("Ошибка", comment: "Заголовок сообщения об ошибке"),
|
|
||||||
message: error.localizedDescription
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isRevoking(session: SessionViewData) -> Bool {
|
|
||||||
revokingSessionIds.contains(session.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var revokeOtherSessionsButton: some View {
|
|
||||||
let primaryColor: Color = revokeInProgress ? .secondary : .red
|
|
||||||
|
|
||||||
return Button {
|
|
||||||
if !revokeInProgress {
|
|
||||||
showRevokeConfirmation = true
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
if revokeInProgress {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "xmark.circle")
|
|
||||||
.foregroundColor(primaryColor)
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(NSLocalizedString("Завершить другие сессии", comment: "Кнопка завершения других сессий"))
|
|
||||||
.foregroundColor(primaryColor)
|
|
||||||
Text(NSLocalizedString("Текущая сессия останется активной", comment: "Подсказка под кнопкой завершения других сессий"))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
.disabled(revokeInProgress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SessionViewData: Identifiable, Equatable {
|
|
||||||
let id: UUID
|
|
||||||
let ipAddress: String?
|
|
||||||
let userAgent: String?
|
|
||||||
let clientType: String
|
|
||||||
let isActive: Bool
|
|
||||||
let createdAt: Date
|
|
||||||
let lastRefreshAt: Date
|
|
||||||
let isCurrent: Bool
|
|
||||||
|
|
||||||
init(payload: UserSessionPayload) {
|
|
||||||
self.id = payload.id
|
|
||||||
self.ipAddress = payload.ipAddress
|
|
||||||
self.userAgent = payload.userAgent
|
|
||||||
self.clientType = payload.clientType
|
|
||||||
self.isActive = payload.isActive
|
|
||||||
self.createdAt = payload.createdAt
|
|
||||||
self.lastRefreshAt = payload.lastRefreshAt
|
|
||||||
self.isCurrent = payload.isCurrent
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientTypeDisplay: String {
|
|
||||||
let normalized = clientType.lowercased()
|
|
||||||
switch normalized {
|
|
||||||
case "mobile":
|
|
||||||
return NSLocalizedString("Мобильное приложение", comment: "Тип сессии — мобильное приложение")
|
|
||||||
case "web":
|
|
||||||
return NSLocalizedString("Веб", comment: "Тип сессии — веб")
|
|
||||||
case "desktop":
|
|
||||||
return NSLocalizedString("Десктоп", comment: "Тип сессии — десктоп")
|
|
||||||
case "bot":
|
|
||||||
return NSLocalizedString("Бот", comment: "Тип сессии — бот")
|
|
||||||
default:
|
|
||||||
return clientType.capitalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var firstLoginText: String {
|
|
||||||
let formatted = Self.dateFormatter.string(from: createdAt)
|
|
||||||
return String(format: NSLocalizedString("Первый вход: %@", comment: "Дата первого входа в сессию"), formatted)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastLoginText: String {
|
|
||||||
let formatted = Self.dateFormatter.string(from: lastRefreshAt)
|
|
||||||
return String(format: NSLocalizedString("Последний вход: %@", comment: "Дата последнего входа в сессию"), formatted)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let dateFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.locale = Locale.current
|
|
||||||
formatter.timeZone = .current
|
|
||||||
formatter.dateStyle = .medium
|
|
||||||
formatter.timeStyle = .short
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SessionsAlert: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let title: String
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AppLockSettingsView: View {
|
|
||||||
@State private var desiredPassword: String = ""
|
|
||||||
@State private var confirmationPassword: String = ""
|
|
||||||
@State private var activeAlert: AppLockAlert?
|
|
||||||
@FocusState private var focusedField: Field?
|
|
||||||
|
|
||||||
private enum Field: Hashable {
|
|
||||||
case desired
|
|
||||||
case confirmation
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section(header: Text(NSLocalizedString("Пароль-приложение", comment: "Раздел формы установки пароля на приложение"))) {
|
|
||||||
SecureField(NSLocalizedString("Введите пароль", comment: "Поле ввода пароля на приложение"), text: $desiredPassword)
|
|
||||||
.focused($focusedField, equals: .desired)
|
|
||||||
|
|
||||||
SecureField(NSLocalizedString("Повторите пароль", comment: "Поле подтверждения пароля на приложение"), text: $confirmationPassword)
|
|
||||||
.focused($focusedField, equals: .confirmation)
|
|
||||||
|
|
||||||
Button(NSLocalizedString("Сохранить пароль", comment: "Кнопка сохранения пароля на приложение")) {
|
|
||||||
handleSaveTapped()
|
|
||||||
}
|
|
||||||
.disabled(desiredPassword.isEmpty || confirmationPassword.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Text(NSLocalizedString("Настоящая защита приложения появится позже. Пока вы можете ознакомится с макетом.", comment: "Описание заглушки для пароля на приложение"))
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Пароль на приложение", comment: "Заголовок экрана пароля на приложение"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.onAppear {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
|
||||||
focusedField = .desired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert(item: $activeAlert) { alert in
|
|
||||||
Alert(
|
|
||||||
title: Text(alert.title),
|
|
||||||
message: Text(alert.message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSaveTapped() {
|
|
||||||
guard !desiredPassword.isEmpty, desiredPassword == confirmationPassword else {
|
|
||||||
activeAlert = AppLockAlert(
|
|
||||||
title: NSLocalizedString("Пароли не совпадают", comment: "Заголовок ошибки несовпадения паролей"),
|
|
||||||
message: NSLocalizedString("Проверьте ввод и попробуйте снова.", comment: "Сообщение ошибки несовпадения паролей"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
activeAlert = AppLockAlert(
|
|
||||||
title: NSLocalizedString("Скоро", comment: "Заголовок заглушки"),
|
|
||||||
message: NSLocalizedString("Защита приложением будет добавлена в будущих обновлениях.", comment: "Сообщение заглушки пароля на приложение")
|
|
||||||
)
|
|
||||||
desiredPassword.removeAll()
|
|
||||||
confirmationPassword.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AppLockAlert: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let title: String
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct AppLockSettingsView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NavigationView {
|
|
||||||
AppLockSettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct EmailSecuritySettingsView: View {
|
|
||||||
@State private var isLoginCodesEnabled = false
|
|
||||||
@State private var activeAlert: EmailSecurityAlert?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section(header: Text(NSLocalizedString("Защита входа", comment: "Раздел защиты входа через email"))) {
|
|
||||||
Toggle(NSLocalizedString("Получать коды на email при входе", comment: "Переключатель отправки кодов при входе"), isOn: Binding(
|
|
||||||
get: { isLoginCodesEnabled },
|
|
||||||
set: { _ in
|
|
||||||
activeAlert = EmailSecurityAlert(
|
|
||||||
title: NSLocalizedString("Скоро", comment: "Заголовок заглушки"),
|
|
||||||
message: NSLocalizedString("Функция пока недоступна.", comment: "Сообщение заглушки")
|
|
||||||
)
|
|
||||||
isLoginCodesEnabled = false
|
|
||||||
}
|
|
||||||
))
|
|
||||||
|
|
||||||
Text(NSLocalizedString("Мы отправим код подтверждения на привязанный email каждый раз при входе.", comment: "Описание работы кодов при входе"))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Подтверждение email", comment: "Раздел подтверждения email"))) {
|
|
||||||
Text(NSLocalizedString("Email не подтверждён. Подтвердите, чтобы активировать дополнительные проверки.", comment: "Описание необходимости подтверждения email"))
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Button(NSLocalizedString("Отправить письмо подтверждения", comment: "Кнопка отправки письма подтверждения")) {
|
|
||||||
activeAlert = EmailSecurityAlert(
|
|
||||||
title: NSLocalizedString("Скоро", comment: "Заголовок заглушки"),
|
|
||||||
message: NSLocalizedString("Мы отправим письмо, как только функция будет готова.", comment: "Сообщение при недоступной отправке письма")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(NSLocalizedString("Email", comment: "Заголовок экрана настроек email"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.alert(item: $activeAlert) { alert in
|
|
||||||
Alert(
|
|
||||||
title: Text(alert.title),
|
|
||||||
message: Text(alert.message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct EmailSecurityAlert: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let title: String
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct EmailSecuritySettingsView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NavigationView {
|
|
||||||
EmailSecuritySettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct TwoFactorAuthView: View {
|
|
||||||
@State private var isTwoFactorEnabled = false
|
|
||||||
@State private var showEnableConfirmation = false
|
|
||||||
@State private var showDisableConfirmation = false
|
|
||||||
@State private var secretKey: String = TwoFactorAuthView.generateSecret()
|
|
||||||
@State private var verificationCode: String = ""
|
|
||||||
@State private var backupCodes: [String] = []
|
|
||||||
@State private var activeAlert: TwoFactorAlert?
|
|
||||||
@FocusState private var isCodeFieldFocused: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
Section(header: Text(NSLocalizedString("Статус защиты", comment: "Раздел состояния 2FA"))) {
|
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { isTwoFactorEnabled },
|
|
||||||
set: { handleToggleChange($0) }
|
|
||||||
)) {
|
|
||||||
Label(NSLocalizedString("Включить 2FA", comment: "Тоггл активации 2FA"), systemImage: "lock.shield")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTwoFactorEnabled {
|
|
||||||
Section(header: Text(NSLocalizedString("Настройка приложения", comment: "Раздел инструкций подключения"))) {
|
|
||||||
Text(NSLocalizedString("Добавьте новый аккаунт в приложении аутентификации и введите следующий ключ:", comment: "Инструкция по добавлению ключа 2FA"))
|
|
||||||
.font(.callout)
|
|
||||||
keyRow
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Проверочный код", comment: "Раздел верификации 2FA"))) {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
TextField(NSLocalizedString("Введите код из приложения", comment: "Поле ввода кода 2FA"), text: $verificationCode)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.focused($isCodeFieldFocused)
|
|
||||||
.onChange(of: verificationCode) { newValue in
|
|
||||||
verificationCode = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: verifyCode) {
|
|
||||||
Text(NSLocalizedString("Подтвердить", comment: "Кнопка подтверждения кода 2FA"))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(verificationCode.isEmpty)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Коды восстановления", comment: "Раздел кодов восстановления 2FA"))) {
|
|
||||||
if backupCodes.isEmpty {
|
|
||||||
Text(NSLocalizedString("Сгенерируйте резервные коды и сохраните их в надежном месте.", comment: "Подсказка о необходимости генерации кодов"))
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
} else {
|
|
||||||
ForEach(backupCodes, id: \.self) { code in
|
|
||||||
HStack {
|
|
||||||
Text(code)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
Spacer()
|
|
||||||
Button(action: { copyToPasteboard(code) }) {
|
|
||||||
Image(systemName: "doc.on.doc")
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(NSLocalizedString("Скопировать код", comment: "Кнопка копирования кода восстановления"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: generateBackupCodes) {
|
|
||||||
Label(NSLocalizedString("Создать новые коды", comment: "Кнопка генерации резервных кодов"), systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(footer: Text(NSLocalizedString("Вы всегда можете отключить двухфакторную защиту, но мы рекомендуем оставлять её включённой для безопасности.", comment: "Рекомендация оставить 2FA включенной"))) {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
.navigationTitle(NSLocalizedString("Двухфакторная аутентификация", comment: "Заголовок экрана 2FA"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.alert(item: $activeAlert) { alert in
|
|
||||||
Alert(
|
|
||||||
title: Text(alert.title),
|
|
||||||
message: Text(alert.message),
|
|
||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
NSLocalizedString("Включить двухфакторную аутентификацию?", comment: "Заголовок подтверждения включения 2FA"),
|
|
||||||
isPresented: $showEnableConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button(NSLocalizedString("Включить", comment: "Кнопка подтверждения включения 2FA"), role: .destructive) {
|
|
||||||
enableTwoFactor()
|
|
||||||
}
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {}
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
NSLocalizedString("Отключить двухфакторную аутентификацию?", comment: "Заголовок подтверждения отключения 2FA"),
|
|
||||||
isPresented: $showDisableConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button(NSLocalizedString("Отключить", comment: "Кнопка подтверждения отключения 2FA"), role: .destructive) {
|
|
||||||
disableTwoFactor()
|
|
||||||
}
|
|
||||||
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension TwoFactorAuthView {
|
|
||||||
var keyRow: some View {
|
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
Text(secretKey)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
.textSelection(.enabled)
|
|
||||||
Spacer()
|
|
||||||
Button(action: { copyToPasteboard(secretKey) }) {
|
|
||||||
Image(systemName: "doc.on.doc")
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(NSLocalizedString("Скопировать ключ", comment: "Кнопка копирования секретного ключа"))
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
.background(Color(UIColor.secondarySystemBackground))
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleToggleChange(_ newValue: Bool) {
|
|
||||||
if newValue {
|
|
||||||
showEnableConfirmation = true
|
|
||||||
} else {
|
|
||||||
showDisableConfirmation = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func enableTwoFactor() {
|
|
||||||
isTwoFactorEnabled = true
|
|
||||||
showEnableConfirmation = false
|
|
||||||
secretKey = Self.generateSecret()
|
|
||||||
verificationCode = ""
|
|
||||||
generateBackupCodes()
|
|
||||||
activeAlert = TwoFactorAlert(
|
|
||||||
title: NSLocalizedString("2FA включена", comment: "Заголовок уведомления об успешной активации 2FA"),
|
|
||||||
message: NSLocalizedString("Сохраните секретный ключ и введите код из приложения, чтобы завершить настройку.", comment: "Сообщение после активации 2FA")
|
|
||||||
)
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
||||||
isCodeFieldFocused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func disableTwoFactor() {
|
|
||||||
isTwoFactorEnabled = false
|
|
||||||
showDisableConfirmation = false
|
|
||||||
verificationCode = ""
|
|
||||||
backupCodes.removeAll()
|
|
||||||
activeAlert = TwoFactorAlert(
|
|
||||||
title: NSLocalizedString("2FA отключена", comment: "Заголовок уведомления об отключении 2FA"),
|
|
||||||
message: NSLocalizedString("Вы можете включить защиту снова в любой момент.", comment: "Сообщение после отключения 2FA")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyCode() {
|
|
||||||
let normalized = verificationCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard normalized.count == 6, normalized.allSatisfy(\.isNumber) else {
|
|
||||||
activeAlert = TwoFactorAlert(
|
|
||||||
title: NSLocalizedString("Неверный код", comment: "Заголовок ошибки неправильного кода 2FA"),
|
|
||||||
message: NSLocalizedString("Проверьте цифры и попробуйте снова.", comment: "Описание ошибки неверного кода 2FA")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationCode = ""
|
|
||||||
activeAlert = TwoFactorAlert(
|
|
||||||
title: NSLocalizedString("Код принят", comment: "Заголовок успешного подтверждения кода 2FA"),
|
|
||||||
message: NSLocalizedString("Двухфакторная аутентификация настроена.", comment: "Сообщение после успешного подтверждения кода 2FA")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateBackupCodes() {
|
|
||||||
backupCodes = Self.generateBackupCodes()
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyToPasteboard(_ value: String) {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
UIPasteboard.general.string = value
|
|
||||||
#endif
|
|
||||||
activeAlert = TwoFactorAlert(
|
|
||||||
title: NSLocalizedString("Скопировано", comment: "Заголовок уведомления о копировании"),
|
|
||||||
message: NSLocalizedString("Значение сохранено в буфере обмена.", comment: "Сообщение после копирования")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func generateSecret() -> String {
|
|
||||||
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
|
|
||||||
return String((0..<16).compactMap { _ in alphabet.randomElement() })
|
|
||||||
}
|
|
||||||
|
|
||||||
static func generateBackupCodes(count: Int = 8) -> [String] {
|
|
||||||
let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
|
|
||||||
return (0..<count).map { _ in
|
|
||||||
String((0..<8).compactMap { _ in alphabet.randomElement() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TwoFactorAlert: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let title: String
|
|
||||||
let message: String
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct TwoFactorAuthView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NavigationView {
|
|
||||||
TwoFactorAuthView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SecuritySettingsView: View {
|
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
|
||||||
@State private var isTwoFactorActive = false
|
|
||||||
@State private var isEmailSettingsActive = false
|
|
||||||
@State private var isAppLockActive = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
Section(header: Text(NSLocalizedString("Вход и защита аккаунта (заглушка)", comment: "Раздел настроек безопасности для аутентификации"))) {
|
|
||||||
NavigationLink(isActive: $isTwoFactorActive) {
|
|
||||||
TwoFactorAuthView()
|
|
||||||
} label: {
|
|
||||||
Label(NSLocalizedString("Двухфакторная аутентификация", comment: "Переход к настройкам двухфакторной аутентификации"), systemImage: "lock.shield")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(isActive: $isEmailSettingsActive) {
|
|
||||||
EmailSecuritySettingsView()
|
|
||||||
} label: {
|
|
||||||
Label(NSLocalizedString("Настройки email", comment: "Переход к настройкам безопасности email"), systemImage: "envelope")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(isActive: $isAppLockActive) {
|
|
||||||
AppLockSettingsView()
|
|
||||||
} label: {
|
|
||||||
Label(NSLocalizedString("Пароль на приложение", comment: "Переход к настройкам пароля на приложение"), systemImage: "lock.square")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(NSLocalizedString("Приватность и контроль", comment: ""))) {
|
|
||||||
|
|
||||||
NavigationLink(destination: EditPrivacyView()) {
|
|
||||||
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(destination: ChangePasswordView()) {
|
|
||||||
Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key")
|
|
||||||
}
|
|
||||||
NavigationLink(destination: ActiveSessionsView()) {
|
|
||||||
Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.insetGrouped)
|
|
||||||
.navigationTitle(NSLocalizedString("Безопасность", comment: "Заголовок экрана настроек безопасности"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
// .onAppear { handleSecuritySettingsOnboardingIfNeeded() }
|
|
||||||
// .onChange(of: viewModel.onboardingDestination) { _ in
|
|
||||||
// handleSecuritySettingsOnboardingIfNeeded()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// private func handleSecuritySettingsOnboardingIfNeeded() {
|
|
||||||
// guard viewModel.onboardingDestination == .securitySettings else { return }
|
|
||||||
// guard !isTwoFactorActive else {
|
|
||||||
// viewModel.onboardingDestination = nil
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// isTwoFactorActive = true
|
|
||||||
// viewModel.onboardingDestination = nil
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct SecuritySettingsView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NavigationView {
|
|
||||||
SecuritySettingsView(viewModel: LoginViewModel())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@ -3,18 +3,8 @@ import SwiftUI
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
@EnvironmentObject private var themeManager: ThemeManager
|
@EnvironmentObject private var themeManager: ThemeManager
|
||||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
|
||||||
@State private var isThemeExpanded = false
|
@State private var isThemeExpanded = false
|
||||||
@State private var isSecurityActive = false
|
|
||||||
@State private var messengerProfile: ProfileDataPayload?
|
|
||||||
@State private var isMessengerProfileLoading = false
|
|
||||||
@State private var messengerProfileError: String?
|
|
||||||
private let themeOptions = ThemeOption.ordered
|
private let themeOptions = ThemeOption.ordered
|
||||||
private let profileService = ProfileService()
|
|
||||||
private let messengerAvatarSize: CGFloat = 96
|
|
||||||
private let bannerRowInsets = EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)
|
|
||||||
private let aboutRowInsets = EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)
|
|
||||||
private let compactSectionSpacing: CGFloat = 6
|
|
||||||
|
|
||||||
private var selectedThemeOption: ThemeOption {
|
private var selectedThemeOption: ThemeOption {
|
||||||
ThemeOption.option(for: themeManager.theme)
|
ThemeOption.option(for: themeManager.theme)
|
||||||
@ -22,48 +12,27 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
if shouldShowLegacySupportBanner {
|
|
||||||
LegacySupportBanner()
|
|
||||||
.listRowInsets(bannerRowInsets)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isMessengerModeEnabled {
|
|
||||||
messengerProfileHeaderSection
|
|
||||||
aboutSection
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Профиль
|
// MARK: - Профиль
|
||||||
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
|
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
|
||||||
// NavigationLink(destination: EditProfileView()) {
|
// NavigationLink(destination: EditProfileView()) {
|
||||||
// Label("Мой профиль", systemImage: "person.crop.circle")
|
// Label("Мой профиль", systemImage: "person.crop.circle")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
NavigationLink(destination: EditProfileView()) {
|
NavigationLink(destination: EditPrivacyView()) {
|
||||||
Label(NSLocalizedString("Редактировать профиль", comment: ""), systemImage: "person.crop.circle")
|
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(destination: BlockedUsersView()) {
|
|
||||||
Label(NSLocalizedString("Чёрный список", comment: ""), systemImage: "hand.raised.fill")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Безопасность
|
// MARK: - Безопасность
|
||||||
Section(header: Text(NSLocalizedString("Безопасность", comment: ""))) {
|
Section(header: Text(NSLocalizedString("Безопасность", comment: ""))) {
|
||||||
NavigationLink(destination: EditPrivacyView()) {
|
|
||||||
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(destination: ChangePasswordView()) {
|
NavigationLink(destination: ChangePasswordView()) {
|
||||||
Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key")
|
Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key")
|
||||||
}
|
}
|
||||||
NavigationLink(destination: ActiveSessionsView()) {
|
NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) {
|
||||||
Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
|
Label("Двухфакторная аутентификация", systemImage: "lock.shield")
|
||||||
}
|
}
|
||||||
NavigationLink(isActive: $isSecurityActive) {
|
NavigationLink(destination: Text("Заглушка: Активные сессии")) {
|
||||||
SecuritySettingsView(viewModel: viewModel)
|
Label("Активные сессии", systemImage: "iphone")
|
||||||
} label: {
|
|
||||||
Label(NSLocalizedString("Безопасность", comment: ""), systemImage: "lock.shield")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,11 +56,11 @@ struct SettingsView: View {
|
|||||||
Label("Темы", systemImage: "moon.fill")
|
Label("Темы", systemImage: "moon.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink(destination: DataSettingsView(currentUserId: viewModel.userId)) {
|
NavigationLink(destination: Text("Заглушка: Хранилище данных")) {
|
||||||
Label("Данные", systemImage: "externaldrive")
|
Label("Данные", systemImage: "externaldrive")
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink(destination: OtherSettingsView()) {
|
NavigationLink(destination: Text("Заглушка: Другие настройки")) {
|
||||||
Label("Другое", systemImage: "ellipsis.circle")
|
Label("Другое", systemImage: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -138,14 +107,8 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Выход
|
// MARK: - Выход
|
||||||
Section (
|
Section {
|
||||||
header: Spacer()
|
|
||||||
.frame(height: 32)
|
|
||||||
.listRowInsets(EdgeInsets())
|
|
||||||
){
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.logoutCurrentUser()
|
viewModel.logoutCurrentUser()
|
||||||
}) {
|
}) {
|
||||||
@ -158,61 +121,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Настройки")
|
.navigationTitle("Настройки")
|
||||||
.onAppear {
|
|
||||||
loadMessengerProfileIfNeeded()
|
|
||||||
}
|
|
||||||
.onChange(of: isMessengerModeEnabled) { newValue in
|
|
||||||
if newValue {
|
|
||||||
loadMessengerProfileIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.applyFormSectionSpacing(compactSectionSpacing)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var messengerProfileHeaderSection: some View {
|
|
||||||
if messengerProfile != nil || isMessengerProfileLoading || messengerProfileError != nil {
|
|
||||||
Section {
|
|
||||||
if let profile = messengerProfile {
|
|
||||||
NavigationLink(destination: EditProfileView()) {
|
|
||||||
ProfileHeaderCardView(
|
|
||||||
avatar: messengerAvatar(for: profile),
|
|
||||||
displayName: messengerDisplayName(for: profile),
|
|
||||||
presenceStatus: nil,
|
|
||||||
statusTags: messengerStatusTags(for: profile),
|
|
||||||
isOfficial: profile.isVerified
|
|
||||||
)
|
|
||||||
.contentShape(Rectangle()) // ← чтобы тап работал по всей площади
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain) // ← чтобы не было system highlight
|
|
||||||
.listRowInsets(bannerRowInsets)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
} else if isMessengerProfileLoading {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.listRowInsets(bannerRowInsets)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
} else if let error = messengerProfileError {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Text(error)
|
|
||||||
.font(.footnote)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Button(NSLocalizedString("Повторить", comment: "Messenger profile header retry")) {
|
|
||||||
loadMessengerProfileIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.listRowInsets(bannerRowInsets)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openLanguageSettings() {
|
private func openLanguageSettings() {
|
||||||
@ -220,114 +128,6 @@ struct SettingsView: View {
|
|||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadMessengerProfileIfNeeded(force: Bool = false) {
|
|
||||||
guard isMessengerModeEnabled else { return }
|
|
||||||
if isMessengerProfileLoading { return }
|
|
||||||
if !force, messengerProfile != nil { return }
|
|
||||||
|
|
||||||
isMessengerProfileLoading = true
|
|
||||||
messengerProfileError = nil
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let profile = try await profileService.fetchMyProfile()
|
|
||||||
await MainActor.run {
|
|
||||||
messengerProfile = profile
|
|
||||||
isMessengerProfileLoading = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
messengerProfileError = error.localizedDescription
|
|
||||||
isMessengerProfileLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func messengerAvatar(for profile: ProfileDataPayload) -> some View {
|
|
||||||
if let fileId = profile.avatars?.current?.fileId,
|
|
||||||
let url = URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(profile.userId.uuidString)?file_id=\(fileId)") {
|
|
||||||
CachedAvatarView(url: url, fileId: fileId, userId: profile.userId.uuidString) {
|
|
||||||
messengerAvatarPlaceholder(for: profile)
|
|
||||||
}
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: messengerAvatarSize, height: messengerAvatarSize)
|
|
||||||
.clipShape(Circle())
|
|
||||||
} else {
|
|
||||||
messengerAvatarPlaceholder(for: profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func messengerAvatarPlaceholder(for profile: ProfileDataPayload) -> some View {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.accentColor.opacity(0.15))
|
|
||||||
.frame(width: messengerAvatarSize, height: messengerAvatarSize)
|
|
||||||
.overlay(
|
|
||||||
Text(messengerInitials(for: profile))
|
|
||||||
.font(.system(size: messengerAvatarSize * 0.45, weight: .semibold))
|
|
||||||
.foregroundColor(Color.accentColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func messengerInitials(for profile: ProfileDataPayload) -> String {
|
|
||||||
if let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
|
||||||
let components = name.split(separator: " ")
|
|
||||||
let initials = components.prefix(2).compactMap { $0.first }
|
|
||||||
if !initials.isEmpty {
|
|
||||||
return initials.map { String($0) }.joined().uppercased()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(profile.login.prefix(1)).uppercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func messengerDisplayName(for profile: ProfileDataPayload) -> String {
|
|
||||||
if let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return "@\(profile.login)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func messengerStatusTags(for profile: ProfileDataPayload) -> [ProfileHeaderCardView.StatusTag] {
|
|
||||||
var tags: [ProfileHeaderCardView.StatusTag] = []
|
|
||||||
// tags.append(
|
|
||||||
// ProfileHeaderCardView.StatusTag(
|
|
||||||
// icon: "at",
|
|
||||||
// text: "@\(profile.login)",
|
|
||||||
// background: Color.white.opacity(0.18),
|
|
||||||
// tint: .white
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
|
|
||||||
// if let createdAt = profile.createdAt {
|
|
||||||
// let formatted = SettingsView.membershipFormatter.string(from: createdAt)
|
|
||||||
// tags.append(
|
|
||||||
// ProfileHeaderCardView.StatusTag(
|
|
||||||
// icon: "calendar",
|
|
||||||
// text: String(
|
|
||||||
// format: NSLocalizedString("С %@ на Yobble", comment: "Messenger profile membership"),
|
|
||||||
// formatted
|
|
||||||
// ),
|
|
||||||
// background: Color.white.opacity(0.12),
|
|
||||||
// tint: .white
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
if profile.isVerified {
|
|
||||||
tags.append(
|
|
||||||
ProfileHeaderCardView.StatusTag(
|
|
||||||
icon: "checkmark.seal.fill",
|
|
||||||
text: NSLocalizedString("Подтверждённый профиль", comment: "Message profile verified tag"),
|
|
||||||
background: Color.white.opacity(0.18),
|
|
||||||
tint: .white
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
private func themeRow(for option: ThemeOption) -> some View {
|
private func themeRow(for option: ThemeOption) -> some View {
|
||||||
let isSelected = option == selectedThemeOption
|
let isSelected = option == selectedThemeOption
|
||||||
|
|
||||||
@ -360,176 +160,4 @@ struct SettingsView: View {
|
|||||||
themeManager.setTheme(mappedTheme)
|
themeManager.setTheme(mappedTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowLegacySupportBanner: Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
let requiredVersion = OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)
|
|
||||||
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
|
|
||||||
#else
|
|
||||||
return false
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let membershipFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateStyle = .long
|
|
||||||
formatter.timeStyle = .none
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let ratingFormatter: NumberFormatter = {
|
|
||||||
let formatter = NumberFormatter()
|
|
||||||
formatter.numberStyle = .decimal
|
|
||||||
formatter.minimumFractionDigits = 1
|
|
||||||
formatter.maximumFractionDigits = 1
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var aboutSection: some View {
|
|
||||||
if let _ = messengerProfile {
|
|
||||||
Section(
|
|
||||||
header: Spacer()
|
|
||||||
.frame(height: 16)
|
|
||||||
.listRowInsets(EdgeInsets())
|
|
||||||
){
|
|
||||||
card {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
infoRow(
|
|
||||||
title: NSLocalizedString("Юзернейм", comment: ""),
|
|
||||||
value: loginDisplay ?? NSLocalizedString("Неизвестный пользователь", comment: "Messenger settings unknown user")
|
|
||||||
)
|
|
||||||
|
|
||||||
if let membership = membershipDescription {
|
|
||||||
rowDivider
|
|
||||||
infoRow(
|
|
||||||
title: NSLocalizedString("Дата регистрации в Yobble", comment: ""),
|
|
||||||
value: membership
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowDivider
|
|
||||||
infoRow(
|
|
||||||
title: NSLocalizedString("Ваш рейтинг", comment: "Messenger settings rating title"),
|
|
||||||
value: ratingDisplayValue
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowInsets(aboutRowInsets)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func infoRow(icon: String? = nil, title: String, value: String) -> some View {
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
if let icon {
|
|
||||||
iconBackground(color: .accentColor.opacity(0.18)) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 17, weight: .semibold))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(title)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(value)
|
|
||||||
.font(.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func iconBackground<Content: View>(color: Color, @ViewBuilder content: () -> Content) -> some View {
|
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
||||||
.fill(color)
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.overlay(content())
|
|
||||||
}
|
|
||||||
|
|
||||||
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
.padding(20)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
||||||
.fill(Color(UIColor.secondarySystemGroupedBackground))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var rowDivider: some View {
|
|
||||||
Divider()
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var loginDisplay: String? {
|
|
||||||
let login = messengerProfile?.login.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !login.isEmpty else { return nil }
|
|
||||||
return "@\(login)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var membershipDescription: String? {
|
|
||||||
guard let createdAt = messengerProfile?.createdAt else { return nil }
|
|
||||||
let formatted = SettingsView.membershipFormatter.string(from: createdAt)
|
|
||||||
return formatted
|
|
||||||
}
|
|
||||||
|
|
||||||
private var ratingDisplayValue: String {
|
|
||||||
guard let rating = messengerProfile?.rating else {
|
|
||||||
return NSLocalizedString("Недоступно", comment: "Messenger settings rating unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
let clamped = max(0, min(5, rating))
|
|
||||||
let formatted = SettingsView.ratingFormatter.string(from: NSNumber(value: clamped))
|
|
||||||
?? String(format: "%.1f", clamped)
|
|
||||||
return String(
|
|
||||||
format: NSLocalizedString("%@ из 5", comment: "Message profile rating format"),
|
|
||||||
formatted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension View {
|
|
||||||
@ViewBuilder
|
|
||||||
func applyFormSectionSpacing(_ spacing: CGFloat) -> some View {
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
self.listSectionSpacing(.custom(spacing))
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LegacySupportBanner: View {
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.font(.system(size: 24, weight: .semibold))
|
|
||||||
.foregroundColor(.yellow)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Экспериментальная поддержка iOS 15/16")
|
|
||||||
.font(.headline)
|
|
||||||
Text("Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
||||||
.fill(Color.yellow.opacity(0.15))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
||||||
.stroke(Color.yellow.opacity(0.4), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
static var DEBUG: Bool = false
|
static var DEBUG: Bool = true
|
||||||
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
|
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
|
||||||
static let PROTOCOL = "https"
|
static let PROTOCOL = "https"
|
||||||
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
|
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
|
||||||
@ -12,10 +12,15 @@ struct AppConfig {
|
|||||||
static let APP_BUILD = "appstore" // appstore / freestore
|
static let APP_BUILD = "appstore" // appstore / freestore
|
||||||
static let APP_VERSION = "0.1"
|
static let APP_VERSION = "0.1"
|
||||||
|
|
||||||
|
static let DISABLE_DB = false
|
||||||
/// Controls whether incoming chat opens as a modal sheet (`true`) or navigates to Chats tab (`false`).
|
/// Controls whether incoming chat opens as a modal sheet (`true`) or navigates to Chats tab (`false`).
|
||||||
static let PRESENT_CHAT_AS_SHEET = false
|
static let PRESENT_CHAT_AS_SHEET = false
|
||||||
|
|
||||||
static let DISABLE_DB = false
|
|
||||||
/// Fallback SQLCipher key used until the user sets an application password.
|
/// Fallback SQLCipher key used until the user sets an application password.
|
||||||
static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
|
static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AppInfo {
|
||||||
|
static let text_1 = "\(NSLocalizedString("profile_down_text_1", comment: "")) yobble"
|
||||||
|
static let text_2 = "\(NSLocalizedString("profile_down_text_2", comment: "")) 0.1test"
|
||||||
|
static let text_3 = "\(NSLocalizedString("profile_down_text_3", comment: ""))2025"
|
||||||
|
}
|
||||||
|
|||||||
@ -2,13 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<string>development</string>
|
<true/>
|
||||||
<key>com.apple.developer.aps-environment</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<string>development</string>
|
<true/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -10,91 +10,74 @@ import CoreData
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct yobbleApp: App {
|
struct yobbleApp: App {
|
||||||
// @UIApplicationDelegateAdaptor(PushAppDelegate.self) var appDelegate
|
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
|
||||||
|
|
||||||
@StateObject private var themeManager = ThemeManager()
|
@StateObject private var themeManager = ThemeManager()
|
||||||
@StateObject private var viewModel = LoginViewModel()
|
@StateObject private var viewModel = LoginViewModel()
|
||||||
@StateObject private var messageCenter = IncomingMessageCenter()
|
@StateObject private var messageCenter = IncomingMessageCenter()
|
||||||
@StateObject private var updateChecker = AppUpdateChecker()
|
|
||||||
private let persistenceController = PersistenceController.shared
|
private let persistenceController = PersistenceController.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
ZStack(alignment: .top) {
|
||||||
if let notice = updateChecker.needUpdateNotice {
|
Group {
|
||||||
NeedUpdateView(
|
if viewModel.isLoading {
|
||||||
title: notice.title,
|
SplashScreenView()
|
||||||
message: notice.message,
|
} else if viewModel.isLoggedIn {
|
||||||
onUpdate: { updateChecker.openAppStore(link: notice.appStoreURL) }
|
MainView(viewModel: viewModel)
|
||||||
)
|
} else {
|
||||||
} else {
|
LoginView(viewModel: viewModel)
|
||||||
ZStack(alignment: .top) {
|
|
||||||
Group {
|
|
||||||
if viewModel.isInitialLoading {
|
|
||||||
SplashScreenView()
|
|
||||||
} else if viewModel.isLoggedIn {
|
|
||||||
MainView(viewModel: viewModel)
|
|
||||||
} else {
|
|
||||||
LoginView(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let banner = messageCenter.banner {
|
|
||||||
NewMessageBannerView(
|
|
||||||
banner: banner,
|
|
||||||
onOpen: { messageCenter.openCurrentChat() },
|
|
||||||
onDismiss: { messageCenter.dismissBanner() }
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 12)
|
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
|
||||||
.zIndex(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
|
|
||||||
.sheet(item: AppConfig.PRESENT_CHAT_AS_SHEET ? $messageCenter.presentedChat : .constant(nil)) { chatItem in
|
|
||||||
NavigationView {
|
|
||||||
PrivateChatView(
|
|
||||||
chat: chatItem,
|
|
||||||
currentUserId: messageCenter.currentUserId
|
|
||||||
)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button(NSLocalizedString("Закрыть", comment: "")) {
|
|
||||||
messageCenter.presentedChat = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert(item: Binding(
|
|
||||||
get: { updateChecker.softUpdateNotice },
|
|
||||||
set: { newValue in
|
|
||||||
if newValue == nil {
|
|
||||||
updateChecker.dismissSoftUpdateIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)) { notice in
|
|
||||||
Alert(
|
|
||||||
title: Text(notice.title),
|
|
||||||
message: Text(notice.message),
|
|
||||||
primaryButton: .default(Text(NSLocalizedString("Обновить", comment: ""))) {
|
|
||||||
updateChecker.openAppStore(link: notice.appStoreURL)
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel(Text(NSLocalizedString("Позже", comment: ""))) {
|
|
||||||
updateChecker.dismissSoftUpdateIfNeeded(skipBuild: notice.skipBuild)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let banner = messageCenter.banner {
|
||||||
|
NewMessageBannerView(
|
||||||
|
banner: banner,
|
||||||
|
onOpen: { messageCenter.openCurrentChat() },
|
||||||
|
onDismiss: { messageCenter.dismissBanner() }
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
|
||||||
|
.sheet(item: AppConfig.PRESENT_CHAT_AS_SHEET ? $messageCenter.presentedChat : .constant(nil)) { chatItem in
|
||||||
|
NavigationView {
|
||||||
|
PrivateChatView(
|
||||||
|
chat: chatItem,
|
||||||
|
currentUserId: messageCenter.currentUserId
|
||||||
|
)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(NSLocalizedString("Закрыть", comment: "")) {
|
||||||
|
messageCenter.presentedChat = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environmentObject(messageCenter)
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: AppConfig.PRESENT_CHAT_AS_SHEET ? .constant(nil) : $messageCenter.presentedChat) { chatItem in
|
||||||
|
NavigationView {
|
||||||
|
PrivateChatView(
|
||||||
|
chat: chatItem,
|
||||||
|
currentUserId: messageCenter.currentUserId
|
||||||
|
)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(NSLocalizedString("Закрыть", comment: "")) {
|
||||||
|
messageCenter.presentedChat = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environmentObject(messageCenter)
|
||||||
}
|
}
|
||||||
.environmentObject(messageCenter)
|
.environmentObject(messageCenter)
|
||||||
.environmentObject(themeManager)
|
.environmentObject(themeManager)
|
||||||
.preferredColorScheme(themeManager.theme.colorScheme)
|
.preferredColorScheme(themeManager.theme.colorScheme)
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateChecker.checkForUpdatesIfNeeded()
|
|
||||||
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
|
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.userId) { newValue in
|
.onChange(of: viewModel.userId) { newValue in
|
||||||
|
|||||||