Compare commits

..

112 Commits

Author SHA1 Message Date
4ee1e20527 fix legacy notice 2025-12-04 02:55:00 +03:00
98ea7bcf02 login patch 2025-12-04 02:41:45 +03:00
0a162a5b2d patch login 2025-12-04 02:17:23 +03:00
0311e0f5b1 update 2025-12-04 02:14:34 +03:00
0617d1bd9c 9 2025-12-04 01:04:39 +03:00
e9b43e76fa fix contact 2025-12-03 23:10:36 +03:00
e44d56e71b push disable in app 2025-12-03 23:00:17 +03:00
71fb0551fe update push 2025-12-03 22:40:44 +03:00
bc9f82b8fb update socket 2025-12-03 22:20:59 +03:00
1449e003de add 2fa auth 2025-12-03 21:49:17 +03:00
372dc92c8d update 2025-12-03 21:37:10 +03:00
d1612df43b edit login 2025-12-03 20:48:33 +03:00
cd67c350b4 patch 2025-12-03 09:05:16 +03:00
bbe6a8a3e4 patch 2025-12-03 09:01:01 +03:00
8712c7ea22 change name placeholder 2025-12-03 08:51:35 +03:00
608add0714 fix keyboard 2025-12-03 08:46:00 +03:00
79461616f5 fix login 2025-12-03 08:41:57 +03:00
1cec8aee3e login animation 2025-12-03 08:03:09 +03:00
c09858dfbd update tob bar 2025-12-03 08:00:29 +03:00
a9aa891f19 patch 2025-12-03 07:57:00 +03:00
a4102f7890 reset password 2025-12-03 07:51:31 +03:00
97951cc748 login view patch 2025-12-03 07:47:26 +03:00
1492afabd2 login update 2025-12-03 07:27:51 +03:00
50916b732a edit register 2025-12-03 07:17:31 +03:00
b8ffca967b patch 2025-12-03 07:11:36 +03:00
f9026ebf87 selector 2025-12-03 07:05:41 +03:00
92e4c30c7f new login screen 2025-12-03 06:46:41 +03:00
e269647d41 add push 2025-12-03 05:47:52 +03:00
0cbbf4777d patch 2025-10-26 07:09:05 +03:00
643466d878 add localization 2025-10-26 03:48:24 +03:00
f14ff3293d add feedback changes 2025-10-26 03:34:37 +03:00
f22bce0e74 edit feedback 2025-10-26 03:25:44 +03:00
7a10ba5b33 patch first message after register 2025-10-26 03:19:54 +03:00
8568f6c20e fix pagination load 2025-10-26 02:57:47 +03:00
526a57b556 patch error 2025-10-26 02:52:16 +03:00
7f73216936 add msg 2025-10-26 02:49:59 +03:00
128ed5723a edit loading 2025-10-26 02:47:58 +03:00
0a7d519567 add error msg 2025-10-26 02:39:55 +03:00
9f6beecb49 patch 2025-10-26 02:34:28 +03:00
3e9d6696b0 patch 2025-10-26 01:53:21 +03:00
3f0543aa3a add new model to blacklist 2025-10-26 01:46:31 +03:00
052ff5fe4f add error 422 to reg 2025-10-24 21:49:32 +03:00
3c394446d2 add afterregister 2025-10-24 21:23:53 +03:00
9f2a938b1e add buttons 2025-10-24 21:19:49 +03:00
7a2fb798a3 delete old open settings 2025-10-24 21:15:21 +03:00
b46fc3ae16 add afterregister sheet 2025-10-24 21:05:11 +03:00
b466864350 add auto open security 2025-10-24 11:58:09 +03:00
107318ef21 patch security setting 2025-10-24 11:43:28 +03:00
e3cf374893 add secureview 2025-10-24 11:13:47 +03:00
6eed966fc9 add 2fa 2025-10-24 11:03:27 +03:00
58c841b5c7 add desription to ismessangermode 2025-10-24 10:44:14 +03:00
854561b5f7 add delete solo session 2025-10-24 10:35:44 +03:00
020aa8de5d change position to revoke all session 2025-10-24 10:29:20 +03:00
be6394f6fb add confirm revoke 2025-10-24 10:20:20 +03:00
cf5d2ad7fb patct sessions list 2025-10-24 10:17:15 +03:00
26534e88c1 add session list 2025-10-24 10:06:26 +03:00
7034503983 change loading circle 2025-10-24 00:21:39 +03:00
dd2abde5b8 disable burger menu in msg mode 2025-10-23 23:55:19 +03:00
e135556fa6 add context menu in contacts list 2025-10-23 23:46:19 +03:00
910eef3703 edit view contact list 2025-10-23 23:22:52 +03:00
e6d7258b70 edit view contacts 2025-10-23 23:18:11 +03:00
374bd1713b edit view contacts 2025-10-23 23:15:22 +03:00
aac0a25c4d add qr screen 2025-10-23 23:04:09 +03:00
e79cbd7ea4 add contact list 2025-10-23 22:49:19 +03:00
813795aece disable refresh 2025-10-23 22:39:32 +03:00
2eabbd59c3 add delete user from blacklist 2025-10-23 22:37:23 +03:00
43a5d8193d add confirm while delete 2025-10-23 22:29:23 +03:00
6b81860960 add blocked user list 2025-10-23 22:23:20 +03:00
8acacdb8c1 add blocked user 2025-10-23 22:08:00 +03:00
1c9f249289 scroll to top while tap tap 2025-10-23 21:49:31 +03:00
198b51bd91 edit padding 2025-10-23 21:12:11 +03:00
40a5f4c628 edit login screen 2025-10-23 21:05:18 +03:00
d692c7c984 add messenger mod 2025-10-23 20:50:44 +03:00
3ae7576c24 add other settings 2025-10-23 20:19:57 +03:00
52cf7e3b1c patch terms flag 2025-10-23 19:22:40 +03:00
85fb780c96 patch terms 2025-10-23 19:20:04 +03:00
a28402136d patch terms 2025-10-23 19:12:21 +03:00
140e82e122 add terms 2025-10-23 19:04:25 +03:00
726a6983b2 add notification legacy support ios 15 2025-10-23 18:48:52 +03:00
2f8c1f3514 add limit lines 2025-10-23 18:41:14 +03:00
fa1637a5af fix warning 2025-10-23 18:35:52 +03:00
b47922694d add legacy ios 15 2025-10-23 18:30:41 +03:00
adba8fc568 add ios 15 2025-10-23 04:07:54 +03:00
2000ddadc2 edit msg 2025-10-23 04:01:48 +03:00
9e95f9c9d9 edit msg 2025-10-23 03:58:35 +03:00
9460024734 edit view msg 2025-10-23 02:14:36 +03:00
b0888c2921 fix position 2025-10-22 06:39:29 +03:00
61e1feb8bd change scroll pos 2025-10-22 06:33:06 +03:00
de2d7c4020 change padding 2025-10-22 06:22:46 +03:00
91a3117595 fix scroll down 2025-10-22 06:20:41 +03:00
5f03feba66 fix pos 2025-10-22 06:14:18 +03:00
b267e5a999 add button scroll down 2025-10-22 06:11:19 +03:00
b9ea0807e5 scroll down 2025-10-22 06:03:10 +03:00
055c57c208 disable keyboard 2025-10-22 05:59:34 +03:00
bbed505033 patch send msg 2025-10-22 05:49:19 +03:00
ee4f783fe7 fix keyboard 2025-10-22 05:41:51 +03:00
93c865f5ca fix pos buttom 2025-10-22 05:38:58 +03:00
2d3299fe96 fix 2025-10-22 05:32:55 +03:00
2c31d25596 fix size 2025-10-22 05:31:01 +03:00
c6e17f0fc5 edit padding 2025-10-22 05:25:20 +03:00
9685674056 edit padding 2025-10-22 05:18:58 +03:00
c1e39128fb edit spacing 2025-10-22 05:16:21 +03:00
331ec94ede fix pos 2025-10-22 05:12:06 +03:00
aa3e619d37 fix pos 2025-10-22 05:10:22 +03:00
4442a40aac fix ico pos 2025-10-22 05:07:28 +03:00
5e419e8b0f testflight build 8 2025-10-22 04:57:49 +03:00
67125b230f new chat view 2025-10-22 04:43:38 +03:00
44f7336c8d пупупу 2025-10-22 03:59:48 +03:00
266742e15d privatechatview patch 2025-10-22 03:51:34 +03:00
6d8b322688 fix open in settings 2025-10-22 03:44:25 +03:00
edbf4faf00 delete error 2025-10-22 02:46:21 +03:00
1bc4dda14c cfg change 2025-10-21 21:03:11 +03:00
79 changed files with 5773 additions and 490 deletions

View File

@ -7,6 +7,8 @@
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 */
@ -33,9 +35,22 @@
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>";
}; };
@ -56,6 +71,8 @@
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;
@ -111,6 +128,8 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */,
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
1A6D61CE2E7CD03E00B9F736 /* yobble */, 1A6D61CE2E7CD03E00B9F736 /* yobble */,
@ -118,6 +137,8 @@
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 */;
@ -204,6 +225,7 @@
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 */;
@ -266,6 +288,14 @@
/* 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 */;
@ -404,11 +434,12 @@
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 = 7; CURRENT_PROJECT_VERSION = 9;
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;
@ -420,7 +451,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 = 16; IPHONEOS_DEPLOYMENT_TARGET = 15;
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;
@ -444,11 +475,12 @@
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 = 7; CURRENT_PROJECT_VERSION = 9;
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;
@ -460,7 +492,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 = 16; IPHONEOS_DEPLOYMENT_TARGET = 15;
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;
@ -609,6 +641,14 @@
/* 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";
@ -620,6 +660,26 @@
/* 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" */;

View File

@ -1,6 +1,123 @@
{ {
"originHash" : "c9fb241c5f575df8f20b39649006995779013948e60c51c3f85b729f83b054e7", "originHash" : "600d4311db652d123053aed731b25dc6c4fe63e106bb9043d105bb4fedf8b79b",
"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",
@ -18,6 +135,15 @@
"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

64
yobble/AppDelegate.swift Normal file
View File

@ -0,0 +1,64 @@
//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)
}
}

View File

@ -3,6 +3,7 @@ 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?
@ -10,6 +11,7 @@ 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
@ -17,15 +19,23 @@ struct TopBarView: View {
@Binding var chatSearchText: String @Binding var chatSearchText: String
var isHomeTab: Bool { var isHomeTab: Bool {
return title == "Home" return title == NSLocalizedString("Home", comment: "")
} }
var isChatsTab: Bool { var isChatsTab: Bool {
return title == "Chats" return title == NSLocalizedString("Чаты", comment: "")
} }
var isProfileTab: Bool { var isProfileTab: Bool {
return title == "Profile" return title == NSLocalizedString("Profile", comment: "")
}
var isContactsTab: Bool {
return title == NSLocalizedString("Контакты", comment: "")
}
var isSettingsTab: Bool {
return title == NSLocalizedString("Настройки", comment: "")
} }
private var statusMessage: String? { private var statusMessage: String? {
@ -41,20 +51,22 @@ struct TopBarView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
// Кнопка "Гамбургер" для открытия меню
Button(action: {
withAnimation {
isSideMenuPresented.toggle()
}
}) {
Image(systemName: "line.horizontal.3")
.imageScale(.large)
.foregroundColor(.primary)
}
if !isMessengerModeEnabled{
// Кнопка "Гамбургер" для открытия меню
Button(action: {
withAnimation {
isSideMenuPresented.toggle()
}
}) {
Image(systemName: "line.horizontal.3")
.imageScale(.large)
.foregroundColor(.primary)
}
}
// Spacer() // Spacer()
if let statusMessage { if let statusMessage, !isContactsTab, !isSettingsTab {
connectionStatusView(message: statusMessage) connectionStatusView(message: statusMessage)
Spacer() Spacer()
} else if isHomeTab{ } else if isHomeTab{
@ -109,6 +121,14 @@ 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 {
@ -217,17 +237,20 @@ 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,
) )
} }
} }

View File

@ -0,0 +1,30 @@
<?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>

12
yobble/Info.plist Normal file
View File

@ -0,0 +1,12 @@
<?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>

View File

@ -29,3 +29,16 @@ 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]
}

View File

@ -44,8 +44,9 @@ final class AuthService {
} }
NetworkClient.shared.request( NetworkClient.shared.request(
path: "/v1/auth/login", path: "/v1/auth/login/password",
method: .post, method: .post,
headers: ["X-Client-Type": "ios"],
body: body, body: body,
requiresAuth: false requiresAuth: false
) { result in ) { result in
@ -83,6 +84,91 @@ 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 {
@ -229,11 +315,24 @@ 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:
@ -248,6 +347,70 @@ 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") {
@ -387,6 +550,15 @@ 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

View File

@ -0,0 +1,231 @@
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)
}
}
}
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
}

View File

@ -0,0 +1,182 @@
import Foundation
enum ContactsServiceError: 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: "Contacts service decoding error")
}
}
}
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
}
final class ContactsService {
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 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)
}
}
}
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
}()
}

View File

@ -0,0 +1,309 @@
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
}()
}

View File

@ -0,0 +1,55 @@
////
//// 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])
// }
//}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 B

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,15 @@
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?
@ -32,7 +38,13 @@ 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)
presentedChat = chatItem if AppConfig.PRESENT_CHAT_AS_SHEET {
presentedChat = chatItem
pendingNavigation = nil
} else {
pendingNavigation = ChatNavigationTarget(chat: chatItem)
presentedChat = nil
}
dismissBanner() dismissBanner()
} }
@ -45,7 +57,8 @@ final class IncomingMessageCenter: ObservableObject {
return return
} }
if let presentedChat, if AppConfig.PRESENT_CHAT_AS_SHEET,
let presentedChat,
presentedChat.chatId == message.chatId { presentedChat.chatId == message.chatId {
return return
} }

View File

@ -0,0 +1,155 @@
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
}
}

View File

@ -340,17 +340,7 @@ 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 }
let messageData: Data guard let messageData = normalizeMessagePayload(payload) else { return }
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
@ -373,6 +363,31 @@ 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

View File

@ -12,22 +12,61 @@ class LoginViewModel: ObservableObject {
@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 = true // сразу true, чтобы показать спиннер при автологине @Published var isLoading: Bool = false
@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"
@ -43,6 +82,10 @@ 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)
@ -91,6 +134,7 @@ class LoginViewModel: ObservableObject {
self?.socketService.disconnect() self?.socketService.disconnect()
} }
self?.isLoading = false self?.isLoading = false
self?.isInitialLoading = false
} }
} }
} }
@ -99,8 +143,13 @@ 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: username, password: password) { [weak self] success, error in authService.login(username: trimmedLogin, password: password) { [weak self] success, error in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.isLoading = false self?.isLoading = false
if success { if success {
@ -116,6 +165,94 @@ 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 {
@ -123,6 +260,7 @@ 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()
} }
@ -169,4 +307,121 @@ 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
}
} }

View File

@ -1,12 +1,23 @@
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct PrivateChatView: View { struct PrivateChatView: View {
let chat: PrivateChatListItem let chat: PrivateChatListItem
let currentUserId: String? let currentUserId: String?
private let bottomAnchorId = "PrivateChatBottomAnchor"
let lineLimitInChat = 6
@StateObject private var viewModel: PrivateChatViewModel @StateObject private var viewModel: PrivateChatViewModel
@State private var hasPositionedToBottom: Bool = false @State private var hasPositionedToBottom: Bool = false
@State private var scrollToBottomTrigger: UUID = .init()
@State private var isBottomAnchorVisible: Bool = true
@State private var draftText: String = "" @State private var draftText: String = ""
@State private var inputTab: ComposerTab = .chat
@State private var isVideoPreferred: Bool = false
@State private var legacyComposerHeight: CGFloat = 40
@FocusState private var isComposerFocused: Bool @FocusState private var isComposerFocused: Bool
@EnvironmentObject private var messageCenter: IncomingMessageCenter @EnvironmentObject private var messageCenter: IncomingMessageCenter
@ -18,17 +29,22 @@ struct PrivateChatView: View {
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
content ZStack(alignment: .bottomTrailing) {
.onChange(of: viewModel.messages.count) { _ in content
guard !viewModel.isLoadingMore, .onChange(of: viewModel.messages.count) { _ in
let lastId = viewModel.messages.last?.id else { return } guard !viewModel.isLoadingMore else { return }
DispatchQueue.main.async { scrollToBottom(proxy: proxy)
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(lastId, anchor: .bottom)
}
hasPositionedToBottom = true
} }
.onChange(of: scrollToBottomTrigger) { _ in
scrollToBottom(proxy: proxy)
}
if !isBottomAnchorVisible {
scrollToBottomButton(proxy: proxy)
.padding(.trailing, 12)
.padding(.bottom, 4)
} }
}
} }
.navigationTitle(title) .navigationTitle(title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -83,9 +99,21 @@ struct PrivateChatView: View {
!viewModel.messages.isEmpty { !viewModel.messages.isEmpty {
errorBanner(message: message) errorBanner(message: message)
} }
Color.clear
.frame(height: 1)
.id(bottomAnchorId)
.onAppear { isBottomAnchorVisible = true }
.onDisappear { isBottomAnchorVisible = false }
} }
.padding(.vertical, 12) .padding(.vertical, 12)
} }
.simultaneousGesture(
DragGesture().onChanged { value in
guard value.translation.height > 0 else { return }
isComposerFocused = false
}
)
.refreshable { .refreshable {
viewModel.refresh() viewModel.refresh()
} }
@ -128,7 +156,7 @@ struct PrivateChatView: View {
private func messageRow(for message: MessageItem) -> some View { private func messageRow(for message: MessageItem) -> some View {
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
return HStack { return HStack(alignment: .bottom, spacing: 12) {
if isCurrentUser { Spacer(minLength: 32) } if isCurrentUser { Spacer(minLength: 32) }
VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 6) { VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 6) {
@ -138,25 +166,34 @@ struct PrivateChatView: View {
// .foregroundColor(.secondary) // .foregroundColor(.secondary)
// } // }
Text(contentText(for: message)) HStack(alignment: .bottom) {
.font(.body) Text(contentText(for: message))
.foregroundColor(isCurrentUser ? .white : .primary) .font(.body)
.frame(maxWidth: .infinity, alignment: isCurrentUser ? .trailing : .leading) .foregroundColor(isCurrentUser ? .white : .primary)
.multilineTextAlignment(.leading)
Text(timestamp(for: message)) Text(timestamp(for: message))
.font(.caption2) .font(.caption2)
.foregroundColor(isCurrentUser ? Color.white.opacity(0.8) : .secondary) .foregroundColor(isCurrentUser ? Color.white.opacity(0.8) : .secondary)
}
} }
.padding(.vertical, 10) .padding(.vertical, 10)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground)) .background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading)
.fixedSize(horizontal: false, vertical: true)
if !isCurrentUser { Spacer(minLength: 32) } if !isCurrentUser { Spacer(minLength: 32) }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
private var messageBubbleMaxWidth: CGFloat {
min(UIScreen.main.bounds.width * 0.72, 360)
}
private func senderName(for message: MessageItem) -> String { private func senderName(for message: MessageItem) -> String {
if let full = message.senderData?.fullName, !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let full = message.senderData?.fullName, !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return full return full
@ -200,47 +237,215 @@ struct PrivateChatView: View {
} }
private var composer: some View { private var composer: some View {
VStack(spacing: 0) { VStack(spacing: 10) {
Divider() HStack(alignment: .bottom, spacing: 4) {
Button(action: { }) { // переключатель на стикеры
HStack(alignment: .center, spacing: 12) { Image(systemName: "paperclip")
TextField(NSLocalizedString("Сообщение", comment: ""), text: $draftText, axis: .vertical)
.lineLimit(1...4)
.focused($isComposerFocused)
.submitLabel(.send)
.disabled(viewModel.isSending || currentUserId == nil)
.onSubmit { sendCurrentMessage() }
Button(action: sendCurrentMessage) {
Image(systemName: viewModel.isSending ? "hourglass" : "paperplane.fill")
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
// .buttonStyle(ComposerIconButtonStyle())
.frame(width: 36, height: 36)
ZStack(alignment: .bottomTrailing) {
Group {
if #available(iOS 16.0, *) {
TextField(inputTab.placeholder, text: $draftText, axis: .vertical)
.lineLimit(1...lineLimitInChat)
.focused($isComposerFocused)
.submitLabel(.send)
.disabled(currentUserId == nil)
.onSubmit { sendCurrentMessage() }
} else {
LegacyMultilineTextView(
text: $draftText,
placeholder: inputTab.placeholder,
isFocused: Binding(
get: { isComposerFocused },
set: { isComposerFocused = $0 }
),
isEnabled: currentUserId != nil,
minHeight: 10,
maxLines: lineLimitInChat,
calculatedHeight: $legacyComposerHeight,
onSubmit: sendCurrentMessage
)
.frame(height: legacyComposerHeight)
}
}
.padding(.top, 10)
.padding(.leading, 12)
.padding(.trailing, 44)
.padding(.bottom, 10)
.frame(maxWidth: .infinity, minHeight: 40, alignment: .bottomLeading)
Button(action: { }) { // переключатель на стикеры
Image(systemName: "face.smiling")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
.padding(.trailing, 12)
.padding(.bottom, 10)
}
.frame(minHeight: 40, alignment: .bottom)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.alignmentGuide(.bottom) { dimension in
dimension[VerticalAlignment.bottom] - 2
}
if !isSendAvailable {
Button(action: { isVideoPreferred.toggle() }) {
Image(systemName: isVideoPreferred ? "video.fill" : "mic.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
// .buttonStyle(ComposerIconButtonStyle())
.frame(width: 36, height: 36)
} else {
sendButton
} }
.disabled(isSendDisabled)
.buttonStyle(.plain)
} }
.padding(.vertical, 10)
.padding(.horizontal, 16)
.background(.clear)
} }
.padding(.horizontal, 6)
.padding(.top, 10)
.padding(.bottom, 8)
.background(.ultraThinMaterial) .background(.ultraThinMaterial)
} }
private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View {
Button {
scrollToBottom(proxy: proxy)
} label: {
Image(systemName: "arrow.down")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.frame(width: 44, height: 44)
// .background(Color.accentColor)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
// .overlay(
// Circle().stroke(Color.white.opacity(0.35), lineWidth: 1)
// )
}
.buttonStyle(.plain)
.shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2)
}
private var isSendDisabled: Bool { private var isSendDisabled: Bool {
draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending || currentUserId == nil draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || currentUserId == nil
}
private var isSendAvailable: Bool {
!draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && currentUserId != nil
}
private var sendButton: some View {
Button(action: sendCurrentMessage) {
Image(systemName: "leaf.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.white.opacity(isSendDisabled ? 0.6 : 1))
.frame(width: 36, height: 36)
.background(isSendDisabled ? Color.accentColor.opacity(0.4) : Color.accentColor)
.clipShape(Circle())
}
.disabled(isSendDisabled)
.buttonStyle(.plain)
}
// private func composerToolbarButton(systemName: String, action: @escaping () -> Void) -> some View {
// Button(action: action) {
// Image(systemName: systemName)
// .font(.system(size: 16, weight: .medium))
// }
private func composerModeButton(_ tab: ComposerTab) -> some View {
Button(action: { inputTab = tab }) {
Text(tab.title)
.font(.caption)
.fontWeight(inputTab == tab ? .semibold : .regular)
.padding(.vertical, 8)
.padding(.horizontal, 14)
.background(
Group {
if inputTab == tab {
Color.accentColor.opacity(0.15)
} else {
Color(.secondarySystemBackground)
}
}
)
.foregroundColor(inputTab == tab ? .accentColor : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
} }
private func sendCurrentMessage() { private func sendCurrentMessage() {
let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines) let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
draftText = ""
scrollToBottomTrigger = .init()
viewModel.sendMessage(text: text) { success in viewModel.sendMessage(text: text) { success in
if success { if success {
draftText = ""
hasPositionedToBottom = true hasPositionedToBottom = true
} }
} }
} }
private func scrollToBottom(proxy: ScrollViewProxy) {
hasPositionedToBottom = true
let targetId = viewModel.messages.last?.id ?? bottomAnchorId
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(targetId, anchor: .bottom)
}
}
}
private struct ComposerIconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: 36, height: 36)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
.overlay(
Circle().stroke(Color.secondary.opacity(0.15))
)
.scaleEffect(configuration.isPressed ? 0.94 : 1)
.opacity(configuration.isPressed ? 0.75 : 1)
}
}
private enum ComposerTab: String {
case chat
case stickers
var title: String {
switch self {
case .chat: return NSLocalizedString("Чат", comment: "")
case .stickers: return NSLocalizedString("Стикеры", comment: "")
}
}
var iconName: String {
switch self {
case .chat: return "text.bubble"
case .stickers: return "face.smiling"
}
}
var placeholder: String {
switch self {
case .chat: return NSLocalizedString("Сообщение", comment: "")
case .stickers: return NSLocalizedString("Поиск стикеров", comment: "")
}
}
}
private static let timeFormatter: DateFormatter = { private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateStyle = .none formatter.dateStyle = .none
@ -262,6 +467,171 @@ struct PrivateChatView: View {
} }
} }
#if canImport(UIKit)
private struct LegacyMultilineTextView: UIViewRepresentable {
@Binding var text: String
var placeholder: String
@Binding var isFocused: Bool
var isEnabled: Bool
var minHeight: CGFloat
var maxLines: Int
@Binding var calculatedHeight: CGFloat
var onSubmit: (() -> Void)?
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = false
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.text = text
textView.returnKeyType = .send
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let placeholderLabel = context.coordinator.placeholderLabel
placeholderLabel.text = placeholder
placeholderLabel.font = textView.font
placeholderLabel.textColor = UIColor.secondaryLabel
placeholderLabel.numberOfLines = 1
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
textView.addSubview(placeholderLabel)
NSLayoutConstraint.activate([
placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor),
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: textView.trailingAnchor)
])
context.coordinator.updatePlaceholderVisibility(for: textView)
DispatchQueue.main.async {
Self.recalculateHeight(
for: textView,
result: calculatedHeightBinding,
minHeight: minHeight,
maxLines: maxLines
)
}
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
context.coordinator.parent = self
if uiView.text != text {
uiView.text = text
}
uiView.isEditable = isEnabled
uiView.isSelectable = isEnabled
uiView.textColor = isEnabled ? UIColor.label : UIColor.secondaryLabel
let placeholderLabel = context.coordinator.placeholderLabel
if placeholderLabel.text != placeholder {
placeholderLabel.text = placeholder
}
placeholderLabel.font = uiView.font
context.coordinator.updatePlaceholderVisibility(for: uiView)
if isFocused && !uiView.isFirstResponder {
uiView.becomeFirstResponder()
} else if !isFocused && uiView.isFirstResponder {
uiView.resignFirstResponder()
}
Self.recalculateHeight(
for: uiView,
result: calculatedHeightBinding,
minHeight: minHeight,
maxLines: maxLines
)
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
private var calculatedHeightBinding: Binding<CGFloat> {
Binding(get: { calculatedHeight }, set: { calculatedHeight = $0 })
}
private static func recalculateHeight(for textView: UITextView, result: Binding<CGFloat>, minHeight: CGFloat, maxLines: Int) {
let width = textView.bounds.width
guard width > 0 else {
DispatchQueue.main.async {
recalculateHeight(for: textView, result: result, minHeight: minHeight, maxLines: maxLines)
}
return
}
let fittingSize = CGSize(width: width, height: .greatestFiniteMagnitude)
let targetSize = textView.sizeThatFits(fittingSize)
let lineHeight = textView.font?.lineHeight ?? UIFont.preferredFont(forTextStyle: .body).lineHeight
let maxHeight = minHeight + lineHeight * CGFloat(max(maxLines - 1, 0))
let clampedHeight = min(max(targetSize.height, minHeight), maxHeight)
let shouldScroll = targetSize.height > maxHeight + 0.5
if abs(result.wrappedValue - clampedHeight) > 0.5 {
let newHeight = clampedHeight
DispatchQueue.main.async {
if abs(result.wrappedValue - newHeight) > 0.5 {
result.wrappedValue = newHeight
}
}
}
textView.isScrollEnabled = shouldScroll
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: LegacyMultilineTextView
let placeholderLabel = UILabel()
init(parent: LegacyMultilineTextView) {
self.parent = parent
}
func textViewDidBeginEditing(_ textView: UITextView) {
if !parent.isFocused {
parent.isFocused = true
}
}
func textViewDidEndEditing(_ textView: UITextView) {
if parent.isFocused {
parent.isFocused = false
}
}
func textViewDidChange(_ textView: UITextView) {
if parent.text != textView.text {
parent.text = textView.text
}
updatePlaceholderVisibility(for: textView)
LegacyMultilineTextView.recalculateHeight(for: textView, result: parent.calculatedHeightBinding, minHeight: parent.minHeight, maxLines: parent.maxLines)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if text == "\n" {
if let onSubmit = parent.onSubmit {
DispatchQueue.main.async {
onSubmit()
}
return false
}
}
return true
}
func updatePlaceholderVisibility(for textView: UITextView) {
placeholderLabel.isHidden = !textView.text.isEmpty
}
}
}
#endif
// MARK: - Preview // MARK: - Preview
// Previews intentionally omitted - MessageItem has custom decoding-only initializer. // Previews intentionally omitted - MessageItem has custom decoding-only initializer.

View File

@ -0,0 +1,75 @@
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)
}
}

View File

@ -9,11 +9,124 @@ 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: 16, 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 isShowingRegistration = false @State private var isShowingTerms = 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 {
@ -22,93 +135,91 @@ struct LoginView: View {
} }
private var isUsernameValid: Bool { private var isUsernameValid: Bool {
let pattern = "^[A-Za-z0-9_]{3,32}$" LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
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)
ZStack { Button {
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 { VStack(alignment: .leading, spacing: 8) {
HStack { Text(NSLocalizedString("Вход по паролю", comment: ""))
.font(.largeTitle).bold()
// Text(NSLocalizedString("Если предпочитаете классический вход, используйте логин и пароль.", comment: ""))
// .foregroundColor(.secondary)
}
Button(action: openLanguageSettings) { VStack(alignment: .leading, spacing: 12) {
Text("🌍") HStack(spacing: 8) {
.padding() Text("@")
.foregroundColor(.secondary)
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($focusedField, equals: .username)
} }
Spacer() .padding()
Menu { .background(Color(.secondarySystemBackground))
ForEach(themeOptions) { option in .cornerRadius(12)
Button(action: { selectTheme(option) }) {
themeMenuContent(for: option) if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
.opacity(option.isEnabled ? 1.0 : 0.5) Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
.foregroundColor(.red)
.font(.caption)
}
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))
} }
.disabled(!option.isEnabled)
} }
} label: {
Image(systemName: themeIconName) if !isPasswordValid && !viewModel.password.isEmpty {
.padding() Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
.foregroundColor(.red)
.font(.caption)
} }
} }
.onTapGesture {
focusedField = nil
}
Spacer() // VStack(alignment: .leading, spacing: 4) {
// Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username) // .toggleStyle(SwitchToggleStyle(tint: .accentColor))
.padding() // Text(isMessengerModeEnabled
.background(Color(.secondarySystemBackground)) // ? "Мессенджер-режим сейчас проработан примерно на 60%."
.cornerRadius(8) // : "Основной режим находится в ранней разработке (около 10%).")
.autocapitalization(.none) // .font(.footnote)
.disableAutocorrection(true) // .foregroundColor(.secondary)
.focused($focusedField, equals: .username) // }
.onChange(of: viewModel.username) { newValue in
if newValue.count > 32 {
viewModel.username = String(newValue.prefix(32))
}
}
// Показываем ошибку для логина
if !isUsernameValid && !viewModel.username.isEmpty {
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
.foregroundColor(.red)
.font(.caption)
}
// Показываем поле пароля
SecureField(NSLocalizedString("Пароль", comment: ""), text: $viewModel.password)
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(8)
.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)
}
var isButtonEnabled: Bool {
!viewModel.isLoading && isUsernameValid && isPasswordValid
}
Button(action: { Button(action: {
viewModel.login() viewModel.login()
@ -116,48 +227,70 @@ struct LoginView: View {
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle()) .progressViewStyle(CircularProgressViewStyle())
.padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.6)) .padding()
.cornerRadius(8)
} else { } else {
Text(NSLocalizedString("Войти", comment: "")) Text(NSLocalizedString("Войти", comment: ""))
.foregroundColor(.white) .foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(isButtonEnabled ? Color.blue : Color.gray) .padding()
.cornerRadius(8)
} }
} }
.disabled(!isButtonEnabled) .background(isLoginButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(12)
.disabled(!isLoginButtonEnabled)
// Spacer()
// Кнопка регистрации
Button(action: { Button(action: {
isShowingRegistration = true isShowingForgotPassword = true
}) { }) {
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация")) Text(NSLocalizedString("Забыли пароль? Сбросить", comment: ""))
.foregroundColor(.blue) .foregroundColor(.blue)
.frame(maxWidth: .infinity)
} }
.padding(.top, 10) .padding(.top, 4)
.sheet(isPresented: $isShowingRegistration) {
RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration) Spacer(minLength: 0)
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
focusedField = nil
}
.loginErrorAlert(viewModel: viewModel)
.onAppear {
if !hasResetTermsOnAppear {
viewModel.hasAcceptedTerms = false
hasResetTermsOnAppear = true
}
}
.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()
} }
Spacer()
} }
.padding() }
.alert(isPresented: $viewModel.showError) { .sheet(isPresented: $isShowingForgotPassword) {
Alert( ForgotPasswordInfoView {
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")), isShowingForgotPassword = false
message: Text(viewModel.errorMessage), withAnimation {
dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) viewModel.showPasswordlessRequest()
) }
} } onDismiss: {
.onTapGesture { isShowingForgotPassword = false
focusedField = nil
} }
} }
} }
@ -172,6 +305,11 @@ struct LoginView: 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)
@ -205,10 +343,542 @@ struct LoginView: 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 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 16+.")
.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)
}
}
}

View File

@ -9,10 +9,8 @@ import SwiftUI
struct RegistrationView: View { struct RegistrationView: View {
@ObservedObject var viewModel: LoginViewModel @ObservedObject var viewModel: LoginViewModel
@Binding var isPresented: Bool let onShowModePrompt: (() -> Void)?
@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 = ""
@ -20,6 +18,7 @@ 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?
@ -32,7 +31,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 username.range(of: pattern, options: .regularExpression) != nil return viewModel.passwordlessLogin.range(of: pattern, options: .regularExpression) != nil
} }
private var isPasswordValid: Bool { private var isPasswordValid: Bool {
@ -44,144 +43,157 @@ struct RegistrationView: View {
} }
private var isFormValid: Bool { private var isFormValid: Bool {
isUsernameValid && isPasswordValid && isConfirmPasswordValid isUsernameValid && isPasswordValid && isConfirmPasswordValid && viewModel.hasAcceptedTerms
}
init(viewModel: LoginViewModel, onShowModePrompt: (() -> Void)? = nil) {
self._viewModel = ObservedObject(initialValue: viewModel)
self.onShowModePrompt = onShowModePrompt
} }
var body: some View { var body: some View {
NavigationView { ScrollView(showsIndicators: false) {
ScrollView { VStack(alignment: .leading, spacing: 24) {
ZStack(alignment: .top) { LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: keyboardDismissingModePrompt)
Color.clear
.contentShape(Rectangle())
.onTapGesture { focusedField = nil }
VStack(alignment: .leading, spacing: 16) { Button(action: goBack) {
Group { HStack(spacing: 6) {
HStack { Image(systemName: "arrow.left")
TextField(NSLocalizedString("Логин", comment: "Логин"), text: $username) Text(NSLocalizedString("Назад", comment: ""))
.autocapitalization(.none) }
.disableAutocorrection(true) .font(.footnote)
.focused($focusedField, equals: .username) .foregroundColor(.blue)
Spacer() }
if !username.isEmpty {
Image(systemName: isUsernameValid ? "checkmark.circle" : "xmark.circle") VStack(alignment: .leading, spacing: 8) {
.foregroundColor(isUsernameValid ? .green : .red) Text(NSLocalizedString("Регистрация", comment: "Регистрация"))
} .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(8) .cornerRadius(12)
.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: "Пароль должен быть от 6 до 32 символов")) Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: ""))
.foregroundColor(.red) .foregroundColor(.red)
.font(.caption) .font(.caption)
} }
}
HStack { VStack(alignment: .leading, spacing: 4) {
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(8) .cornerRadius(12)
.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)
} }
}
Button(action: registerUser) { TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: ""), text: $inviteCode)
if isLoading { .autocapitalization(.none)
ProgressView() .disableAutocorrection(true)
.padding() .focused($focusedField, equals: .invite)
.frame(maxWidth: .infinity) .padding()
.background(Color.gray.opacity(0.6)) .background(Color(.secondarySystemBackground))
.cornerRadius(8) .cornerRadius(12)
} 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()
} }
}
.navigationTitle(Text(NSLocalizedString("Регистрация", comment: "Регистрация"))) TermsAgreementCard(
.toolbar { isAccepted: $viewModel.hasAcceptedTerms,
ToolbarItem(placement: .navigationBarTrailing) { openTerms: {
Button(action: dismissSheet) { viewModel.loadTermsIfNeeded()
Text(NSLocalizedString("Закрыть", comment: "Закрыть")) isShowingTerms = true
} }
}
}
.alert(isPresented: $showError) {
Alert(
title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")),
message: Text(errorMessage),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
) )
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)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.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()
}
} }
} }
} }
@ -189,10 +201,12 @@ struct RegistrationView: View {
private func registerUser() { private func registerUser() {
isLoading = true isLoading = true
errorMessage = "" errorMessage = ""
viewModel.registerUser(username: username, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in let trimmedLogin = viewModel.passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
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 {
dismissSheet() viewModel.hasAcceptedTerms = false
} else { } else {
errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "") errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "")
showError = true showError = true
@ -200,10 +214,25 @@ struct RegistrationView: View {
} }
} }
private func dismissSheet() { private func goBack() {
focusedField = nil focusedField = nil
isPresented = false viewModel.hasAcceptedTerms = false
presentationMode.wrappedValue.dismiss() withAnimation {
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)
} }
} }
@ -212,6 +241,7 @@ 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 // чтобы убрать спиннер
return RegistrationView(viewModel: viewModel, isPresented: .constant(true)) viewModel.isInitialLoading = false
return RegistrationView(viewModel: viewModel, onShowModePrompt: nil)
} }
} }

View File

@ -0,0 +1,102 @@
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())
}
}

View File

@ -0,0 +1,72 @@
//
// 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))
}
}

View File

@ -32,6 +32,7 @@ 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
@ -101,16 +102,17 @@ 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 {
ZStack { ScrollViewReader { proxy in
List { ZStack {
List {
// VStack(spacing: 0) { // VStack(spacing: 0) {
// searchBar // searchBar
// .padding(.horizontal, 16) // .padding(.horizontal, 16)
@ -118,63 +120,74 @@ 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)
}
} }
.padding(.vertical, 4)
} }
.padding(.vertical, 4) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
.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)
}
}
} }
Section(header: globalSearchHeader) { if isSearching {
globalSearchContent Section(header: localSearchHeader) {
} if localSearchResults.isEmpty {
} else { emptySearchResultView
.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.chats.isEmpty { if viewModel.isInitialLoading && viewModel.chats.isEmpty {
emptyState loadingState
} else {
ForEach(viewModel.chats) { chat in
chatRowItem(for: chat)
} }
if viewModel.isLoadingMore { if viewModel.chats.isEmpty {
loadingMoreRow emptyState
} 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)
.listStyle(.plain) .modifier(ScrollDismissesKeyboardModifier())
.modifier(ScrollDismissesKeyboardModifier()) .simultaneousGesture(searchBarGesture)
.simultaneousGesture(searchBarGesture) .simultaneousGesture(tapToDismissKeyboardGesture)
.simultaneousGesture(tapToDismissKeyboardGesture) .onReceive(NotificationCenter.default.publisher(for: .chatsShouldScrollToTop)) { _ in
scrollChatsToTop(using: proxy)
}
// .safeAreaInset(edge: .top) { // .safeAreaInset(edge: .top) {
// VStack(spacing: 0) { // VStack(spacing: 0) {
// searchBar // searchBar
@ -186,7 +199,8 @@ struct ChatsTab: View {
// .background(Color(UIColor.systemBackground)) // .background(Color(UIColor.systemBackground))
// } // }
pendingChatNavigationLink pendingChatNavigationLink
}
} }
} }
@ -217,6 +231,14 @@ 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
@ -319,8 +341,10 @@ 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))
} }
} }
} }
@ -340,14 +364,26 @@ 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 {
VStack(spacing: 12) { HStack {
Spacer()
ProgressView() ProgressView()
Text(NSLocalizedString("Загружаем чаты…", comment: "")) .progressViewStyle(CircularProgressViewStyle())
.font(.subheadline) Spacer()
.foregroundColor(.secondary)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 18)
.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 {
@ -371,15 +407,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()
@ -442,7 +478,7 @@ struct ChatsTab: View {
} }
.hidden() .hidden()
) )
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
// .listRowSeparator(.hidden) // .listRowSeparator(.hidden)
.onAppear { .onAppear {
guard !isSearching else { return } guard !isSearching else { return }
@ -1181,4 +1217,5 @@ 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")
} }

View File

@ -0,0 +1,384 @@
import SwiftUI
import Foundation
struct ContactsTab: View {
@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
private let contactsService = ContactsService()
private let pageSize = 25
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 {
showContactPlaceholder(for: contact)
} label: {
ContactRow(contact: contact)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button {
handleContactAction(.edit, for: contact)
} label: {
Label(
NSLocalizedString("Изменить контакт", comment: "Contacts context action edit"),
systemImage: "square.and.pencil"
)
}
Button {
handleContactAction(.block, for: contact)
} label: {
Label(
NSLocalizedString("Заблокировать контакт", comment: "Contacts context action block"),
systemImage: "hand.raised.fill"
)
}
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 {
if index >= contacts.count - 5 {
Task {
await loadContacts(reset: false)
}
}
}
}
if isLoading && !contacts.isEmpty {
loadingState
} else if let pagingError, !contacts.isEmpty {
pagingErrorState(pagingError)
}
}
}
.background(Color(UIColor.systemBackground))
.listStyle(.plain)
.task {
await loadContacts(reset: false)
}
.refreshable {
await refreshContacts()
}
.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")))
)
}
}
}
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 showContactPlaceholder(for contact: Contact) {
activeAlert = .info(
title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"),
message: String(
format: NSLocalizedString("Просмотр \"%1$@\" появится позже.", comment: "Contacts placeholder message"),
contact.displayName
)
)
}
private func handleContactAction(_ action: ContactAction, for contact: Contact) {
activeAlert = .info(
title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"),
message: action.placeholderMessage(for: contact)
)
}
}
private struct ContactRow: View {
let contact: Contact
var body: some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 40, height: 40)
.overlay(
Text(contact.initials)
.font(.headline)
.foregroundColor(.accentColor)
)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(contact.displayName)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
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)
}
.padding(.vertical, 6)
}
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 initials: String {
let components = displayName.split(separator: " ")
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials
.map { String($0).uppercased() }
.joined()
}
let filtered = login.filter { $0.isLetter }.prefix(2)
if !filtered.isEmpty {
return filtered.uppercased()
}
return "??"
}
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
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 !payload.login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.handle = "@\(payload.login)"
} else {
self.handle = nil
}
}
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
func placeholderMessage(for contact: Contact) -> String {
switch self {
case .edit:
return String(
format: NSLocalizedString("Изменение контакта \"%1$@\" появится позже.", comment: "Contacts edit placeholder message"),
contact.displayName
)
case .block:
return String(
format: NSLocalizedString("Блокировка контакта \"%1$@\" появится позже.", comment: "Contacts block placeholder message"),
contact.displayName
)
case .delete:
return String(
format: NSLocalizedString("Удаление контакта \"%1$@\" появится позже.", comment: "Contacts delete placeholder message"),
contact.displayName
)
}
}
}

View File

@ -2,37 +2,48 @@ 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 {
// Tab 1: Feed if isMessengerModeEnabled {
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
selectedTab = 0
}
// Tab 2: Search TabBarButton(systemName: "person.2.fill", text: NSLocalizedString("Контакты", comment: ""), isSelected: selectedTab == 4) {
TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) { selectedTab = 4
selectedTab = 1 }
}
// Create Button TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
CreateButton { handleChatsTabTap()
onCreate() }
}
// Tab 3: Chats TabBarButton(systemName: "gearshape.fill", text: NSLocalizedString("Настройки", comment: ""), isSelected: selectedTab == 5) {
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) { selectedTab = 5
selectedTab = 2 }
} } else {
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
selectedTab = 0
}
// Tab 4: Profile TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) {
TabBarButton(systemName: "person.crop.square", text: NSLocalizedString("Лицо", comment: ""), isSelected: selectedTab == 3) { selectedTab = 1
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, 1) .padding(.top, isMessengerModeEnabled ? 6 : 1)
.padding(.bottom, 30) // Добавляем отступ снизу .padding(.bottom, 30) // Добавляем отступ снизу
// .background(Color(.systemGray6)) // .background(Color(.systemGray6))
} }
@ -82,3 +93,13 @@ 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
}
}
}

View File

@ -2,7 +2,9 @@ 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
@ -16,14 +18,21 @@ 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 "Home" case 0: return NSLocalizedString("Home", comment: "")
case 1: return "Concept" case 1: return NSLocalizedString("Concept", comment: "")
case 2: return "Chats" case 2: return NSLocalizedString("Чаты", comment: "")
case 3: return "Profile" case 3: return NSLocalizedString("Profile", comment: "")
default: return "Home" case 4: return NSLocalizedString("Контакты", comment: "")
case 5: return NSLocalizedString("Настройки", comment: "")
default: return NSLocalizedString("Home", comment: "")
} }
} }
@ -39,36 +48,54 @@ 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 {
NewHomeTab() if isMessengerModeEnabled {
.opacity(selectedTab == 0 ? 1 : 0) ChatsTab(
loginViewModel: viewModel,
ConceptTab() searchRevealProgress: $chatSearchRevealProgress,
.opacity(selectedTab == 1 ? 1 : 0) searchText: $chatSearchText
)
ChatsTab(
loginViewModel: viewModel,
searchRevealProgress: $chatSearchRevealProgress,
searchText: $chatSearchText
)
.opacity(selectedTab == 2 ? 1 : 0) .opacity(selectedTab == 2 ? 1 : 0)
.allowsHitTesting(selectedTab == 2) .allowsHitTesting(selectedTab == 2)
ProfileTab() ContactsTab()
.opacity(selectedTab == 3 ? 1 : 0) .opacity(selectedTab == 4 ? 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) { CustomTabBar(selectedTab: $selectedTab, isMessengerModeEnabled: isMessengerModeEnabled) {
print("Create button tapped") print("Create button tapped")
} }
} }
@ -91,41 +118,49 @@ struct MainView: View {
.allowsHitTesting(menuOffset > 0) .allowsHitTesting(menuOffset > 0)
// Боковое меню // Боковое меню
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented) if !isMessengerModeEnabled {
.frame(width: menuWidth) SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
.offset(x: -menuWidth + menuOffset) // Новая логика смещения .frame(width: menuWidth)
.ignoresSafeArea(edges: .vertical) .offset(x: -menuWidth + menuOffset) // Новая логика смещения
.ignoresSafeArea(edges: .vertical)
}
} }
deepLinkNavigationLink
} }
.gesture( .gesture(
DragGesture() DragGesture()
.onChanged { gesture in .onChanged { gesture in
if !isSideMenuPresented && gesture.startLocation.x > 60 { return } if !isMessengerModeEnabled {
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 !isSideMenuPresented && gesture.startLocation.x > 60 { return } if !isMessengerModeEnabled {
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
} }
} }
) )
@ -137,11 +172,100 @@ 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()
}
} }
} }
@ -149,6 +273,7 @@ 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())
} }
} }

View File

@ -0,0 +1,11 @@
import SwiftUI
struct QrView: View {
var body: some View {
Form {
}
.navigationTitle("Qr")
}
}

View File

@ -0,0 +1,283 @@
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?
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 {
activeAlert = .addPlaceholder
} label: {
Image(systemName: "plus")
}
}
}
.task {
await loadBlockedUsers()
}
.alert(item: $activeAlert) { alert in
switch alert {
case .addPlaceholder:
return Alert(
title: Text(NSLocalizedString("Скоро", comment: "Add blocked user placeholder title")),
message: Text(NSLocalizedString("Добавление новых блокировок появится позже.", comment: "Add blocked user placeholder message")),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
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 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 func userRow(_ user: BlockedUser, index: Int) -> some View {
HStack(spacing: 12) {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 44, height: 44)
.overlay(
Text(user.initials)
.font(.headline)
.foregroundColor(.accentColor)
)
VStack(alignment: .leading, spacing: 4) {
Text(user.displayName)
.font(.body)
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))
}
.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)
}
}
@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 initials: String {
let components = displayName.split(separator: " ")
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials
.map { String($0).uppercased() }
.joined()
}
if let handle {
let filtered = handle.filter { $0.isLetter }.prefix(2)
if !filtered.isEmpty {
return filtered.uppercased()
}
}
return "??"
}
init(payload: BlockedUserInfo) {
self.id = payload.userId
self.login = payload.login
self.fullName = payload.fullName
self.customName = payload.customName
self.createdAt = payload.createdAt
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 !payload.login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.handle = "@\(payload.login)"
} else {
self.handle = nil
}
}
}
private enum ActiveAlert: Identifiable {
case addPlaceholder
case error(id: UUID = UUID(), message: String)
var id: String {
switch self {
case .addPlaceholder:
return "addPlaceholder"
case .error(let id, _):
return id.uuidString
}
}
}

View File

@ -25,6 +25,7 @@ struct FeedbackView: View {
ratingSection ratingSection
suggestionSection suggestionSection
contactSection contactSection
infoSection2
Button(action: submitSuggestion) { Button(action: submitSuggestion) {
HStack(spacing: 10) { HStack(spacing: 10) {
@ -56,7 +57,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 {
@ -98,6 +99,24 @@ 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"))
@ -177,9 +196,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("Получить ответ от команды", comment: "feedback: contact toggle"), isOn: $wantsResponse) Toggle(NSLocalizedString("Уведомить об ответе по e-mail", comment: "feedback: contact toggle"), isOn: $wantsResponse)
.toggleStyle(SwitchToggleStyle(tint: .accentColor)) .toggleStyle(SwitchToggleStyle(tint: .accentColor))
if wantsResponse { if wantsResponse {

View File

@ -0,0 +1,27 @@
import SwiftUI
struct OtherSettingsView: View {
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
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)
}
.navigationTitle(Text(NSLocalizedString("Другое", comment: "")))
}
}
#Preview {
NavigationView {
OtherSettingsView()
}
}

View File

@ -0,0 +1,382 @@
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
}

View File

@ -0,0 +1,82 @@
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

View File

@ -0,0 +1,65 @@
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

View File

@ -0,0 +1,226 @@
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

View File

@ -0,0 +1,76 @@
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

View File

@ -4,6 +4,7 @@ struct SettingsView: View {
@ObservedObject var viewModel: LoginViewModel @ObservedObject var viewModel: LoginViewModel
@EnvironmentObject private var themeManager: ThemeManager @EnvironmentObject private var themeManager: ThemeManager
@State private var isThemeExpanded = false @State private var isThemeExpanded = false
@State private var isSecurityActive = false
private let themeOptions = ThemeOption.ordered private let themeOptions = ThemeOption.ordered
private var selectedThemeOption: ThemeOption { private var selectedThemeOption: ThemeOption {
@ -18,21 +19,31 @@ struct SettingsView: View {
// Label("Мой профиль", systemImage: "person.crop.circle") // Label("Мой профиль", systemImage: "person.crop.circle")
// } // }
NavigationLink(destination: EditPrivacyView()) { NavigationLink(destination: EditProfileView()) {
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill") Label(NSLocalizedString("Редактировать профиль", comment: ""), systemImage: "person.crop.circle")
}
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: Text("Заглушка: Двухфакторная аутентификация")) { NavigationLink(destination: ActiveSessionsView()) {
Label("Двухфакторная аутентификация", systemImage: "lock.shield") Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
} }
NavigationLink(destination: Text("Заглушка: Активные сессии")) { NavigationLink(isActive: $isSecurityActive) {
Label("Активные сессии", systemImage: "iphone") SecuritySettingsView(viewModel: viewModel)
} label: {
Label(NSLocalizedString("Безопасность", comment: ""), systemImage: "lock.shield")
} }
} }
@ -60,7 +71,7 @@ struct SettingsView: View {
Label("Данные", systemImage: "externaldrive") Label("Данные", systemImage: "externaldrive")
} }
NavigationLink(destination: Text("Заглушка: Другие настройки")) { NavigationLink(destination: OtherSettingsView()) {
Label("Другое", systemImage: "ellipsis.circle") Label("Другое", systemImage: "ellipsis.circle")
} }
} }

View File

@ -18,9 +18,3 @@ struct AppConfig {
/// 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"
}

View File

@ -2,9 +2,13 @@
<!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>com.apple.security.app-sandbox</key> <key>aps-environment</key>
<true/> <string>development</string>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.developer.aps-environment</key>
<true/> <string>development</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@ -10,6 +10,9 @@ 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()
@ -19,7 +22,7 @@ struct yobbleApp: App {
WindowGroup { WindowGroup {
ZStack(alignment: .top) { ZStack(alignment: .top) {
Group { Group {
if viewModel.isLoading { if viewModel.isInitialLoading {
SplashScreenView() SplashScreenView()
} else if viewModel.isLoggedIn { } else if viewModel.isLoggedIn {
MainView(viewModel: viewModel) MainView(viewModel: viewModel)
@ -55,23 +58,6 @@ struct yobbleApp: App {
} }
} }
} }
.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)