Compare commits
112 Commits
test-view-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ee1e20527 | |||
| 98ea7bcf02 | |||
| 0a162a5b2d | |||
| 0311e0f5b1 | |||
| 0617d1bd9c | |||
| e9b43e76fa | |||
| e44d56e71b | |||
| 71fb0551fe | |||
| bc9f82b8fb | |||
| 1449e003de | |||
| 372dc92c8d | |||
| d1612df43b | |||
| cd67c350b4 | |||
| bbe6a8a3e4 | |||
| 8712c7ea22 | |||
| 608add0714 | |||
| 79461616f5 | |||
| 1cec8aee3e | |||
| c09858dfbd | |||
| a9aa891f19 | |||
| a4102f7890 | |||
| 97951cc748 | |||
| 1492afabd2 | |||
| 50916b732a | |||
| b8ffca967b | |||
| f9026ebf87 | |||
| 92e4c30c7f | |||
| e269647d41 | |||
| 0cbbf4777d | |||
| 643466d878 | |||
| f14ff3293d | |||
| f22bce0e74 | |||
| 7a10ba5b33 | |||
| 8568f6c20e | |||
| 526a57b556 | |||
| 7f73216936 | |||
| 128ed5723a | |||
| 0a7d519567 | |||
| 9f6beecb49 | |||
| 3e9d6696b0 | |||
| 3f0543aa3a | |||
| 052ff5fe4f | |||
| 3c394446d2 | |||
| 9f2a938b1e | |||
| 7a2fb798a3 | |||
| b46fc3ae16 | |||
| b466864350 | |||
| 107318ef21 | |||
| e3cf374893 | |||
| 6eed966fc9 | |||
| 58c841b5c7 | |||
| 854561b5f7 | |||
| 020aa8de5d | |||
| be6394f6fb | |||
| cf5d2ad7fb | |||
| 26534e88c1 | |||
| 7034503983 | |||
| dd2abde5b8 | |||
| e135556fa6 | |||
| 910eef3703 | |||
| e6d7258b70 | |||
| 374bd1713b | |||
| aac0a25c4d | |||
| e79cbd7ea4 | |||
| 813795aece | |||
| 2eabbd59c3 | |||
| 43a5d8193d | |||
| 6b81860960 | |||
| 8acacdb8c1 | |||
| 1c9f249289 | |||
| 198b51bd91 | |||
| 40a5f4c628 | |||
| d692c7c984 | |||
| 3ae7576c24 | |||
| 52cf7e3b1c | |||
| 85fb780c96 | |||
| a28402136d | |||
| 140e82e122 | |||
| 726a6983b2 | |||
| 2f8c1f3514 | |||
| fa1637a5af | |||
| b47922694d | |||
| adba8fc568 | |||
| 2000ddadc2 | |||
| 9e95f9c9d9 | |||
| 9460024734 | |||
| b0888c2921 | |||
| 61e1feb8bd | |||
| de2d7c4020 | |||
| 91a3117595 | |||
| 5f03feba66 | |||
| b267e5a999 | |||
| b9ea0807e5 | |||
| 055c57c208 | |||
| bbed505033 | |||
| ee4f783fe7 | |||
| 93c865f5ca | |||
| 2d3299fe96 | |||
| 2c31d25596 | |||
| c6e17f0fc5 | |||
| 9685674056 | |||
| c1e39128fb | |||
| 331ec94ede | |||
| aa3e619d37 | |||
| 4442a40aac | |||
| 5e419e8b0f | |||
| 67125b230f | |||
| 44f7336c8d | |||
| 266742e15d | |||
| 6d8b322688 | |||
| edbf4faf00 | |||
| 1bc4dda14c |
@ -7,6 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* 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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -33,9 +35,22 @@
|
||||
1A6D61E42E7CD04100B9F736 /* yobbleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = yobbleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* 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 */
|
||||
1A6D61CE2E7CD03E00B9F736 /* yobble */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
1A38EB832EDE80E700DD8FC4 /* Exceptions for "yobble" folder in "yobble" target */,
|
||||
);
|
||||
path = yobble;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -56,6 +71,8 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A38EB882EDE871B00DD8FC4 /* FirebaseMessaging in Frameworks */,
|
||||
1A38EB8C2EDE8CC700DD8FC4 /* FirebaseCore in Frameworks */,
|
||||
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -111,6 +128,8 @@
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */,
|
||||
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1A6D61CE2E7CD03E00B9F736 /* yobble */,
|
||||
@ -118,6 +137,8 @@
|
||||
name = yobble;
|
||||
packageProductDependencies = (
|
||||
1A85C6CB2EA6FD73009FA847 /* SocketIO */,
|
||||
1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */,
|
||||
1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */,
|
||||
);
|
||||
productName = yobble;
|
||||
productReference = 1A6D61CC2E7CD03E00B9F736 /* yobble.app */;
|
||||
@ -204,6 +225,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */,
|
||||
1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 1A6D61CD2E7CD03E00B9F736 /* Products */;
|
||||
@ -266,6 +288,14 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = 1A38EB8F2EDE8ECA00DD8FC4 /* FirebaseMessaging */;
|
||||
};
|
||||
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = 1A38EB912EDE8ED500DD8FC4 /* FirebaseCore */;
|
||||
};
|
||||
1A6D61DC2E7CD04000B9F736 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1A6D61CB2E7CD03E00B9F736 /* yobble */;
|
||||
@ -404,11 +434,12 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = V22H44W47J;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = yobble/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
@ -420,7 +451,7 @@
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown 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[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 11;
|
||||
@ -444,11 +475,12 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = V22H44W47J;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = yobble/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
@ -460,7 +492,7 @@
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown 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[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 11;
|
||||
@ -609,6 +641,14 @@
|
||||
/* End XCConfigurationList 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" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/socketio/socket.io-client-swift";
|
||||
@ -620,6 +660,26 @@
|
||||
/* End XCRemoteSwiftPackageReference 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 */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */;
|
||||
|
||||
@ -1,6 +1,123 @@
|
||||
{
|
||||
"originHash" : "c9fb241c5f575df8f20b39649006995779013948e60c51c3f85b729f83b054e7",
|
||||
"originHash" : "600d4311db652d123053aed731b25dc6c4fe63e106bb9043d105bb4fedf8b79b",
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@ -18,6 +135,15 @@
|
||||
"revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a",
|
||||
"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
|
||||
|
||||
64
yobble/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct TopBarView: View {
|
||||
var title: String
|
||||
|
||||
let isMessengerModeEnabled: Bool
|
||||
// Состояния для ProfileTab
|
||||
@Binding var selectedAccount: String
|
||||
// @Binding var sheetType: ProfileTab.SheetType?
|
||||
@ -10,6 +11,7 @@ struct TopBarView: View {
|
||||
// var viewModel: LoginViewModel
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@Binding var isSettingsPresented: Bool
|
||||
@Binding var isQrPresented: Bool
|
||||
|
||||
// Привязка для управления боковым меню
|
||||
@Binding var isSideMenuPresented: Bool
|
||||
@ -17,15 +19,23 @@ struct TopBarView: View {
|
||||
@Binding var chatSearchText: String
|
||||
|
||||
var isHomeTab: Bool {
|
||||
return title == "Home"
|
||||
return title == NSLocalizedString("Home", comment: "")
|
||||
}
|
||||
|
||||
var isChatsTab: Bool {
|
||||
return title == "Chats"
|
||||
return title == NSLocalizedString("Чаты", comment: "")
|
||||
}
|
||||
|
||||
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? {
|
||||
@ -41,20 +51,22 @@ struct TopBarView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
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()
|
||||
|
||||
if let statusMessage {
|
||||
if let statusMessage, !isContactsTab, !isSettingsTab {
|
||||
connectionStatusView(message: statusMessage)
|
||||
Spacer()
|
||||
} else if isHomeTab{
|
||||
@ -109,6 +121,14 @@ struct TopBarView: View {
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
} else if isContactsTab {
|
||||
NavigationLink(isActive: $isQrPresented) {
|
||||
QrView()
|
||||
} label: {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
// else if isChatsTab {
|
||||
@ -217,17 +237,20 @@ struct TopBarView_Previews: PreviewProvider {
|
||||
@StateObject private var viewModel = LoginViewModel()
|
||||
@State private var searchText: String = ""
|
||||
@State private var isSettingsPresented = false
|
||||
@State private var isQrPresented = false
|
||||
|
||||
var body: some View {
|
||||
TopBarView(
|
||||
title: "Chats",
|
||||
isMessengerModeEnabled: false,
|
||||
selectedAccount: $selectedAccount,
|
||||
accounts: [selectedAccount],
|
||||
viewModel: viewModel,
|
||||
isSettingsPresented: $isSettingsPresented,
|
||||
isQrPresented: $isSettingsPresented,
|
||||
isSideMenuPresented: $isSideMenuPresented,
|
||||
chatSearchRevealProgress: $revealProgress,
|
||||
chatSearchText: $searchText
|
||||
chatSearchText: $searchText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
30
yobble/GoogleService-Info.plist
Normal 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
@ -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>
|
||||
@ -29,3 +29,16 @@ struct ErrorResponse: Decodable {
|
||||
struct MessagePayload: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct BlockedUserInfo: Decodable {
|
||||
let userId: UUID
|
||||
let login: String
|
||||
let fullName: String?
|
||||
let customName: String?
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
struct BlockedUsersPayload: Decodable {
|
||||
let hasMore: Bool
|
||||
let items: [BlockedUserInfo]
|
||||
}
|
||||
|
||||
@ -44,8 +44,9 @@ final class AuthService {
|
||||
}
|
||||
|
||||
NetworkClient.shared.request(
|
||||
path: "/v1/auth/login",
|
||||
path: "/v1/auth/login/password",
|
||||
method: .post,
|
||||
headers: ["X-Client-Type": "ios"],
|
||||
body: body,
|
||||
requiresAuth: false
|
||||
) { 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) {
|
||||
let payload = RegisterRequest(login: username, password: password, invite: invite)
|
||||
guard let body = try? JSONEncoder().encode(payload) else {
|
||||
@ -229,11 +315,24 @@ final class AuthService {
|
||||
return mappedRegistrationMessage(for: message, statusCode: statusCode)
|
||||
}
|
||||
|
||||
let message = extractMessage(from: data)
|
||||
|
||||
switch statusCode {
|
||||
case 400:
|
||||
return NSLocalizedString("Неверный запрос (400).", comment: "")
|
||||
case 403:
|
||||
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:
|
||||
return NSLocalizedString("Слишком много запросов.", comment: "")
|
||||
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 {
|
||||
if statusCode == 400 {
|
||||
if message.contains("Invalid invitation code") {
|
||||
@ -268,7 +431,7 @@ final class AuthService {
|
||||
return NSLocalizedString("Регистрация временно недоступна.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if statusCode == 429 {
|
||||
return NSLocalizedString("Слишком много запросов.", comment: "")
|
||||
}
|
||||
@ -387,6 +550,15 @@ private struct LoginRequest: Encodable {
|
||||
let password: String
|
||||
}
|
||||
|
||||
private struct LoginCodeRequestPayload: Encodable {
|
||||
let login: String
|
||||
}
|
||||
|
||||
private struct VerifyCodeRequestPayload: Encodable {
|
||||
let login: String
|
||||
let otp: String
|
||||
}
|
||||
|
||||
private struct RegisterRequest: Encodable {
|
||||
let login: String
|
||||
let password: String
|
||||
|
||||
231
yobble/Network/BlockedUsersService.swift
Normal 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
|
||||
}
|
||||
182
yobble/Network/ContactsService.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
309
yobble/Network/SessionsService.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
55
yobble/PushAppDelegate.swift
Normal 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])
|
||||
// }
|
||||
//}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 534 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 720 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1018 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 13 KiB |
@ -1,6 +1,11 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
struct ChatNavigationTarget: Identifiable {
|
||||
let id = UUID()
|
||||
let chat: PrivateChatListItem
|
||||
}
|
||||
|
||||
final class IncomingMessageCenter: ObservableObject {
|
||||
@Published private(set) var banner: IncomingMessageBanner?
|
||||
@Published var presentedChat: PrivateChatListItem?
|
||||
@ -122,9 +127,4 @@ final class IncomingMessageCenter: ObservableObject {
|
||||
dismissWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||
}
|
||||
|
||||
struct ChatNavigationTarget: Identifiable {
|
||||
let id = UUID()
|
||||
let chat: PrivateChatListItem
|
||||
}
|
||||
}
|
||||
|
||||
155
yobble/Services/PushTokenManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -340,17 +340,7 @@ final class SocketService {
|
||||
private func handleNewPrivateMessage(_ data: [Any]) {
|
||||
guard let payload = data.first else { return }
|
||||
|
||||
let messageData: Data
|
||||
if let dictionary = payload as? [String: Any],
|
||||
JSONSerialization.isValidJSONObject(dictionary),
|
||||
let json = try? JSONSerialization.data(withJSONObject: dictionary, options: []) {
|
||||
messageData = json
|
||||
} else if let string = payload as? String,
|
||||
let data = string.data(using: .utf8) {
|
||||
messageData = data
|
||||
} else {
|
||||
return
|
||||
}
|
||||
guard let messageData = normalizeMessagePayload(payload) else { return }
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
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() {
|
||||
consecutiveHeartbeatMisses = 0
|
||||
heartbeatAckInFlight = false
|
||||
|
||||
@ -12,22 +12,61 @@ class LoginViewModel: ObservableObject {
|
||||
@Published var username: String = ""
|
||||
@Published var userId: 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 errorMessage: String = ""
|
||||
@Published var isLoggedIn: Bool = false
|
||||
@Published var socketState: SocketService.ConnectionState
|
||||
@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 socketService = SocketService.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var resendTimer: Timer?
|
||||
|
||||
enum LoginFlowStep: Equatable {
|
||||
case passwordlessRequest
|
||||
case passwordlessVerify
|
||||
case password
|
||||
case registration
|
||||
}
|
||||
|
||||
enum ChatLoadingState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
}
|
||||
|
||||
enum OnboardingDestination: Equatable {
|
||||
case afterRegister
|
||||
}
|
||||
|
||||
private enum DefaultsKeys {
|
||||
static let currentUser = "currentUser"
|
||||
static let userId = "userId"
|
||||
@ -43,6 +82,10 @@ class LoginViewModel: ObservableObject {
|
||||
autoLogin()
|
||||
}
|
||||
|
||||
deinit {
|
||||
resendTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func observeSocketState() {
|
||||
socketService.connectionStatePublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
@ -91,6 +134,7 @@ class LoginViewModel: ObservableObject {
|
||||
self?.socketService.disconnect()
|
||||
}
|
||||
self?.isLoading = false
|
||||
self?.isInitialLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -99,8 +143,13 @@ class LoginViewModel: ObservableObject {
|
||||
func login() {
|
||||
isLoading = true
|
||||
showError = false
|
||||
|
||||
authService.login(username: username, password: password) { [weak self] success, error in
|
||||
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedLogin != passwordlessLogin {
|
||||
passwordlessLogin = trimmedLogin
|
||||
}
|
||||
username = trimmedLogin
|
||||
|
||||
authService.login(username: trimmedLogin, password: password) { [weak self] success, error in
|
||||
DispatchQueue.main.async {
|
||||
self?.isLoading = false
|
||||
if success {
|
||||
@ -115,6 +164,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) {
|
||||
authService.register(username: username, password: password, invite: invite) { [weak self] success, message in
|
||||
@ -123,6 +260,7 @@ class LoginViewModel: ObservableObject {
|
||||
self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
|
||||
self?.loadStoredUser()
|
||||
self?.socketService.connectForCurrentUser()
|
||||
self?.onboardingDestination = .afterRegister
|
||||
} else {
|
||||
self?.socketService.disconnect()
|
||||
}
|
||||
@ -169,4 +307,121 @@ class LoginViewModel: ObservableObject {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct PrivateChatView: View {
|
||||
let chat: PrivateChatListItem
|
||||
let currentUserId: String?
|
||||
private let bottomAnchorId = "PrivateChatBottomAnchor"
|
||||
|
||||
let lineLimitInChat = 6
|
||||
|
||||
@StateObject private var viewModel: PrivateChatViewModel
|
||||
@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 inputTab: ComposerTab = .chat
|
||||
@State private var isVideoPreferred: Bool = false
|
||||
@State private var legacyComposerHeight: CGFloat = 40
|
||||
@FocusState private var isComposerFocused: Bool
|
||||
@EnvironmentObject private var messageCenter: IncomingMessageCenter
|
||||
|
||||
@ -18,17 +29,22 @@ struct PrivateChatView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
content
|
||||
.onChange(of: viewModel.messages.count) { _ in
|
||||
guard !viewModel.isLoadingMore,
|
||||
let lastId = viewModel.messages.last?.id else { return }
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
proxy.scrollTo(lastId, anchor: .bottom)
|
||||
}
|
||||
hasPositionedToBottom = true
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
content
|
||||
.onChange(of: viewModel.messages.count) { _ in
|
||||
guard !viewModel.isLoadingMore else { return }
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
.onChange(of: scrollToBottomTrigger) { _ in
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
|
||||
if !isBottomAnchorVisible {
|
||||
scrollToBottomButton(proxy: proxy)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@ -83,9 +99,21 @@ struct PrivateChatView: View {
|
||||
!viewModel.messages.isEmpty {
|
||||
errorBanner(message: message)
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id(bottomAnchorId)
|
||||
.onAppear { isBottomAnchorVisible = true }
|
||||
.onDisappear { isBottomAnchorVisible = false }
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.simultaneousGesture(
|
||||
DragGesture().onChanged { value in
|
||||
guard value.translation.height > 0 else { return }
|
||||
isComposerFocused = false
|
||||
}
|
||||
)
|
||||
.refreshable {
|
||||
viewModel.refresh()
|
||||
}
|
||||
@ -128,7 +156,7 @@ struct PrivateChatView: View {
|
||||
|
||||
private func messageRow(for message: MessageItem) -> some View {
|
||||
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
|
||||
return HStack {
|
||||
return HStack(alignment: .bottom, spacing: 12) {
|
||||
if isCurrentUser { Spacer(minLength: 32) }
|
||||
|
||||
VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 6) {
|
||||
@ -138,25 +166,34 @@ struct PrivateChatView: View {
|
||||
// .foregroundColor(.secondary)
|
||||
// }
|
||||
|
||||
Text(contentText(for: message))
|
||||
.font(.body)
|
||||
.foregroundColor(isCurrentUser ? .white : .primary)
|
||||
.frame(maxWidth: .infinity, alignment: isCurrentUser ? .trailing : .leading)
|
||||
|
||||
Text(timestamp(for: message))
|
||||
.font(.caption2)
|
||||
.foregroundColor(isCurrentUser ? Color.white.opacity(0.8) : .secondary)
|
||||
HStack(alignment: .bottom) {
|
||||
Text(contentText(for: message))
|
||||
.font(.body)
|
||||
.foregroundColor(isCurrentUser ? .white : .primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(timestamp(for: message))
|
||||
.font(.caption2)
|
||||
.foregroundColor(isCurrentUser ? Color.white.opacity(0.8) : .secondary)
|
||||
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if !isCurrentUser { Spacer(minLength: 32) }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var messageBubbleMaxWidth: CGFloat {
|
||||
min(UIScreen.main.bounds.width * 0.72, 360)
|
||||
}
|
||||
|
||||
private func senderName(for message: MessageItem) -> String {
|
||||
if let full = message.senderData?.fullName, !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return full
|
||||
@ -200,47 +237,215 @@ struct PrivateChatView: View {
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
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")
|
||||
VStack(spacing: 10) {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
Button(action: { }) { // переключатель на стикеры
|
||||
Image(systemName: "paperclip")
|
||||
.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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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() {
|
||||
let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
draftText = ""
|
||||
scrollToBottomTrigger = .init()
|
||||
viewModel.sendMessage(text: text) { success in
|
||||
if success {
|
||||
draftText = ""
|
||||
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 = {
|
||||
let formatter = DateFormatter()
|
||||
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
|
||||
|
||||
// Previews intentionally omitted - MessageItem has custom decoding-only initializer.
|
||||
|
||||
75
yobble/Views/Login/LoginTopBar.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -9,11 +9,124 @@ import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@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
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
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?
|
||||
|
||||
private enum Field: Hashable {
|
||||
@ -22,142 +135,162 @@ struct LoginView: View {
|
||||
}
|
||||
|
||||
private var isUsernameValid: Bool {
|
||||
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
||||
return viewModel.username.range(of: pattern, options: .regularExpression) != nil
|
||||
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
|
||||
}
|
||||
|
||||
private var isPasswordValid: Bool {
|
||||
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 {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
||||
|
||||
ZStack {
|
||||
Color.clear // чтобы поймать тап
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Button {
|
||||
focusedField = nil
|
||||
withAnimation {
|
||||
viewModel.showPasswordlessRequest()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.left")
|
||||
Text(NSLocalizedString("Назад", comment: ""))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(NSLocalizedString("Вход по паролю", comment: ""))
|
||||
.font(.largeTitle).bold()
|
||||
// Text(NSLocalizedString("Если предпочитаете классический вход, используйте логин и пароль.", comment: ""))
|
||||
// .foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button(action: openLanguageSettings) {
|
||||
Text("🌍")
|
||||
.padding()
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Text("@")
|
||||
.foregroundColor(.secondary)
|
||||
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .username)
|
||||
}
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(themeOptions) { option in
|
||||
Button(action: { selectTheme(option) }) {
|
||||
themeMenuContent(for: option)
|
||||
.opacity(option.isEnabled ? 1.0 : 0.5)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
|
||||
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
|
||||
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)
|
||||
.padding()
|
||||
|
||||
if !isPasswordValid && !viewModel.password.isEmpty {
|
||||
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
focusedField = nil
|
||||
}
|
||||
|
||||
Spacer()
|
||||
// VStack(alignment: .leading, spacing: 4) {
|
||||
// Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
|
||||
// .toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
// Text(isMessengerModeEnabled
|
||||
// ? "Мессенджер-режим сейчас проработан примерно на 60%."
|
||||
// : "Основной режим находится в ранней разработке (около 10%).")
|
||||
// .font(.footnote)
|
||||
// .foregroundColor(.secondary)
|
||||
// }
|
||||
|
||||
TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.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: {
|
||||
viewModel.login()
|
||||
}) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.6))
|
||||
.cornerRadius(8)
|
||||
.padding()
|
||||
} else {
|
||||
Text(NSLocalizedString("Войти", comment: ""))
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isButtonEnabled ? Color.blue : Color.gray)
|
||||
.cornerRadius(8)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.disabled(!isButtonEnabled)
|
||||
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
|
||||
.cornerRadius(12)
|
||||
.disabled(!isLoginButtonEnabled)
|
||||
|
||||
// Spacer()
|
||||
|
||||
// Кнопка регистрации
|
||||
Button(action: {
|
||||
isShowingRegistration = true
|
||||
isShowingForgotPassword = true
|
||||
}) {
|
||||
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
|
||||
Text(NSLocalizedString("Забыли пароль? Сбросить", comment: ""))
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.sheet(isPresented: $isShowingRegistration) {
|
||||
RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration)
|
||||
.padding(.top, 4)
|
||||
|
||||
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) {
|
||||
Alert(
|
||||
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
|
||||
message: Text(viewModel.errorMessage),
|
||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||
)
|
||||
}
|
||||
.onTapGesture {
|
||||
focusedField = nil
|
||||
}
|
||||
.sheet(isPresented: $isShowingForgotPassword) {
|
||||
ForgotPasswordInfoView {
|
||||
isShowingForgotPassword = false
|
||||
withAnimation {
|
||||
viewModel.showPasswordlessRequest()
|
||||
}
|
||||
} onDismiss: {
|
||||
isShowingForgotPassword = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -172,6 +305,11 @@ struct LoginView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func hideKeyboardAndShowModePrompt() {
|
||||
focusedField = nil
|
||||
onShowModePrompt()
|
||||
}
|
||||
|
||||
private func openLanguageSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
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 {
|
||||
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()
|
||||
viewModel.isLoading = false // чтобы убрать спиннер
|
||||
viewModel.isLoading = false
|
||||
viewModel.isInitialLoading = false
|
||||
viewModel.loginFlowStep = step
|
||||
viewModel.passwordlessLogin = "preview@yobble.app"
|
||||
viewModel.verificationCode = "123456"
|
||||
return LoginView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ForgotPasswordInfoView: View {
|
||||
let onUseCode: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(NSLocalizedString("Сброс пароля", comment: ""))
|
||||
.font(.title2.bold())
|
||||
|
||||
Text(NSLocalizedString("Прямого сброса пароля нет: сменить его можно только из настроек, уже будучи в аккаунте. Если привязана почта или другое 2FA-устройство, воспользуйтесь входом по коду - он подтвердит вашу личность и пустит в аккаунт. После входа откройте настройки → безопасность и задайте новый пароль.", comment: ""))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button(action: onUseCode) {
|
||||
Text(NSLocalizedString("Войти", comment: ""))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Text(NSLocalizedString("Закрыть", comment: ""))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,8 @@ import SwiftUI
|
||||
|
||||
struct RegistrationView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@Binding var isPresented: Bool
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
let onShowModePrompt: (() -> Void)?
|
||||
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
@State private var confirmPassword: String = ""
|
||||
@State private var inviteCode: String = ""
|
||||
@ -20,6 +18,7 @@ struct RegistrationView: View {
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var showError: Bool = false
|
||||
@State private var errorMessage: String = ""
|
||||
@State private var isShowingTerms: Bool = false
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
@ -32,7 +31,7 @@ struct RegistrationView: View {
|
||||
|
||||
private var isUsernameValid: Bool {
|
||||
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 {
|
||||
@ -44,144 +43,157 @@ struct RegistrationView: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
ZStack(alignment: .top) {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { focusedField = nil }
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: keyboardDismissingModePrompt)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Group {
|
||||
HStack {
|
||||
TextField(NSLocalizedString("Логин", comment: "Логин"), text: $username)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .username)
|
||||
Spacer()
|
||||
if !username.isEmpty {
|
||||
Image(systemName: isUsernameValid ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundColor(isUsernameValid ? .green : .red)
|
||||
}
|
||||
}
|
||||
Button(action: goBack) {
|
||||
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(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()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.onChange(of: username) { newValue in
|
||||
if newValue.count > 32 {
|
||||
username = String(newValue.prefix(32))
|
||||
}
|
||||
}
|
||||
|
||||
if !isUsernameValid && !username.isEmpty {
|
||||
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: "Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
HStack {
|
||||
SecureField(NSLocalizedString("Пароль", comment: "Пароль"), text: $password)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .password)
|
||||
Spacer()
|
||||
if !password.isEmpty {
|
||||
Image(systemName: isPasswordValid ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundColor(isPasswordValid ? .green : .red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.cornerRadius(12)
|
||||
.onChange(of: password) { newValue in
|
||||
if newValue.count > 128 {
|
||||
password = String(newValue.prefix(128))
|
||||
}
|
||||
}
|
||||
|
||||
if !isPasswordValid && !password.isEmpty {
|
||||
Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: "Пароль должен быть от 6 до 32 символов"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
if !isPasswordValid && !password.isEmpty {
|
||||
Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: ""))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
SecureField(NSLocalizedString("Подтверждение пароля", comment: "Подтверждение пароля"), text: $confirmPassword)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
Spacer()
|
||||
if !confirmPassword.isEmpty {
|
||||
Image(systemName: isConfirmPasswordValid ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundColor(isConfirmPasswordValid ? .green : .red)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
SecureField(NSLocalizedString("Подтвердите пароль", comment: ""), text: $confirmPassword)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.cornerRadius(12)
|
||||
.onChange(of: confirmPassword) { newValue in
|
||||
if newValue.count > 32 {
|
||||
confirmPassword = String(newValue.prefix(32))
|
||||
}
|
||||
}
|
||||
|
||||
if !isConfirmPasswordValid && !confirmPassword.isEmpty {
|
||||
Text(NSLocalizedString("Пароли не совпадают", comment: "Пароли не совпадают"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: "Инвайт-код"), text: $inviteCode)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .invite)
|
||||
if !isConfirmPasswordValid && !confirmPassword.isEmpty {
|
||||
Text(NSLocalizedString("Пароли не совпадают", comment: ""))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: registerUser) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.6))
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться"))
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isFormValid ? Color.blue : Color.gray.opacity(0.6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.disabled(!isFormValid)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding()
|
||||
TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: ""), text: $inviteCode)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .invite)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(NSLocalizedString("Регистрация", comment: "Регистрация")))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: dismissSheet) {
|
||||
Text(NSLocalizedString("Закрыть", comment: "Закрыть"))
|
||||
|
||||
TermsAgreementCard(
|
||||
isAccepted: $viewModel.hasAcceptedTerms,
|
||||
openTerms: {
|
||||
viewModel.loadTermsIfNeeded()
|
||||
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() {
|
||||
isLoading = true
|
||||
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
|
||||
if success {
|
||||
dismissSheet()
|
||||
viewModel.hasAcceptedTerms = false
|
||||
} else {
|
||||
errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "")
|
||||
showError = true
|
||||
@ -200,10 +214,25 @@ struct RegistrationView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissSheet() {
|
||||
private func goBack() {
|
||||
focusedField = nil
|
||||
isPresented = false
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
viewModel.hasAcceptedTerms = false
|
||||
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 {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.isLoading = false // чтобы убрать спиннер
|
||||
return RegistrationView(viewModel: viewModel, isPresented: .constant(true))
|
||||
viewModel.isInitialLoading = false
|
||||
return RegistrationView(viewModel: viewModel, onShowModePrompt: nil)
|
||||
}
|
||||
}
|
||||
|
||||
102
yobble/Views/Login/TermsViews.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
|
||||
72
yobble/Views/Tab/AfterRegisterView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,6 @@ import UIKit
|
||||
|
||||
struct ChatsTab: View {
|
||||
@ObservedObject private var loginViewModel: LoginViewModel
|
||||
@Binding private var pendingNavigation: IncomingMessageCenter.ChatNavigationTarget?
|
||||
@Binding var searchRevealProgress: CGFloat
|
||||
@Binding var searchText: String
|
||||
private let searchService = SearchService()
|
||||
@ -33,6 +32,7 @@ struct ChatsTab: View {
|
||||
@State private var isPendingChatActive: Bool = false
|
||||
|
||||
private let searchRevealDistance: CGFloat = 90
|
||||
private let scrollToTopAnchorId = "ChatsListTopAnchor"
|
||||
|
||||
private var currentUserId: String? {
|
||||
let userId = loginViewModel.userId
|
||||
@ -41,12 +41,10 @@ struct ChatsTab: View {
|
||||
|
||||
init(
|
||||
loginViewModel: LoginViewModel,
|
||||
pendingNavigation: Binding<IncomingMessageCenter.ChatNavigationTarget?>,
|
||||
searchRevealProgress: Binding<CGFloat>,
|
||||
searchText: Binding<String>
|
||||
) {
|
||||
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
|
||||
self._pendingNavigation = pendingNavigation
|
||||
self._searchRevealProgress = searchRevealProgress
|
||||
self._searchText = searchText
|
||||
}
|
||||
@ -100,27 +98,21 @@ struct ChatsTab: View {
|
||||
globalSearchTask?.cancel()
|
||||
globalSearchTask = nil
|
||||
}
|
||||
.onChange(of: pendingNavigation?.id) { _ in
|
||||
guard let target = pendingNavigation else { return }
|
||||
handleNavigationTarget(target.chat)
|
||||
DispatchQueue.main.async {
|
||||
pendingNavigation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||||
loadingState
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
// if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||||
// loadingState
|
||||
// }
|
||||
chatList
|
||||
|
||||
}
|
||||
|
||||
private var chatList: some View {
|
||||
ZStack {
|
||||
List {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
List {
|
||||
// VStack(spacing: 0) {
|
||||
// searchBar
|
||||
// .padding(.horizontal, 16)
|
||||
@ -128,63 +120,74 @@ struct ChatsTab: View {
|
||||
// }
|
||||
// .background(Color(UIColor.systemBackground))
|
||||
|
||||
if let message = viewModel.errorMessage {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.orange)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: triggerChatsReload) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
if let message = viewModel.errorMessage {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text(message)
|
||||
.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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
}
|
||||
|
||||
Section(header: globalSearchHeader) {
|
||||
globalSearchContent
|
||||
}
|
||||
} else {
|
||||
if isSearching {
|
||||
Section(header: localSearchHeader) {
|
||||
if localSearchResults.isEmpty {
|
||||
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 {
|
||||
// errorState(message: message)
|
||||
// } else
|
||||
if viewModel.chats.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
|
||||
ForEach(viewModel.chats) { chat in
|
||||
chatRowItem(for: chat)
|
||||
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||||
loadingState
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
loadingMoreRow
|
||||
if viewModel.chats.isEmpty {
|
||||
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)
|
||||
.modifier(ScrollDismissesKeyboardModifier())
|
||||
.simultaneousGesture(searchBarGesture)
|
||||
.simultaneousGesture(tapToDismissKeyboardGesture)
|
||||
.listStyle(.plain)
|
||||
.modifier(ScrollDismissesKeyboardModifier())
|
||||
.simultaneousGesture(searchBarGesture)
|
||||
.simultaneousGesture(tapToDismissKeyboardGesture)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldScrollToTop)) { _ in
|
||||
scrollChatsToTop(using: proxy)
|
||||
}
|
||||
// .safeAreaInset(edge: .top) {
|
||||
// VStack(spacing: 0) {
|
||||
// searchBar
|
||||
@ -196,7 +199,8 @@ struct ChatsTab: View {
|
||||
// .background(Color(UIColor.systemBackground))
|
||||
// }
|
||||
|
||||
pendingChatNavigationLink
|
||||
pendingChatNavigationLink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,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 {
|
||||
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
@ -329,8 +341,10 @@ struct ChatsTab: View {
|
||||
if globalSearchResults.isEmpty {
|
||||
globalSearchEmptyRow
|
||||
} else {
|
||||
let firstGlobalUserId = globalSearchResults.first?.id
|
||||
ForEach(globalSearchResults) { user in
|
||||
globalSearchRow(for: user)
|
||||
.id(user.id == firstGlobalUserId ? AnyHashable(scrollToTopAnchorId) : AnyHashable(user.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -350,14 +364,26 @@ struct ChatsTab: View {
|
||||
.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 {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Text(NSLocalizedString("Загружаем чаты…", comment: ""))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Spacer()
|
||||
}
|
||||
.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 {
|
||||
@ -381,15 +407,15 @@ struct ChatsTab: View {
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "bubble.left")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
// Image(systemName: "bubble.left")
|
||||
// .font(.system(size: 48))
|
||||
// .foregroundColor(.secondary)
|
||||
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
Button(action: triggerChatsReload) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
}
|
||||
// Button(action: triggerChatsReload) {
|
||||
// Text(NSLocalizedString("Обновить", comment: ""))
|
||||
// }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
@ -452,7 +478,7 @@ struct ChatsTab: View {
|
||||
}
|
||||
.hidden()
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
// .listRowSeparator(.hidden)
|
||||
.onAppear {
|
||||
guard !isSearching else { return }
|
||||
@ -552,30 +578,6 @@ private extension ChatsTab {
|
||||
#endif
|
||||
}
|
||||
|
||||
func handleNavigationTarget(_ chatItem: PrivateChatListItem) {
|
||||
dismissKeyboard()
|
||||
if !searchText.isEmpty {
|
||||
searchText = ""
|
||||
}
|
||||
if searchRevealProgress > 0 {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||||
searchRevealProgress = 0
|
||||
}
|
||||
}
|
||||
|
||||
let existingChat = viewModel.chats.first(where: { $0.chatId == chatItem.chatId })
|
||||
pendingChatItem = existingChat ?? chatItem
|
||||
selectedChatId = chatItem.chatId
|
||||
isPendingChatActive = true
|
||||
|
||||
if existingChat == nil {
|
||||
if loginViewModel.chatLoadingState != .loading {
|
||||
loginViewModel.chatLoadingState = .loading
|
||||
}
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchQueryChange(_ query: String) {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@ -1195,12 +1197,10 @@ struct ChatsTab_Previews: PreviewProvider {
|
||||
@State private var progress: CGFloat = 1
|
||||
@State private var searchText: String = ""
|
||||
@StateObject private var loginViewModel = LoginViewModel()
|
||||
@State private var pendingNavigation: IncomingMessageCenter.ChatNavigationTarget?
|
||||
|
||||
var body: some View {
|
||||
ChatsTab(
|
||||
loginViewModel: loginViewModel,
|
||||
pendingNavigation: $pendingNavigation,
|
||||
searchRevealProgress: $progress,
|
||||
searchText: $searchText
|
||||
)
|
||||
@ -1217,4 +1217,5 @@ extension Notification.Name {
|
||||
static let debugRefreshChats = Notification.Name("debugRefreshChats")
|
||||
static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh")
|
||||
static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted")
|
||||
static let chatsShouldScrollToTop = Notification.Name("chatsShouldScrollToTop")
|
||||
}
|
||||
|
||||
384
yobble/Views/Tab/ContactsTab.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,37 +2,48 @@ import SwiftUI
|
||||
|
||||
struct CustomTabBar: View {
|
||||
@Binding var selectedTab: Int
|
||||
let isMessengerModeEnabled: Bool
|
||||
var onCreate: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
// Tab 1: Feed
|
||||
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
if isMessengerModeEnabled {
|
||||
|
||||
// Tab 2: Search
|
||||
TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) {
|
||||
selectedTab = 1
|
||||
}
|
||||
TabBarButton(systemName: "person.2.fill", text: NSLocalizedString("Контакты", comment: ""), isSelected: selectedTab == 4) {
|
||||
selectedTab = 4
|
||||
}
|
||||
|
||||
// Create Button
|
||||
CreateButton {
|
||||
onCreate()
|
||||
}
|
||||
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
|
||||
handleChatsTabTap()
|
||||
}
|
||||
|
||||
// Tab 3: Chats
|
||||
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
|
||||
selectedTab = 2
|
||||
}
|
||||
TabBarButton(systemName: "gearshape.fill", text: NSLocalizedString("Настройки", comment: ""), isSelected: selectedTab == 5) {
|
||||
selectedTab = 5
|
||||
}
|
||||
} else {
|
||||
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
|
||||
// Tab 4: Profile
|
||||
TabBarButton(systemName: "person.crop.square", text: NSLocalizedString("Лицо", comment: ""), isSelected: selectedTab == 3) {
|
||||
selectedTab = 3
|
||||
TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) {
|
||||
selectedTab = 1
|
||||
}
|
||||
|
||||
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(.top, 1)
|
||||
.padding(.top, isMessengerModeEnabled ? 6 : 1)
|
||||
.padding(.bottom, 30) // Добавляем отступ снизу
|
||||
// .background(Color(.systemGray6))
|
||||
}
|
||||
@ -82,3 +93,13 @@ struct CreateButton: View {
|
||||
.offset(y: -3)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CustomTabBar {
|
||||
func handleChatsTabTap() {
|
||||
if selectedTab == 2 {
|
||||
NotificationCenter.default.post(name: .chatsShouldScrollToTop, object: nil)
|
||||
} else {
|
||||
selectedTab = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ struct MainView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@EnvironmentObject private var messageCenter: IncomingMessageCenter
|
||||
@State private var selectedTab: Int = 0
|
||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
||||
// @StateObject private var newHomeTabViewModel = NewHomeTabViewModel()
|
||||
|
||||
// Состояния для TopBarView
|
||||
@ -17,14 +18,21 @@ struct MainView: View {
|
||||
@State private var chatSearchRevealProgress: CGFloat = 0
|
||||
@State private var chatSearchText: String = ""
|
||||
@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 {
|
||||
switch selectedTab {
|
||||
case 0: return "Home"
|
||||
case 1: return "Concept"
|
||||
case 2: return "Chats"
|
||||
case 3: return "Profile"
|
||||
default: return "Home"
|
||||
case 0: return NSLocalizedString("Home", comment: "")
|
||||
case 1: return NSLocalizedString("Concept", comment: "")
|
||||
case 2: return NSLocalizedString("Чаты", comment: "")
|
||||
case 3: return NSLocalizedString("Profile", comment: "")
|
||||
case 4: return NSLocalizedString("Контакты", comment: "")
|
||||
case 5: return NSLocalizedString("Настройки", comment: "")
|
||||
default: return NSLocalizedString("Home", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,49 +42,60 @@ struct MainView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
let pendingNavigationBinding: Binding<IncomingMessageCenter.ChatNavigationTarget?> = AppConfig.PRESENT_CHAT_AS_SHEET
|
||||
? .constant(nil)
|
||||
: Binding(
|
||||
get: { messageCenter.pendingNavigation },
|
||||
set: { messageCenter.pendingNavigation = $0 }
|
||||
)
|
||||
ZStack(alignment: .top) {
|
||||
ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю
|
||||
// Основной контент
|
||||
VStack(spacing: 0) {
|
||||
TopBarView(
|
||||
title: tabTitle,
|
||||
isMessengerModeEnabled: isMessengerModeEnabled,
|
||||
selectedAccount: $selectedAccount,
|
||||
accounts: accounts,
|
||||
viewModel: viewModel,
|
||||
isSettingsPresented: $isSettingsPresented,
|
||||
isQrPresented: $isQrPresented,
|
||||
isSideMenuPresented: $isSideMenuPresented,
|
||||
chatSearchRevealProgress: $chatSearchRevealProgress,
|
||||
chatSearchText: $chatSearchText
|
||||
)
|
||||
|
||||
ZStack {
|
||||
NewHomeTab()
|
||||
.opacity(selectedTab == 0 ? 1 : 0)
|
||||
|
||||
ConceptTab()
|
||||
.opacity(selectedTab == 1 ? 1 : 0)
|
||||
|
||||
ChatsTab(
|
||||
loginViewModel: viewModel,
|
||||
pendingNavigation: pendingNavigationBinding,
|
||||
searchRevealProgress: $chatSearchRevealProgress,
|
||||
searchText: $chatSearchText
|
||||
)
|
||||
if isMessengerModeEnabled {
|
||||
ChatsTab(
|
||||
loginViewModel: viewModel,
|
||||
searchRevealProgress: $chatSearchRevealProgress,
|
||||
searchText: $chatSearchText
|
||||
)
|
||||
.opacity(selectedTab == 2 ? 1 : 0)
|
||||
.allowsHitTesting(selectedTab == 2)
|
||||
|
||||
ProfileTab()
|
||||
.opacity(selectedTab == 3 ? 1 : 0)
|
||||
|
||||
ContactsTab()
|
||||
.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)
|
||||
|
||||
CustomTabBar(selectedTab: $selectedTab) {
|
||||
CustomTabBar(selectedTab: $selectedTab, isMessengerModeEnabled: isMessengerModeEnabled) {
|
||||
print("Create button tapped")
|
||||
}
|
||||
}
|
||||
@ -99,41 +118,49 @@ struct MainView: View {
|
||||
.allowsHitTesting(menuOffset > 0)
|
||||
|
||||
// Боковое меню
|
||||
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
|
||||
.frame(width: menuWidth)
|
||||
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
||||
.ignoresSafeArea(edges: .vertical)
|
||||
if !isMessengerModeEnabled {
|
||||
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
|
||||
.frame(width: menuWidth)
|
||||
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
||||
.ignoresSafeArea(edges: .vertical)
|
||||
}
|
||||
}
|
||||
|
||||
deepLinkNavigationLink
|
||||
}
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { gesture in
|
||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||
|
||||
let translation = gesture.translation.width
|
||||
|
||||
// Определяем базовое смещение в зависимости от того, открыто меню или нет
|
||||
let baseOffset = isSideMenuPresented ? menuWidth : 0
|
||||
|
||||
// Новое смещение — это база плюс текущий свайп
|
||||
let newOffset = baseOffset + translation
|
||||
|
||||
// Жестко ограничиваем итоговое смещение между 0 и шириной меню
|
||||
self.menuOffset = max(0, min(menuWidth, newOffset))
|
||||
if !isMessengerModeEnabled {
|
||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||
|
||||
let translation = gesture.translation.width
|
||||
|
||||
// Определяем базовое смещение в зависимости от того, открыто меню или нет
|
||||
let baseOffset = isSideMenuPresented ? menuWidth : 0
|
||||
|
||||
// Новое смещение — это база плюс текущий свайп
|
||||
let newOffset = baseOffset + translation
|
||||
|
||||
// Жестко ограничиваем итоговое смещение между 0 и шириной меню
|
||||
self.menuOffset = max(0, min(menuWidth, newOffset))
|
||||
}
|
||||
}
|
||||
.onEnded { gesture in
|
||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||
|
||||
let threshold = menuWidth * 0.4
|
||||
|
||||
withAnimation(.easeInOut) {
|
||||
if self.menuOffset > threshold {
|
||||
isSideMenuPresented = true
|
||||
} else {
|
||||
isSideMenuPresented = false
|
||||
if !isMessengerModeEnabled {
|
||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||
|
||||
let threshold = menuWidth * 0.4
|
||||
|
||||
withAnimation(.easeInOut) {
|
||||
if self.menuOffset > threshold {
|
||||
isSideMenuPresented = true
|
||||
} else {
|
||||
isSideMenuPresented = false
|
||||
}
|
||||
// Устанавливаем финальное смещение после анимации
|
||||
self.menuOffset = isSideMenuPresented ? menuWidth : 0
|
||||
}
|
||||
// Устанавливаем финальное смещение после анимации
|
||||
self.menuOffset = isSideMenuPresented ? menuWidth : 0
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -145,20 +172,100 @@ struct MainView: View {
|
||||
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,
|
||||
messageCenter.pendingNavigation != nil else { return }
|
||||
let target = messageCenter.pendingNavigation else { return }
|
||||
withAnimation(.easeInOut) {
|
||||
selectedTab = 2
|
||||
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
|
||||
if newValue != 3 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
yobble/Views/Tab/QrView.swift
Normal file
@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QrView: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
||||
}
|
||||
.navigationTitle("Qr")
|
||||
}
|
||||
}
|
||||
283
yobble/Views/Tab/Settings/BlockedUsersView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ struct FeedbackView: View {
|
||||
ratingSection
|
||||
suggestionSection
|
||||
contactSection
|
||||
infoSection2
|
||||
|
||||
Button(action: submitSuggestion) {
|
||||
HStack(spacing: 10) {
|
||||
@ -56,7 +57,7 @@ struct FeedbackView: View {
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||
.navigationTitle(NSLocalizedString("Обратная связь (не работает)", comment: "feedback: navigation title"))
|
||||
.navigationTitle(NSLocalizedString("Обратная связь", comment: "feedback: navigation title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.simultaneousGesture(
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionTitle(NSLocalizedString("Что вы хотите обсудить?", comment: "feedback: category title"))
|
||||
@ -177,9 +196,9 @@ struct FeedbackView: View {
|
||||
|
||||
private var contactSection: some View {
|
||||
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))
|
||||
|
||||
if wantsResponse {
|
||||
|
||||
27
yobble/Views/Tab/Settings/OtherSettingsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
382
yobble/Views/Tab/Settings/Security/ActiveSessionsView.swift
Normal 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
|
||||
}
|
||||
82
yobble/Views/Tab/Settings/Security/AppLockSettingsView.swift
Normal 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
|
||||
@ -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
|
||||
226
yobble/Views/Tab/Settings/Security/TwoFactorAuthView.swift
Normal 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
|
||||
76
yobble/Views/Tab/Settings/SecuritySettingsView.swift
Normal 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
|
||||
@ -4,6 +4,7 @@ struct SettingsView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@EnvironmentObject private var themeManager: ThemeManager
|
||||
@State private var isThemeExpanded = false
|
||||
@State private var isSecurityActive = false
|
||||
private let themeOptions = ThemeOption.ordered
|
||||
|
||||
private var selectedThemeOption: ThemeOption {
|
||||
@ -18,21 +19,31 @@ struct SettingsView: View {
|
||||
// Label("Мой профиль", systemImage: "person.crop.circle")
|
||||
// }
|
||||
|
||||
NavigationLink(destination: EditPrivacyView()) {
|
||||
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
|
||||
NavigationLink(destination: EditProfileView()) {
|
||||
Label(NSLocalizedString("Редактировать профиль", comment: ""), systemImage: "person.crop.circle")
|
||||
}
|
||||
|
||||
NavigationLink(destination: BlockedUsersView()) {
|
||||
Label(NSLocalizedString("Чёрный список", comment: ""), systemImage: "hand.raised.fill")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Безопасность
|
||||
Section(header: Text(NSLocalizedString("Безопасность", comment: ""))) {
|
||||
NavigationLink(destination: EditPrivacyView()) {
|
||||
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
|
||||
}
|
||||
|
||||
NavigationLink(destination: ChangePasswordView()) {
|
||||
Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key")
|
||||
}
|
||||
NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) {
|
||||
Label("Двухфакторная аутентификация", systemImage: "lock.shield")
|
||||
NavigationLink(destination: ActiveSessionsView()) {
|
||||
Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
|
||||
}
|
||||
NavigationLink(destination: Text("Заглушка: Активные сессии")) {
|
||||
Label("Активные сессии", systemImage: "iphone")
|
||||
NavigationLink(isActive: $isSecurityActive) {
|
||||
SecuritySettingsView(viewModel: viewModel)
|
||||
} label: {
|
||||
Label(NSLocalizedString("Безопасность", comment: ""), systemImage: "lock.shield")
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +71,7 @@ struct SettingsView: View {
|
||||
Label("Данные", systemImage: "externaldrive")
|
||||
}
|
||||
|
||||
NavigationLink(destination: Text("Заглушка: Другие настройки")) {
|
||||
NavigationLink(destination: OtherSettingsView()) {
|
||||
Label("Другое", systemImage: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,9 +18,3 @@ struct AppConfig {
|
||||
/// Fallback SQLCipher key used until the user sets an application password.
|
||||
static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
|
||||
}
|
||||
|
||||
struct AppInfo {
|
||||
static let text_1 = "\(NSLocalizedString("profile_down_text_1", comment: "")) yobble"
|
||||
static let text_2 = "\(NSLocalizedString("profile_down_text_2", comment: "")) 0.1test"
|
||||
static let text_3 = "\(NSLocalizedString("profile_down_text_3", comment: ""))2025"
|
||||
}
|
||||
|
||||
@ -2,9 +2,13 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -10,6 +10,9 @@ import CoreData
|
||||
|
||||
@main
|
||||
struct yobbleApp: App {
|
||||
// @UIApplicationDelegateAdaptor(PushAppDelegate.self) var appDelegate
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
||||
|
||||
@StateObject private var themeManager = ThemeManager()
|
||||
@StateObject private var viewModel = LoginViewModel()
|
||||
@StateObject private var messageCenter = IncomingMessageCenter()
|
||||
@ -19,7 +22,7 @@ struct yobbleApp: App {
|
||||
WindowGroup {
|
||||
ZStack(alignment: .top) {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
if viewModel.isInitialLoading {
|
||||
SplashScreenView()
|
||||
} else if viewModel.isLoggedIn {
|
||||
MainView(viewModel: viewModel)
|
||||
|
||||