Compare commits

..

1 Commits

Author SHA1 Message Date
5c0c24cff2 patch view 2025-10-21 20:52:46 +03:00
79 changed files with 488 additions and 5771 deletions

View File

@ -7,8 +7,6 @@
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 */
@ -35,22 +33,9 @@
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>";
};
@ -71,8 +56,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1A38EB882EDE871B00DD8FC4 /* FirebaseMessaging in Frameworks */,
1A38EB8C2EDE8CC700DD8FC4 /* FirebaseCore in Frameworks */,
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -128,8 +111,6 @@
buildRules = (
);
dependencies = (
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */,
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
1A6D61CE2E7CD03E00B9F736 /* yobble */,
@ -137,8 +118,6 @@
name = yobble;
packageProductDependencies = (
1A85C6CB2EA6FD73009FA847 /* SocketIO */,
1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */,
1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */,
);
productName = yobble;
productReference = 1A6D61CC2E7CD03E00B9F736 /* yobble.app */;
@ -225,7 +204,6 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */,
1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 1A6D61CD2E7CD03E00B9F736 /* Products */;
@ -288,14 +266,6 @@
/* 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 */;
@ -434,12 +404,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 7;
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;
@ -451,7 +420,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 = 15;
IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 11;
@ -475,12 +444,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 7;
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;
@ -492,7 +460,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 = 15;
IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 11;
@ -641,14 +609,6 @@
/* 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";
@ -660,26 +620,6 @@
/* 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" */;

View File

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

View File

@ -1,64 +0,0 @@
//e8DBOKOTPUGxtvK2mqZ-gy:APA91bGtJO3Jf8NxuvzSnfj4YyZllen29x1c_o3UtKHKTvnVcTz0TdHapCyjJH4ZsuiO9z2HhGW134165c-VXmrdKlYSBGz5-ZtU0lTWLe5LDLuZGDbqYdk
import Firebase
import FirebaseMessaging
import UserNotifications
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate {
private let pushTokenManager = PushTokenManager.shared
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
print("hello")
FirebaseApp.configure()
UNUserNotificationCenter.current().delegate = self
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
return true
}
// Foreground notifications вот это важное!
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
// completionHandler([.banner, .sound, .badge]) // push
completionHandler([]) // no push
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
print("📨 APNs device token:", token)
Messaging.messaging().apnsToken = deviceToken
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("❌ APNs registration failed:", error)
}
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
guard let fcmToken else {
if AppConfig.DEBUG { print("🔥 FCM token: NO TOKEN") }
return
}
if AppConfig.DEBUG { print("🔥 FCM token:", fcmToken) }
pushTokenManager.registerFCMToken(fcmToken)
}
}

View File

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

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAdhhLghSRDeN9ivTG5jd9ZT6DNdQ8pBM4</string>
<key>GCM_SENDER_ID</key>
<string>1058456897662</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>org.yobble.yobble</string>
<key>PROJECT_ID</key>
<string>yobble</string>
<key>STORAGE_BUCKET</key>
<string>yobble.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:1058456897662:ios:c2a898d6a6412b8709f02f</string>
</dict>
</plist>

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>

View File

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

View File

@ -44,9 +44,8 @@ final class AuthService {
}
NetworkClient.shared.request(
path: "/v1/auth/login/password",
path: "/v1/auth/login",
method: .post,
headers: ["X-Client-Type": "ios"],
body: body,
requiresAuth: false
) { result in
@ -84,91 +83,6 @@ final class AuthService {
}
}
func requestLoginCode(identifier: String, completion: @escaping (Bool, String?) -> Void) {
let payload = LoginCodeRequestPayload(login: identifier)
guard let body = try? JSONEncoder().encode(payload) else {
completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
return
}
NetworkClient.shared.request(
path: "/v1/auth/login/code",
method: .post,
headers: ["X-Client-Type": "ios"],
body: body,
requiresAuth: false
) { [weak self] result in
guard let self else { return }
switch result {
case .success(let response):
do {
let decoder = JSONDecoder()
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? apiResponse.data.message
completion(false, message)
return
}
completion(true, nil)
} catch {
completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
}
case .failure(let error):
completion(false, self.passwordlessRequestErrorMessage(for: error))
}
}
}
func loginWithCode(identifier: String, code: String, completion: @escaping (Bool, String?) -> Void) {
let payload = VerifyCodeRequestPayload(login: identifier, otp: code)
guard let body = try? JSONEncoder().encode(payload) else {
completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
return
}
NetworkClient.shared.request(
path: "/v1/auth/login/verify_code",
method: .post,
headers: ["X-Client-Type": "ios"],
body: body,
requiresAuth: false
) { [weak self] result in
guard let self else { return }
switch result {
case .success(let response):
do {
let decoder = JSONDecoder()
let apiResponse = try decoder.decode(APIResponse<TokenPairPayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Проверьте код и попробуйте снова.", comment: "")
completion(false, message)
return
}
let tokens = apiResponse.data
KeychainService.shared.save(tokens.access_token, forKey: "access_token", service: identifier)
KeychainService.shared.save(tokens.refresh_token, forKey: "refresh_token", service: identifier)
if let userId = tokens.user_id {
KeychainService.shared.save(userId, forKey: "userId", service: identifier)
}
UserDefaults.standard.set(identifier, forKey: "currentUser")
NotificationCenter.default.post(name: .accessTokenDidChange, object: nil)
completion(true, nil)
} catch {
completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
}
case .failure(let error):
completion(false, self.passwordlessVerifyErrorMessage(for: error))
}
}
}
func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
let payload = RegisterRequest(login: username, password: password, invite: invite)
guard let body = try? JSONEncoder().encode(payload) else {
@ -315,24 +229,11 @@ 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:
@ -347,70 +248,6 @@ final class AuthService {
}
}
private func passwordlessRequestErrorMessage(for error: NetworkError) -> String {
switch error {
case .network(let err):
return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription)
case .server(let statusCode, let data):
let message = extractMessage(from: data)
switch statusCode {
case 401, 404:
return message ?? NSLocalizedString("Аккаунт не найден.", comment: "")
case 403:
return message ?? NSLocalizedString("Этому аккаунту недоступен вход по коду.", comment: "")
case 422:
return message ?? NSLocalizedString("Неверный логин. Проверьте и попробуйте снова.", comment: "")
case 429:
return NSLocalizedString("Слишком много попыток. Попробуйте позже.", comment: "")
case 502:
return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
default:
if let message {
return message
}
return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)")
}
case .unauthorized:
return NSLocalizedString("Необходимо авторизоваться заново.", comment: "")
case .invalidURL, .noResponse:
return NSLocalizedString("Некорректный ответ от сервера.", comment: "")
}
}
private func passwordlessVerifyErrorMessage(for error: NetworkError) -> String {
switch error {
case .network(let err):
return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription)
case .server(let statusCode, let data):
let message = extractMessage(from: data)
switch statusCode {
case 401:
return message ?? NSLocalizedString("Неверный или просроченный код.", comment: "")
case 403:
return message ?? NSLocalizedString("Этот аккаунт недоступен.", comment: "")
case 404:
return message ?? NSLocalizedString("Аккаунт не найден.", comment: "")
case 422:
return message ?? NSLocalizedString("Некорректные данные. Проверьте код и логин.", comment: "")
case 429:
return NSLocalizedString("Слишком много попыток. Попробуйте позже.", comment: "")
case 502:
return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
default:
if let message {
return message
}
return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)")
}
case .unauthorized:
return NSLocalizedString("Сессия недействительна. Авторизуйтесь заново.", comment: "")
case .invalidURL, .noResponse:
return NSLocalizedString("Некорректный ответ от сервера.", comment: "")
}
}
private func mappedRegistrationMessage(for message: String, statusCode: Int) -> String {
if statusCode == 400 {
if message.contains("Invalid invitation code") {
@ -431,7 +268,7 @@ final class AuthService {
return NSLocalizedString("Регистрация временно недоступна.", comment: "")
}
}
if statusCode == 429 {
return NSLocalizedString("Слишком много запросов.", comment: "")
}
@ -550,15 +387,6 @@ 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

View File

@ -1,231 +0,0 @@
import Foundation
enum BlockedUsersServiceError: LocalizedError {
case unexpectedStatus(String)
case decoding(debugDescription: String)
case encoding(String)
var errorDescription: String? {
switch self {
case .unexpectedStatus(let message):
return message
case .decoding(let debugDescription):
return AppConfig.DEBUG
? debugDescription
: NSLocalizedString("Не удалось загрузить список.", comment: "Blocked users service decoding error")
case .encoding(let message):
return message
}
}
}
final class BlockedUsersService {
private let client: NetworkClient
private let decoder: JSONDecoder
init(client: NetworkClient = .shared) {
self.client = client
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
}
func fetchBlockedUsers(limit: Int, offset: Int, completion: @escaping (Result<BlockedUsersPayload, Error>) -> Void) {
let query = [
"limit": String(limit),
"offset": String(offset)
]
client.request(
path: "/v1/user/blacklist/list",
method: .get,
query: query,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<BlockedUsersPayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список.", comment: "Blocked users service unexpected status")
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[BlockedUsersService] decode blocked users failed: \(debugMessage)")
}
completion(.failure(BlockedUsersServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func fetchBlockedUsers(limit: Int, offset: Int) async throws -> BlockedUsersPayload {
try await withCheckedThrowingContinuation { continuation in
fetchBlockedUsers(limit: limit, offset: offset) { result in
continuation.resume(with: result)
}
}
}
func remove(userId: UUID, completion: @escaping (Result<String, Error>) -> Void) {
let request = BlockedUserDeleteRequest(userId: userId)
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
guard let body = try? encoder.encode(request) else {
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Blocked users delete encoding error")
completion(.failure(BlockedUsersServiceError.encoding(message)))
return
}
client.request(
path: "/v1/user/blacklist/remove",
method: .delete,
body: body,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось удалить пользователя из списка.", comment: "Blocked users delete unexpected status")
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data.message))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[BlockedUsersService] decode delete response failed: \(debugMessage)")
}
completion(.failure(BlockedUsersServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func remove(userId: UUID) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
remove(userId: userId) { result in
continuation.resume(with: result)
}
}
}
private static func decodeDate(from decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let date = iso8601WithFractionalSeconds.date(from: string) {
return date
}
if let date = iso8601Simple.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Невозможно декодировать дату: \(string)"
)
}
private static func describeDecodingError(error: Error, data: Data) -> String {
var parts: [String] = []
if let decodingError = error as? DecodingError {
parts.append(decodingDescription(from: decodingError))
} else {
parts.append(error.localizedDescription)
}
if let payload = truncatedPayload(from: data) {
parts.append("payload=\(payload)")
}
return parts.joined(separator: "\n")
}
private static func decodingDescription(from error: DecodingError) -> String {
switch error {
case .typeMismatch(let type, let context):
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .valueNotFound(let type, let context):
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .keyNotFound(let key, let context):
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
case .dataCorrupted(let context):
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
@unknown default:
return error.localizedDescription
}
}
private static func codingPath(from context: DecodingError.Context) -> String {
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
return path.isEmpty ? "root" : path.joined(separator: ".")
}
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty else {
return nil
}
if string.count <= limit {
return string
}
let index = string.index(string.startIndex, offsetBy: limit)
return String(string[string.startIndex..<index]) + ""
}
private static func errorMessage(from data: Data) -> String? {
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
if let detail = apiError.detail, !detail.isEmpty {
return detail
}
if let message = apiError.data?.message, !message.isEmpty {
return message
}
}
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
return string
}
return nil
}
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static let iso8601Simple: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}
private struct BlockedUserDeleteRequest: Encodable {
let userId: UUID
}

View File

@ -1,182 +0,0 @@
import Foundation
enum ContactsServiceError: LocalizedError {
case unexpectedStatus(String)
case decoding(debugDescription: String)
var errorDescription: String? {
switch self {
case .unexpectedStatus(let message):
return message
case .decoding(let debugDescription):
return AppConfig.DEBUG
? debugDescription
: NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service decoding error")
}
}
}
struct ContactPayload: Decodable {
let userId: UUID
let login: String
let fullName: String?
let customName: String?
let friendCode: Bool
let createdAt: Date
}
struct ContactsListPayload: Decodable {
let items: [ContactPayload]
let hasMore: Bool
}
final class ContactsService {
private let client: NetworkClient
private let decoder: JSONDecoder
init(client: NetworkClient = .shared) {
self.client = client
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
}
func fetchContacts(limit: Int, offset: Int, completion: @escaping (Result<ContactsListPayload, Error>) -> Void) {
client.request(
path: "/v1/user/contact/list",
method: .get,
query: [
"limit": String(limit),
"offset": String(offset)
],
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<ContactsListPayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service unexpected status")
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[ContactsService] decode contacts failed: \(debugMessage)")
}
completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func fetchContacts(limit: Int, offset: Int) async throws -> ContactsListPayload {
try await withCheckedThrowingContinuation { continuation in
fetchContacts(limit: limit, offset: offset) { result in
continuation.resume(with: result)
}
}
}
private static func decodeDate(from decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let date = iso8601WithFractionalSeconds.date(from: string) {
return date
}
if let date = iso8601Simple.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Невозможно декодировать дату: \(string)"
)
}
private static func describeDecodingError(error: Error, data: Data) -> String {
var parts: [String] = []
if let decodingError = error as? DecodingError {
parts.append(decodingDescription(from: decodingError))
} else {
parts.append(error.localizedDescription)
}
if let payload = truncatedPayload(from: data) {
parts.append("payload=\(payload)")
}
return parts.joined(separator: "\n")
}
private static func decodingDescription(from error: DecodingError) -> String {
switch error {
case .typeMismatch(let type, let context):
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .valueNotFound(let type, let context):
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .keyNotFound(let key, let context):
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
case .dataCorrupted(let context):
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
@unknown default:
return error.localizedDescription
}
}
private static func codingPath(from context: DecodingError.Context) -> String {
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
return path.isEmpty ? "root" : path.joined(separator: ".")
}
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty else {
return nil
}
if string.count <= limit {
return string
}
let index = string.index(string.startIndex, offsetBy: limit)
return String(string[string.startIndex..<index]) + ""
}
private static func errorMessage(from data: Data) -> String? {
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
if let detail = apiError.detail, !detail.isEmpty {
return detail
}
if let message = apiError.data?.message, !message.isEmpty {
return message
}
}
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
return string
}
return nil
}
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static let iso8601Simple: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}

View File

@ -1,309 +0,0 @@
import Foundation
enum SessionsServiceError: LocalizedError {
case unexpectedStatus(String)
case decoding(debugDescription: String)
var errorDescription: String? {
switch self {
case .unexpectedStatus(let message):
return message
case .decoding(let debugDescription):
return AppConfig.DEBUG
? debugDescription
: NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service decoding error")
}
}
}
struct UserSessionPayload: Decodable {
let id: UUID
let ipAddress: String?
let userAgent: String?
let clientType: String
let isActive: Bool
let createdAt: Date
let lastRefreshAt: Date
let isCurrent: Bool
}
private struct SessionsListPayload: Decodable {
let sessions: [UserSessionPayload]
}
final class SessionsService {
private let client: NetworkClient
private let decoder: JSONDecoder
init(client: NetworkClient = .shared) {
self.client = client
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
}
func fetchSessions(completion: @escaping (Result<[UserSessionPayload], Error>) -> Void) {
client.request(
path: "/v1/auth/sessions/list",
method: .get,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<SessionsListPayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service unexpected status")
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data.sessions))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[SessionsService] decode sessions failed: \(debugMessage)")
}
completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func fetchSessions() async throws -> [UserSessionPayload] {
try await withCheckedThrowingContinuation { continuation in
fetchSessions { result in
continuation.resume(with: result)
}
}
}
func revokeAllExceptCurrent(completion: @escaping (Result<String, Error>) -> Void) {
client.request(
path: "/v1/auth/sessions/revoke_all_except_current",
method: .post,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось завершить другие сессии.", comment: "Sessions service revoke-all unexpected status")
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data.message))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[SessionsService] decode revoke-all failed: \(debugMessage)")
}
completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func revokeAllExceptCurrent() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
revokeAllExceptCurrent { result in
continuation.resume(with: result)
}
}
}
func revoke(sessionId: UUID, completion: @escaping (Result<String, Error>) -> Void) {
client.request(
path: "/v1/auth/sessions/revoke/\(sessionId.uuidString)",
method: .post,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось завершить сессию.", comment: "Sessions service revoke unexpected status")
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data.message))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[SessionsService] decode revoke failed: \(debugMessage)")
}
completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func revoke(sessionId: UUID) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
revoke(sessionId: sessionId) { result in
continuation.resume(with: result)
}
}
}
func updatePushToken(_ token: String, completion: @escaping (Result<String, Error>) -> Void) {
client.request(
path: "/v1/auth/sessions/update_push_token",
method: .post,
query: ["fcm_token": token],
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить push-токен.", comment: "Sessions service update push unexpected status")
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data.message))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[SessionsService] decode update-push failed: \(debugMessage)")
}
completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func updatePushToken(_ token: String) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
updatePushToken(token) { result in
continuation.resume(with: result)
}
}
}
private static func decodeDate(from decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let date = iso8601WithFractionalSeconds.date(from: string) {
return date
}
if let date = iso8601Simple.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Невозможно декодировать дату: \(string)"
)
}
private static func describeDecodingError(error: Error, data: Data) -> String {
var parts: [String] = []
if let decodingError = error as? DecodingError {
parts.append(decodingDescription(from: decodingError))
} else {
parts.append(error.localizedDescription)
}
if let payload = truncatedPayload(from: data) {
parts.append("payload=\(payload)")
}
return parts.joined(separator: "\n")
}
private static func decodingDescription(from error: DecodingError) -> String {
switch error {
case .typeMismatch(let type, let context):
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .valueNotFound(let type, let context):
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .keyNotFound(let key, let context):
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
case .dataCorrupted(let context):
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
@unknown default:
return error.localizedDescription
}
}
private static func codingPath(from context: DecodingError.Context) -> String {
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
return path.isEmpty ? "root" : path.joined(separator: ".")
}
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty else {
return nil
}
if string.count <= limit {
return string
}
let index = string.index(string.startIndex, offsetBy: limit)
return String(string[string.startIndex..<index]) + ""
}
private static func errorMessage(from data: Data) -> String? {
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
if let detail = apiError.detail, !detail.isEmpty {
return detail
}
if let message = apiError.data?.message, !message.isEmpty {
return message
}
}
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
return string
}
return nil
}
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static let iso8601Simple: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}

View File

@ -1,55 +0,0 @@
////
//// PushAppDelegate.swift
//// yobble
////
//// Created by cheykrym on 02.12.2025.
//// 72acf38bfbf0e990f745a612527911f8df1d63d60de70d41391c54b52498f7ab
//
//import UIKit
//import UserNotifications
//
//class PushAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
//
// // Запрос разрешения на уведомления
// func application(_ application: UIApplication,
// didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
//
// UNUserNotificationCenter.current().delegate = self
//
// UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
// guard granted else {
// print(" User denied push notifications")
// return
// }
//
// DispatchQueue.main.async {
// UIApplication.shared.registerForRemoteNotifications()
// }
// }
//
// return true
// }
//
// // Получаем пуш-токен устройства
// func application(_ application: UIApplication,
// didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
//
// let token = deviceToken.map { String(format: "%02x", $0) }.joined()
// print("📨 Device Token:", token)
// }
//
// // Ошибка регистрации
// func application(_ application: UIApplication,
// didFailToRegisterForRemoteNotificationsWithError error: Error) {
// print(" Failed to register for remote notifications:", error)
// }
//
// // Пуш пришёл в форграунде
// func userNotificationCenter(_ center: UNUserNotificationCenter,
// willPresent notification: UNNotification,
// withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
//
// // показываем алерт даже если приложение открыто
// completionHandler([.banner, .sound, .badge])
// }
//}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 B

After

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,155 +0,0 @@
import Foundation
import UIKit
final class PushTokenManager {
static let shared = PushTokenManager()
private let queue = DispatchQueue(label: "org.yobble.push-token", qos: .utility)
private let sessionsService: SessionsService
private var currentFCMToken: String?
private var lastSentTokens: [String: String]
private var loginsRequiringSync: Set<String> = []
private var isUpdating = false
private var pendingUpdate = false
private var retryWorkItem: DispatchWorkItem?
private var notificationTokens: [NSObjectProtocol] = []
private enum Keys {
static let storedToken = "push.current_fcm_token"
static let sentTokens = "push.last_sent_tokens"
static let currentUser = "currentUser"
}
private enum Constants {
static let retryDelay: TimeInterval = 20
}
private init(sessionsService: SessionsService = SessionsService()) {
self.sessionsService = sessionsService
let defaults = UserDefaults.standard
self.currentFCMToken = defaults.string(forKey: Keys.storedToken)
self.lastSentTokens = defaults.dictionary(forKey: Keys.sentTokens) as? [String: String] ?? [:]
observeNotifications()
queue.async { [weak self] in
guard let self else { return }
if let login = self.currentLogin() {
self.loginsRequiringSync.insert(login)
}
self.pendingUpdate = true
self.tryUpdateTokenIfNeeded()
}
}
deinit {
notificationTokens.forEach { NotificationCenter.default.removeObserver($0) }
notificationTokens.removeAll()
}
func registerFCMToken(_ token: String) {
queue.async { [weak self] in
guard let self else { return }
guard self.currentFCMToken != token else { return }
self.currentFCMToken = token
UserDefaults.standard.set(token, forKey: Keys.storedToken)
if let login = self.currentLogin() {
self.loginsRequiringSync.insert(login)
}
self.pendingUpdate = true
self.tryUpdateTokenIfNeeded()
}
}
private func observeNotifications() {
let center = NotificationCenter.default
let accessTokenObserver = center.addObserver(forName: .accessTokenDidChange, object: nil, queue: nil) { [weak self] _ in
guard let self else { return }
self.queue.async {
if let login = self.currentLogin() {
self.loginsRequiringSync.insert(login)
} else {
self.loginsRequiringSync.removeAll()
}
self.pendingUpdate = true
self.tryUpdateTokenIfNeeded()
}
}
notificationTokens.append(accessTokenObserver)
let didBecomeActiveObserver = center.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in
guard let self else { return }
self.queue.async {
self.pendingUpdate = true
self.tryUpdateTokenIfNeeded()
}
}
notificationTokens.append(didBecomeActiveObserver)
}
private func tryUpdateTokenIfNeeded() {
guard pendingUpdate else { return }
guard !isUpdating else { return }
guard let login = currentLogin() else { return }
guard let token = currentFCMToken, !token.isEmpty else { return }
let needsForcedSync = loginsRequiringSync.contains(login)
if !needsForcedSync, let lastToken = lastSentTokens[login], lastToken == token {
pendingUpdate = false
return
}
pendingUpdate = false
isUpdating = true
retryWorkItem?.cancel()
retryWorkItem = nil
sessionsService.updatePushToken(token) { [weak self] result in
guard let self else { return }
self.queue.async {
self.isUpdating = false
switch result {
case .success:
self.loginsRequiringSync.remove(login)
self.lastSentTokens[login] = token
UserDefaults.standard.set(self.lastSentTokens, forKey: Keys.sentTokens)
if AppConfig.DEBUG {
print("[PushTokenManager] Push token updated for @\(login)")
}
case .failure(let error):
if AppConfig.DEBUG {
print("[PushTokenManager] Failed to update push token: \(error.localizedDescription)")
}
self.loginsRequiringSync.insert(login)
self.pendingUpdate = true
self.scheduleRetry()
}
self.tryUpdateTokenIfNeeded()
}
}
}
private func scheduleRetry() {
guard retryWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.retryWorkItem = nil
self.pendingUpdate = true
self.tryUpdateTokenIfNeeded()
}
retryWorkItem = workItem
queue.asyncAfter(deadline: .now() + Constants.retryDelay, execute: workItem)
}
private func currentLogin() -> String? {
guard let login = UserDefaults.standard.string(forKey: Keys.currentUser), !login.isEmpty else {
return nil
}
return login
}
}

View File

@ -340,7 +340,17 @@ final class SocketService {
private func handleNewPrivateMessage(_ data: [Any]) {
guard let payload = data.first else { return }
guard let messageData = normalizeMessagePayload(payload) else { return }
let messageData: Data
if let dictionary = payload as? [String: Any],
JSONSerialization.isValidJSONObject(dictionary),
let json = try? JSONSerialization.data(withJSONObject: dictionary, options: []) {
messageData = json
} else if let string = payload as? String,
let data = string.data(using: .utf8) {
messageData = data
} else {
return
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
@ -363,31 +373,6 @@ final class SocketService {
}
}
private func normalizeMessagePayload(_ payload: Any) -> Data? {
// Server can wrap the actual message in an { event, payload } envelope.
if let dictionary = payload as? [String: Any] {
let messageBody = dictionary["payload"] ?? dictionary
if let messageDict = messageBody as? [String: Any],
JSONSerialization.isValidJSONObject(messageDict) {
return try? JSONSerialization.data(withJSONObject: messageDict, options: [])
}
}
if let string = payload as? String,
let data = string.data(using: .utf8) {
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let nested = jsonObject["payload"] {
return normalizeMessagePayload(nested)
}
return data
}
if let data = payload as? Data {
return data
}
return nil
}
private func handleHeartbeatSuccess() {
consecutiveHeartbeatMisses = 0
heartbeatAckInFlight = false

View File

@ -12,61 +12,22 @@ class LoginViewModel: ObservableObject {
@Published var username: String = ""
@Published var userId: String = ""
@Published var password: String = ""
@Published var isLoading: Bool = false
@Published var isInitialLoading: Bool = true // отдельный флаг для сплэша до завершения автологина
@Published var isLoading: Bool = true // сразу 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"
@ -82,10 +43,6 @@ class LoginViewModel: ObservableObject {
autoLogin()
}
deinit {
resendTimer?.invalidate()
}
private func observeSocketState() {
socketService.connectionStatePublisher
.receive(on: DispatchQueue.main)
@ -134,7 +91,6 @@ class LoginViewModel: ObservableObject {
self?.socketService.disconnect()
}
self?.isLoading = false
self?.isInitialLoading = false
}
}
}
@ -143,13 +99,8 @@ class LoginViewModel: ObservableObject {
func login() {
isLoading = true
showError = false
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedLogin != passwordlessLogin {
passwordlessLogin = trimmedLogin
}
username = trimmedLogin
authService.login(username: trimmedLogin, password: password) { [weak self] success, error in
authService.login(username: username, password: password) { [weak self] success, error in
DispatchQueue.main.async {
self?.isLoading = false
if success {
@ -164,94 +115,6 @@ class LoginViewModel: ObservableObject {
}
}
}
func requestPasswordlessCode() {
guard LoginViewModel.isLoginValid(passwordlessLogin) else {
errorMessage = NSLocalizedString("Неверный логин", comment: "")
showError = true
return
}
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
isSendingCode = true
showError = false
authService.requestLoginCode(identifier: trimmedLogin) { [weak self] success, message in
DispatchQueue.main.async {
guard let self else { return }
self.isSendingCode = false
if success {
self.passwordlessLogin = trimmedLogin
self.verificationCode = ""
self.loginFlowStep = .passwordlessVerify
self.startResendTimer()
} else {
if self.handlePasswordlessRedirect(message: message, login: trimmedLogin) {
return
}
self.errorMessage = message ?? NSLocalizedString("Не удалось отправить код.", comment: "")
self.showError = true
}
}
}
}
func verifyPasswordlessCode() {
guard verificationCode.count == Constants.verificationCodeLength,
!passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
}
isVerifyingCode = true
showError = false
authService.loginWithCode(identifier: passwordlessLogin, code: verificationCode) { [weak self] success, message in
DispatchQueue.main.async {
guard let self else { return }
self.isVerifyingCode = false
if success {
self.resendTimer?.invalidate()
self.loadStoredUser()
self.isLoggedIn = true
self.socketService.connectForCurrentUser()
self.verificationCode = ""
} else {
self.errorMessage = message ?? NSLocalizedString("Проверьте введённый код и попробуйте снова.", comment: "")
self.showError = true
// self.verificationCode = ""
}
}
}
}
func resendPasswordlessCode() {
guard resendSecondsRemaining == 0 else { return }
requestPasswordlessCode()
}
func showPasswordLogin() {
resendTimer?.invalidate()
loginFlowStep = .password
}
func showPasswordlessRequest() {
loginFlowStep = .passwordlessRequest
}
func backToPasswordlessRequest() {
verificationCode = ""
loginFlowStep = .passwordlessRequest
}
func showRegistration() {
loginFlowStep = .registration
}
func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
authService.register(username: username, password: password, invite: invite) { [weak self] success, message in
@ -260,7 +123,6 @@ class LoginViewModel: ObservableObject {
self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
self?.loadStoredUser()
self?.socketService.connectForCurrentUser()
self?.onboardingDestination = .afterRegister
} else {
self?.socketService.disconnect()
}
@ -307,121 +169,4 @@ 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
}
}

View File

@ -1,23 +1,12 @@
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
@ -29,22 +18,17 @@ struct PrivateChatView: View {
var body: some View {
ScrollViewReader { proxy in
ZStack(alignment: .bottomTrailing) {
content
.onChange(of: viewModel.messages.count) { _ in
guard !viewModel.isLoadingMore else { return }
scrollToBottom(proxy: proxy)
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
}
.onChange(of: scrollToBottomTrigger) { _ in
scrollToBottom(proxy: proxy)
}
if !isBottomAnchorVisible {
scrollToBottomButton(proxy: proxy)
.padding(.trailing, 12)
.padding(.bottom, 4)
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
@ -99,21 +83,9 @@ 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()
}
@ -156,7 +128,7 @@ struct PrivateChatView: View {
private func messageRow(for message: MessageItem) -> some View {
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
return HStack(alignment: .bottom, spacing: 12) {
return HStack {
if isCurrentUser { Spacer(minLength: 32) }
VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 6) {
@ -166,34 +138,25 @@ struct PrivateChatView: View {
// .foregroundColor(.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)
}
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)
}
.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
@ -237,215 +200,47 @@ struct PrivateChatView: View {
}
private var composer: some View {
VStack(spacing: 10) {
HStack(alignment: .bottom, spacing: 4) {
Button(action: { }) { // переключатель на стикеры
Image(systemName: "paperclip")
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")
.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 || 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)
draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending || currentUserId == nil
}
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
@ -467,171 +262,6 @@ 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.

View File

@ -1,75 +0,0 @@
import SwiftUI
struct LoginTopBar: View {
let openLanguageSettings: () -> Void
let onShowModePrompt: (() -> Void)?
@EnvironmentObject private var themeManager: ThemeManager
@Environment(\.colorScheme) private var colorScheme
private let themeOptions = ThemeOption.ordered
var body: some View {
HStack {
Button(action: openLanguageSettings) {
Text("🌍")
.padding(8)
}
Spacer()
if let onShowModePrompt {
Button(action: onShowModePrompt) {
Text(NSLocalizedString("Режим", comment: ""))
.font(.footnote.bold())
}
Spacer()
}
Menu {
ForEach(themeOptions) { option in
Button(action: { selectTheme(option) }) {
themeMenuContent(for: option)
.opacity(option.isEnabled ? 1.0 : 0.5)
}
.disabled(!option.isEnabled)
}
} label: {
Image(systemName: themeIconName)
.padding(8)
}
}
}
private var selectedThemeOption: ThemeOption {
ThemeOption.option(for: themeManager.theme)
}
private var themeIconName: String {
switch themeManager.theme {
case .system:
return colorScheme == .dark ? "moon.fill" : "sun.max.fill"
case .light:
return "sun.max.fill"
case .oledDark:
return "moon.fill"
}
}
private func themeMenuContent(for option: ThemeOption) -> some View {
let isSelected = option == selectedThemeOption
return HStack(spacing: 8) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundColor(isSelected ? .accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(option.title)
if let note = option.note {
Text(note)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
private func selectTheme(_ option: ThemeOption) {
guard let mappedTheme = option.mappedTheme else { return }
themeManager.setTheme(mappedTheme)
}
}

View File

@ -9,124 +9,11 @@ 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 isShowingTerms = false
@State private var hasResetTermsOnAppear = false
@State private var isShowingForgotPassword = false
@State private var isShowingRegistration = false
@FocusState private var focusedField: Field?
private enum Field: Hashable {
@ -135,162 +22,142 @@ struct PasswordLoginView: View {
}
private var isUsernameValid: Bool {
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
let pattern = "^[A-Za-z0-9_]{3,32}$"
return viewModel.username.range(of: pattern, options: .regularExpression) != nil
}
private var isPasswordValid: Bool {
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)
Button {
ZStack {
Color.clear // чтобы поймать тап
.contentShape(Rectangle())
.onTapGesture {
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(NSLocalizedString("Если предпочитаете классический вход, используйте логин и пароль.", comment: ""))
// .foregroundColor(.secondary)
VStack {
HStack {
Button(action: openLanguageSettings) {
Text("🌍")
.padding()
}
Spacer()
Menu {
ForEach(themeOptions) { option in
Button(action: { selectTheme(option) }) {
themeMenuContent(for: option)
.opacity(option.isEnabled ? 1.0 : 0.5)
}
.disabled(!option.isEnabled)
}
} label: {
Image(systemName: themeIconName)
.padding()
}
}
.onTapGesture {
focusedField = nil
}
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Text("@")
.foregroundColor(.secondary)
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($focusedField, equals: .username)
}
Spacer()
TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
.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))
}
.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 !isPasswordValid && !viewModel.password.isEmpty {
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
.foregroundColor(.red)
.font(.caption)
}
// Показываем ошибку для логина
if !isUsernameValid && !viewModel.username.isEmpty {
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
.foregroundColor(.red)
.font(.caption)
}
// VStack(alignment: .leading, spacing: 4) {
// Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
// .toggleStyle(SwitchToggleStyle(tint: .accentColor))
// Text(isMessengerModeEnabled
// ? "Мессенджер-режим сейчас проработан примерно на 60%."
// : "Основной режим находится в ранней разработке (около 10%).")
// .font(.footnote)
// .foregroundColor(.secondary)
// }
// Показываем поле пароля
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())
.frame(maxWidth: .infinity)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.6))
.cornerRadius(8)
} else {
Text(NSLocalizedString("Войти", comment: ""))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.frame(maxWidth: .infinity)
.background(isButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(8)
}
}
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(12)
.disabled(!isLoginButtonEnabled)
.disabled(!isButtonEnabled)
// Spacer()
// Кнопка регистрации
Button(action: {
isShowingForgotPassword = true
isShowingRegistration = true
}) {
Text(NSLocalizedString("Забыли пароль? Сбросить", comment: ""))
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
.foregroundColor(.blue)
.frame(maxWidth: .infinity)
}
.padding(.top, 4)
Spacer(minLength: 0)
.padding(.top, 10)
.sheet(isPresented: $isShowingRegistration) {
RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration)
}
Spacer()
}
.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
.padding()
.alert(isPresented: $viewModel.showError) {
Alert(
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
message: Text(viewModel.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()
}
}
}
.sheet(isPresented: $isShowingForgotPassword) {
ForgotPasswordInfoView {
isShowingForgotPassword = false
withAnimation {
viewModel.showPasswordlessRequest()
}
} onDismiss: {
isShowingForgotPassword = false
.onTapGesture {
focusedField = nil
}
}
}
@ -305,11 +172,6 @@ struct PasswordLoginView: View {
}
}
private func hideKeyboardAndShowModePrompt() {
focusedField = nil
onShowModePrompt()
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
@ -343,542 +205,10 @@ struct PasswordLoginView: View {
}
private struct PasswordlessRequestView: View {
@ObservedObject var viewModel: LoginViewModel
let shouldAutofocus: Bool
let onShowModePrompt: () -> Void
@FocusState private var isFieldFocused: Bool
private var isLoginValid: Bool {
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Yobble Passport", comment: ""))
.font(.largeTitle).bold()
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("@")
.foregroundColor(.secondary)
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
.textContentType(.username)
.keyboardType(.default)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($isFieldFocused)
.onChange(of: viewModel.passwordlessLogin) { newValue in
if newValue.count > 32 {
viewModel.passwordlessLogin = String(newValue.prefix(32))
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
Button {
withAnimation {
viewModel.requestPasswordlessCode()
}
} label: {
if viewModel.isSendingCode {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else {
Text(NSLocalizedString("Войти", comment: ""))
.bold()
.frame(maxWidth: .infinity)
.padding()
}
}
.foregroundColor(.white)
.background(viewModel.canRequestPasswordlessCode ? Color.blue : Color.gray)
.cornerRadius(12)
.disabled(!viewModel.canRequestPasswordlessCode)
Divider()
Button {
viewModel.hasAcceptedTerms = false
withAnimation {
viewModel.showRegistration()
}
} label: {
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: ""))
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
isFieldFocused = false
}
.onAppear(perform: scheduleFocusIfNeeded)
.onChange(of: shouldAutofocus) { newValue in
if newValue {
scheduleFocusIfNeeded()
} else {
isFieldFocused = false
}
}
.loginErrorAlert(viewModel: viewModel)
}
private func hideKeyboardAndShowModePrompt() {
isFieldFocused = false
onShowModePrompt()
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func scheduleFocusIfNeeded() {
guard shouldAutofocus else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if shouldAutofocus {
isFieldFocused = true
}
}
}
}
private struct LegacySupportNoticeView: View {
@Binding var isPresented: Bool
var body: some View {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture {
isPresented = false
}
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40, weight: .bold))
.foregroundColor(.yellow)
Text("Экспериментальная поддержка iOS 15")
.font(.headline)
.multilineTextAlignment(.center)
Text("Поддержка iOS 15 работает в экспериментальном режиме. Для лучшей совместимости требуется 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.isInitialLoading = false
viewModel.loginFlowStep = step
viewModel.passwordlessLogin = "preview@yobble.app"
viewModel.verificationCode = "123456"
viewModel.isLoading = false // чтобы убрать спиннер
return LoginView(viewModel: viewModel)
}
}
private struct ForgotPasswordInfoView: View {
let onUseCode: () -> Void
let onDismiss: () -> Void
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 16) {
Text(NSLocalizedString("Сброс пароля", comment: ""))
.font(.title2.bold())
Text(NSLocalizedString("Прямого сброса пароля нет: сменить его можно только из настроек, уже будучи в аккаунте. Если привязана почта или другое 2FA-устройство, воспользуйтесь входом по коду - он подтвердит вашу личность и пустит в аккаунт. После входа откройте настройки → безопасность и задайте новый пароль.", comment: ""))
.foregroundColor(.secondary)
Button(action: onUseCode) {
Text(NSLocalizedString("Войти", comment: ""))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.cornerRadius(12)
}
Button(action: onDismiss) {
Text(NSLocalizedString("Закрыть", comment: ""))
.frame(maxWidth: .infinity)
.padding()
}
Spacer()
}
.padding()
.navigationBarHidden(true)
}
}
}

View File

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

View File

@ -1,102 +0,0 @@
import SwiftUI
struct TermsAgreementCard: View {
@Binding var isAccepted: Bool
var openTerms: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Button {
isAccepted.toggle()
} label: {
Image(systemName: isAccepted ? "checkmark.square.fill" : "square")
.font(.system(size: 24, weight: .semibold))
.foregroundColor(isAccepted ? .blue : .secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(NSLocalizedString("Согласиться с правилами", comment: ""))
.accessibilityValue(isAccepted ? NSLocalizedString("Включено", comment: "") : NSLocalizedString("Выключено", comment: ""))
VStack(alignment: .leading, spacing: 6) {
Text(NSLocalizedString("Я ознакомился и принимаю правила сервиса", comment: ""))
.font(.subheadline)
.foregroundColor(.primary)
Button(action: openTerms) {
HStack(spacing: 4) {
Text(NSLocalizedString("Открыть правила", comment: ""))
Image(systemName: "arrow.up.right")
.font(.caption)
}
}
.buttonStyle(.plain)
.font(.footnote)
.foregroundColor(.blue)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.secondarySystemBackground))
.cornerRadius(14)
}
}
struct TermsFullScreenView: View {
@Binding var isPresented: Bool
var title: String
var content: String
var isLoading: Bool
var errorMessage: String?
var onRetry: () -> Void
var body: some View {
NavigationView {
Group {
if isLoading {
ProgressView()
} else if let errorMessage {
VStack(spacing: 16) {
Text(errorMessage)
.multilineTextAlignment(.center)
.padding(.horizontal)
Button(action: onRetry) {
Text(NSLocalizedString("Повторить", comment: ""))
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
}
} else {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let attributed = try? AttributedString(markdown: content) {
Text(attributed)
} else {
Text(content)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: { isPresented = false }) {
Text(NSLocalizedString("Закрыть", comment: ""))
}
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

View File

@ -1,72 +0,0 @@
//
// AfterRegisterView.swift
// yobble
//
// Created by cheykrym on 24.10.2025.
//
import SwiftUI
struct AfterRegisterView: View {
@Binding var isPresented: Bool
@State private var isTwoFactorActive = false
@State private var isEmailSettingsActive = false
@State private var isAppLockActive = false
var body: some View {
NavigationView {
Form {
Section(header: Text(NSLocalizedString("Добро пожаловать в Yobble!", comment: ""))) {
Text(NSLocalizedString("Для начала, мы рекомендуем настроить параметры безопасности вашего аккаунта.", comment: ""))
}
Section(header: Text(NSLocalizedString("Безопасность аккаунта", comment: ""))) {
NavigationLink(destination: TwoFactorAuthView()) {
Label(NSLocalizedString("Двухфакторная аутентификация", comment: ""), systemImage: "lock.shield")
}
NavigationLink(destination: EmailSecuritySettingsView()) {
Label(NSLocalizedString("Настройки email", comment: ""), systemImage: "envelope")
}
}
Section(header: Text(NSLocalizedString("Приложение", comment: ""))) {
NavigationLink(destination: AppLockSettingsView()) {
Label(NSLocalizedString("Пароль на приложение", comment: ""), systemImage: "lock.square")
}
}
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
NavigationLink(destination: EditProfileView()) {
Label(NSLocalizedString("Редактировать профиль", comment: ""), systemImage: "person.crop.circle")
}
NavigationLink(destination: EditPrivacyView()) {
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
}
}
Section {
Button(action: { isPresented = false }) {
Text(NSLocalizedString("Продолжить", comment: ""))
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
.navigationTitle(NSLocalizedString("Начальная настройка", comment: ""))
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(NSLocalizedString("Пропустить", comment: "")) {
isPresented = false
}
}
}
}
}
}
struct AfterRegisterView_Previews: PreviewProvider {
static var previews: some View {
AfterRegisterView(isPresented: .constant(true))
}
}

View File

@ -32,7 +32,6 @@ 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
@ -102,17 +101,16 @@ struct ChatsTab: View {
@ViewBuilder
private var content: some View {
// if viewModel.isInitialLoading && viewModel.chats.isEmpty {
// loadingState
// }
chatList
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
loadingState
} else {
chatList
}
}
private var chatList: some View {
ScrollViewReader { proxy in
ZStack {
List {
ZStack {
List {
// VStack(spacing: 0) {
// searchBar
// .padding(.horizontal, 16)
@ -120,74 +118,63 @@ 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)
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)
.foregroundColor(.orange)
Spacer(minLength: 0)
Button(action: triggerChatsReload) {
Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline)
}
}
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
if isSearching {
Section(header: localSearchHeader) {
if localSearchResults.isEmpty {
emptySearchResultView
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.listRowSeparator(.hidden)
} else {
ForEach(localSearchResults) { chat in
chatRowItem(for: chat)
}
}
}
if isSearching {
Section(header: 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 {
Section(header: globalSearchHeader) {
globalSearchContent
}
} else {
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
// errorState(message: message)
// } else
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
loadingState
if viewModel.chats.isEmpty {
emptyState
} else {
ForEach(viewModel.chats) { chat in
chatRowItem(for: chat)
}
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
}
if viewModel.isLoadingMore {
loadingMoreRow
}
}
}
.listStyle(.plain)
.modifier(ScrollDismissesKeyboardModifier())
.simultaneousGesture(searchBarGesture)
.simultaneousGesture(tapToDismissKeyboardGesture)
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldScrollToTop)) { _ in
scrollChatsToTop(using: proxy)
}
}
.listStyle(.plain)
.modifier(ScrollDismissesKeyboardModifier())
.simultaneousGesture(searchBarGesture)
.simultaneousGesture(tapToDismissKeyboardGesture)
// .safeAreaInset(edge: .top) {
// VStack(spacing: 0) {
// searchBar
@ -199,8 +186,7 @@ struct ChatsTab: View {
// .background(Color(UIColor.systemBackground))
// }
pendingChatNavigationLink
}
pendingChatNavigationLink
}
}
@ -231,14 +217,6 @@ struct ChatsTab: View {
}
}
private func scrollChatsToTop(using proxy: ScrollViewProxy) {
DispatchQueue.main.async {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
proxy.scrollTo(scrollToTopAnchorId, anchor: .top)
}
}
}
private var searchBarGesture: some Gesture {
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
@ -341,10 +319,8 @@ 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))
}
}
}
@ -364,26 +340,14 @@ 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 {
HStack {
Spacer()
VStack(spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Spacer()
Text(NSLocalizedString("Загружаем чаты…", comment: ""))
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 18)
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorState(message: String) -> some View {
@ -407,15 +371,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()
@ -478,7 +442,7 @@ struct ChatsTab: View {
}
.hidden()
)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
// .listRowSeparator(.hidden)
.onAppear {
guard !isSearching else { return }
@ -1217,5 +1181,4 @@ 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")
}

View File

@ -1,384 +0,0 @@
import SwiftUI
import Foundation
struct ContactsTab: View {
@State private var contacts: [Contact] = []
@State private var isLoading = false
@State private var loadError: String?
@State private var pagingError: String?
@State private var activeAlert: ContactsAlert?
@State private var hasMore = true
@State private var offset = 0
private let contactsService = ContactsService()
private let pageSize = 25
var body: some View {
List {
if isLoading && contacts.isEmpty {
loadingState
}
if let loadError, contacts.isEmpty {
errorState(loadError)
} else if contacts.isEmpty {
emptyState
} else {
ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in
Button {
showContactPlaceholder(for: contact)
} label: {
ContactRow(contact: contact)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button {
handleContactAction(.edit, for: contact)
} label: {
Label(
NSLocalizedString("Изменить контакт", comment: "Contacts context action edit"),
systemImage: "square.and.pencil"
)
}
Button {
handleContactAction(.block, for: contact)
} label: {
Label(
NSLocalizedString("Заблокировать контакт", comment: "Contacts context action block"),
systemImage: "hand.raised.fill"
)
}
Button(role: .destructive) {
handleContactAction(.delete, for: contact)
} label: {
Label(
NSLocalizedString("Удалить контакт", comment: "Contacts context action delete"),
systemImage: "trash"
)
}
}
.listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12))
.onAppear {
if index >= contacts.count - 5 {
Task {
await loadContacts(reset: false)
}
}
}
}
if isLoading && !contacts.isEmpty {
loadingState
} else if let pagingError, !contacts.isEmpty {
pagingErrorState(pagingError)
}
}
}
.background(Color(UIColor.systemBackground))
.listStyle(.plain)
.task {
await loadContacts(reset: false)
}
.refreshable {
await refreshContacts()
}
.alert(item: $activeAlert) { alert in
switch alert {
case .error(_, let message):
return Alert(
title: Text(NSLocalizedString("Ошибка", comment: "Contacts load error title")),
message: Text(message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
case .info(_, let title, let message):
return Alert(
title: Text(title),
message: Text(message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
}
}
}
private var loadingState: some View {
HStack {
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Spacer()
}
.padding(.vertical, 18)
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
.listRowSeparator(.hidden)
}
private func errorState(_ message: String) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer()
Button(action: { Task { await refreshContacts() } }) {
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
.font(.subheadline)
}
}
.padding(.vertical, 10)
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
.listRowSeparator(.hidden)
}
private func pagingErrorState(_ message: String) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer()
Button(action: { Task { await loadContacts(reset: false) } }) {
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
.font(.subheadline)
}
}
.padding(.vertical, 10)
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
.listRowSeparator(.hidden)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "person.crop.circle.badge.questionmark")
.font(.system(size: 52))
.foregroundColor(.secondary)
Text(NSLocalizedString("Контактов пока нет", comment: "Contacts empty state title"))
.font(.headline)
.multilineTextAlignment(.center)
Text(NSLocalizedString("Добавьте контакты, чтобы быстрее выходить на связь.", comment: "Contacts empty state subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.listRowInsets(EdgeInsets(top: 20, leading: 12, bottom: 20, trailing: 12))
.listRowSeparator(.hidden)
}
@MainActor
private func refreshContacts() async {
hasMore = true
offset = 0
pagingError = nil
loadError = nil
contacts.removeAll()
await loadContacts(reset: true)
}
@MainActor
private func loadContacts(reset: Bool) async {
if isLoading { return }
if !reset && !hasMore { return }
isLoading = true
if offset == 0 {
loadError = nil
}
pagingError = nil
do {
let payload = try await contactsService.fetchContacts(limit: pageSize, offset: offset)
let newContacts = payload.items.map(Contact.init)
if reset {
contacts = newContacts
} else {
contacts.append(contentsOf: newContacts)
}
offset += newContacts.count
hasMore = payload.hasMore
} catch {
let message = error.localizedDescription
if contacts.isEmpty {
loadError = message
} else {
pagingError = message
}
if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") }
}
isLoading = false
}
private func showContactPlaceholder(for contact: Contact) {
activeAlert = .info(
title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"),
message: String(
format: NSLocalizedString("Просмотр \"%1$@\" появится позже.", comment: "Contacts placeholder message"),
contact.displayName
)
)
}
private func handleContactAction(_ action: ContactAction, for contact: Contact) {
activeAlert = .info(
title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"),
message: action.placeholderMessage(for: contact)
)
}
}
private struct ContactRow: View {
let contact: Contact
var body: some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 40, height: 40)
.overlay(
Text(contact.initials)
.font(.headline)
.foregroundColor(.accentColor)
)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(contact.displayName)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
Spacer()
Text(contact.formattedCreatedAt)
.font(.caption2)
.foregroundColor(.secondary)
}
if let handle = contact.handle {
Text(handle)
.font(.caption2)
.foregroundColor(.secondary)
}
if contact.friendCode {
friendCodeBadge
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 6)
}
private var friendCodeBadge: some View {
Text(NSLocalizedString("Код дружбы", comment: "Friend code badge"))
.font(.caption2.weight(.medium))
.foregroundColor(Color.accentColor)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.accentColor.opacity(0.12))
.clipShape(Capsule())
}
}
private struct Contact: Identifiable, Equatable {
let id: UUID
let login: String
let fullName: String?
let customName: String?
let friendCode: Bool
let createdAt: Date
let displayName: String
let handle: String?
var initials: String {
let components = displayName.split(separator: " ")
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials
.map { String($0).uppercased() }
.joined()
}
let filtered = login.filter { $0.isLetter }.prefix(2)
if !filtered.isEmpty {
return filtered.uppercased()
}
return "??"
}
var formattedCreatedAt: String {
Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date())
}
init(payload: ContactPayload) {
self.id = payload.userId
self.login = payload.login
self.fullName = payload.fullName
self.customName = payload.customName
self.friendCode = payload.friendCode
self.createdAt = payload.createdAt
if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = customName
} else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = fullName
} else {
self.displayName = payload.login
}
if !payload.login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.handle = "@\(payload.login)"
} else {
self.handle = nil
}
}
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter
}()
}
private enum ContactsAlert: Identifiable {
case error(id: UUID = UUID(), message: String)
case info(id: UUID = UUID(), title: String, message: String)
var id: String {
switch self {
case .error(let id, _), .info(let id, _, _):
return id.uuidString
}
}
}
private enum ContactAction {
case edit
case block
case delete
func placeholderMessage(for contact: Contact) -> String {
switch self {
case .edit:
return String(
format: NSLocalizedString("Изменение контакта \"%1$@\" появится позже.", comment: "Contacts edit placeholder message"),
contact.displayName
)
case .block:
return String(
format: NSLocalizedString("Блокировка контакта \"%1$@\" появится позже.", comment: "Contacts block placeholder message"),
contact.displayName
)
case .delete:
return String(
format: NSLocalizedString("Удаление контакта \"%1$@\" появится позже.", comment: "Contacts delete placeholder message"),
contact.displayName
)
}
}
}

View File

@ -2,48 +2,37 @@ import SwiftUI
struct CustomTabBar: View {
@Binding var selectedTab: Int
let isMessengerModeEnabled: Bool
var onCreate: () -> Void
var body: some View {
HStack {
if isMessengerModeEnabled {
// Tab 1: Feed
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
selectedTab = 0
}
TabBarButton(systemName: "person.2.fill", text: NSLocalizedString("Контакты", comment: ""), isSelected: selectedTab == 4) {
selectedTab = 4
}
// Tab 2: Search
TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) {
selectedTab = 1
}
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
handleChatsTabTap()
}
// Create Button
CreateButton {
onCreate()
}
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 3: Chats
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
selectedTab = 2
}
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
}
// Tab 4: Profile
TabBarButton(systemName: "person.crop.square", text: NSLocalizedString("Лицо", comment: ""), isSelected: selectedTab == 3) {
selectedTab = 3
}
}
.padding(.horizontal)
.padding(.top, isMessengerModeEnabled ? 6 : 1)
.padding(.top, 1)
.padding(.bottom, 30) // Добавляем отступ снизу
// .background(Color(.systemGray6))
}
@ -93,13 +82,3 @@ struct CreateButton: View {
.offset(y: -3)
}
}
private extension CustomTabBar {
func handleChatsTabTap() {
if selectedTab == 2 {
NotificationCenter.default.post(name: .chatsShouldScrollToTop, object: nil)
} else {
selectedTab = 2
}
}
}

View File

@ -2,9 +2,7 @@ import SwiftUI
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
@ -18,21 +16,14 @@ 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 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: "")
case 0: return "Home"
case 1: return "Concept"
case 2: return "Chats"
case 3: return "Profile"
default: return "Home"
}
}
@ -48,54 +39,36 @@ struct MainView: View {
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 {
if isMessengerModeEnabled {
ChatsTab(
loginViewModel: viewModel,
searchRevealProgress: $chatSearchRevealProgress,
searchText: $chatSearchText
)
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)
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)
}
ProfileTab()
.opacity(selectedTab == 3 ? 1 : 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
CustomTabBar(selectedTab: $selectedTab, isMessengerModeEnabled: isMessengerModeEnabled) {
CustomTabBar(selectedTab: $selectedTab) {
print("Create button tapped")
}
}
@ -118,49 +91,41 @@ struct MainView: View {
.allowsHitTesting(menuOffset > 0)
// Боковое меню
if !isMessengerModeEnabled {
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
.frame(width: menuWidth)
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
.ignoresSafeArea(edges: .vertical)
}
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
.frame(width: menuWidth)
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
.ignoresSafeArea(edges: .vertical)
}
deepLinkNavigationLink
}
.gesture(
DragGesture()
.onChanged { gesture in
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))
}
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 !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
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
}
}
)
@ -172,100 +137,11 @@ 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,
let target = messageCenter.pendingNavigation else { return }
withAnimation(.easeInOut) {
isSideMenuPresented = false
menuOffset = 0
}
if !chatSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
chatSearchText = ""
}
if chatSearchRevealProgress > 0 {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
chatSearchRevealProgress = 0
}
}
deepLinkChatItem = target.chat
isDeepLinkChatActive = true
NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil)
DispatchQueue.main.async {
messageCenter.pendingNavigation = nil
}
}
.onChange(of: selectedTab) { newValue in
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()
}
}
}
@ -273,7 +149,6 @@ struct MainView_Previews: PreviewProvider {
static var previews: some View {
let mockViewModel = LoginViewModel()
MainView(viewModel: mockViewModel)
.environmentObject(IncomingMessageCenter())
.environmentObject(ThemeManager())
}
}

View File

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

View File

@ -1,283 +0,0 @@
import SwiftUI
struct BlockedUsersView: View {
@State private var blockedUsers: [BlockedUser] = []
@State private var isLoading = false
@State private var hasMore = true
@State private var offset = 0
@State private var loadError: String?
@State private var pendingUnblock: BlockedUser?
@State private var showUnblockConfirmation = false
@State private var removingUserIds: Set<UUID> = []
@State private var activeAlert: ActiveAlert?
@State private var errorMessageDown: String?
private let blockedUsersService = BlockedUsersService()
private let limit = 20
var body: some View {
List {
if isLoading && blockedUsers.isEmpty {
initialLoadingState
} else if let loadError, blockedUsers.isEmpty {
errorState(loadError)
} else if blockedUsers.isEmpty {
emptyState
} else {
usersSection
}
}
.navigationTitle(NSLocalizedString("Чёрный список", comment: ""))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
activeAlert = .addPlaceholder
} label: {
Image(systemName: "plus")
}
}
}
.task {
await loadBlockedUsers()
}
.alert(item: $activeAlert) { alert in
switch alert {
case .addPlaceholder:
return Alert(
title: Text(NSLocalizedString("Скоро", comment: "Add blocked user placeholder title")),
message: Text(NSLocalizedString("Добавление новых блокировок появится позже.", comment: "Add blocked user placeholder message")),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
case .error(_, let message):
return Alert(
title: Text(NSLocalizedString("Ошибка", comment: "Common error title")),
message: Text(message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
}
}
.confirmationDialog(
NSLocalizedString("Удалить из заблокированных?", comment: "Unblock confirmation title"),
isPresented: $showUnblockConfirmation,
presenting: pendingUnblock
) { user in
Button(NSLocalizedString("Разблокировать", comment: "Unblock confirmation action"), role: .destructive) {
pendingUnblock = nil
showUnblockConfirmation = false
Task {
await unblock(user)
}
}
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
pendingUnblock = nil
showUnblockConfirmation = false
}
} message: { user in
Text(String(format: NSLocalizedString("Пользователь \"%1$@\" будет удалён из списка заблокированных.", comment: "Unblock confirmation message"), user.displayName))
}
}
private var usersSection: some View {
Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) {
ForEach(Array(blockedUsers.enumerated()), id: \.element.id) { index, user in
userRow(user, index: index)
}
if isLoading && !blockedUsers.isEmpty {
Text("Идет загрузка...")
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else if let errorMessage = errorMessageDown {
Text(errorMessage)
.foregroundColor(.red)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
}
private func userRow(_ user: BlockedUser, index: Int) -> some View {
HStack(spacing: 12) {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 44, height: 44)
.overlay(
Text(user.initials)
.font(.headline)
.foregroundColor(.accentColor)
)
VStack(alignment: .leading, spacing: 4) {
Text(user.displayName)
.font(.body)
if let handle = user.handle {
Text(handle)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(.vertical, 0)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
pendingUnblock = user
showUnblockConfirmation = true
} label: {
Label(NSLocalizedString("Разблокировать", comment: ""), systemImage: "person.crop.circle.badge.xmark")
}
.disabled(removingUserIds.contains(user.id))
}
.onAppear {
if index >= blockedUsers.count - 5 {
Task {
await loadBlockedUsers()
}
}
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "hand.raised")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text(NSLocalizedString("У вас нет заблокированных пользователей", comment: ""))
.font(.headline)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 32)
.listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
.listRowSeparator(.hidden)
}
private var initialLoadingState: some View {
Section {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func errorState(_ message: String) -> some View {
Section {
Text(message)
.foregroundColor(.red)
.frame(maxWidth: .infinity, alignment: .center)
}
}
@MainActor
private func loadBlockedUsers() async {
errorMessageDown = nil
guard !isLoading, hasMore else {
return
}
isLoading = true
defer { isLoading = false }
if offset == 0 {
loadError = nil
}
do {
let payload = try await blockedUsersService.fetchBlockedUsers(limit: limit, offset: offset)
blockedUsers.append(contentsOf: payload.items.map(BlockedUser.init))
offset += payload.items.count
hasMore = payload.hasMore
} catch {
let message = error.localizedDescription
if offset == 0 {
loadError = message
}
// activeAlert = .error(message: message)
errorMessageDown = message
if AppConfig.DEBUG { print("[BlockedUsersView] load blocked users failed: \(error)") }
}
}
@MainActor
private func unblock(_ user: BlockedUser) async {
guard !removingUserIds.contains(user.id) else { return }
removingUserIds.insert(user.id)
defer { removingUserIds.remove(user.id) }
do {
_ = try await blockedUsersService.remove(userId: user.id)
blockedUsers.removeAll { $0.id == user.id }
} catch {
activeAlert = .error(message: error.localizedDescription)
if AppConfig.DEBUG { print("[BlockedUsersView] unblock failed: \(error)") }
}
}
}
private struct BlockedUser: Identifiable, Equatable {
let id: UUID
let login: String
let fullName: String?
let customName: String?
let createdAt: Date
private(set) var displayName: String
private(set) var handle: String?
var initials: String {
let components = displayName.split(separator: " ")
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials
.map { String($0).uppercased() }
.joined()
}
if let handle {
let filtered = handle.filter { $0.isLetter }.prefix(2)
if !filtered.isEmpty {
return filtered.uppercased()
}
}
return "??"
}
init(payload: BlockedUserInfo) {
self.id = payload.userId
self.login = payload.login
self.fullName = payload.fullName
self.customName = payload.customName
self.createdAt = payload.createdAt
if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = customName
} else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = fullName
} else {
self.displayName = payload.login
}
if !payload.login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.handle = "@\(payload.login)"
} else {
self.handle = nil
}
}
}
private enum ActiveAlert: Identifiable {
case addPlaceholder
case error(id: UUID = UUID(), message: String)
var id: String {
switch self {
case .addPlaceholder:
return "addPlaceholder"
case .error(let id, _):
return id.uuidString
}
}
}

View File

@ -25,7 +25,6 @@ struct FeedbackView: View {
ratingSection
suggestionSection
contactSection
infoSection2
Button(action: submitSuggestion) {
HStack(spacing: 10) {
@ -57,7 +56,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 {
@ -99,24 +98,6 @@ struct FeedbackView: View {
)
}
private var infoSection2: some View {
VStack(alignment: .leading, spacing: 8) {
Label {
Text(NSLocalizedString("Ваш отзыв создаст чат с командой поддержки, который появится в общем списке чатов.", comment: "feedback: info detail chat"))
} icon: {
Image(systemName: "lock.shield.fill")
.foregroundColor(.accentColor)
}
.font(.callout)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.accentColor.opacity(0.08))
)
}
private var categorySection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionTitle(NSLocalizedString("Что вы хотите обсудить?", comment: "feedback: category title"))
@ -196,9 +177,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("Уведомить об ответе по e-mail", comment: "feedback: contact toggle"), isOn: $wantsResponse)
Toggle(NSLocalizedString("Получить ответ от команды", comment: "feedback: contact toggle"), isOn: $wantsResponse)
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if wantsResponse {

View File

@ -1,27 +0,0 @@
import SwiftUI
struct OtherSettingsView: View {
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
var body: some View {
Form {
VStack(alignment: .leading, spacing: 4) {
Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text(isMessengerModeEnabled
? "Мессенджер-режим сейчас проработан примерно на 50%."
: "Основной режим находится в ранней разработке (около 10%).")
.font(.footnote)
.foregroundColor(.secondary)
}
.padding(.vertical, 8)
}
.navigationTitle(Text(NSLocalizedString("Другое", comment: "")))
}
}
#Preview {
NavigationView {
OtherSettingsView()
}
}

View File

@ -1,382 +0,0 @@
import SwiftUI
struct ActiveSessionsView: View {
@State private var sessions: [SessionViewData] = []
@State private var isLoading = false
@State private var loadError: String?
@State private var revokeInProgress = false
@State private var activeAlert: SessionsAlert?
@State private var showRevokeConfirmation = false
@State private var sessionPendingRevoke: SessionViewData?
@State private var revokingSessionIds: Set<UUID> = []
private let sessionsService = SessionsService()
private var currentSession: SessionViewData? {
sessions.first { $0.isCurrent }
}
private var otherSessions: [SessionViewData] {
sessions.filter { !$0.isCurrent }
}
var body: some View {
List {
if isLoading && sessions.isEmpty {
loadingState
} else if let loadError, sessions.isEmpty {
errorState(loadError)
} else if sessions.isEmpty {
emptyState
} else {
Section {
HStack {
Text(NSLocalizedString("Всего сессий", comment: "Сводка по количеству сессий"))
.font(.subheadline)
Spacer()
Text("\(sessions.count)")
.font(.subheadline.weight(.semibold))
}
if !otherSessions.isEmpty {
Text(String(format: NSLocalizedString("Сессий на других устройствах: %d", comment: "Количество сессий на других устройствах"), otherSessions.count))
.font(.footnote)
.foregroundColor(.secondary)
}
}
Section(header: Text(NSLocalizedString("Это устройство", comment: "Заголовок секции текущего устройства"))) {
if let currentSession {
sessionRow(for: currentSession)
} else {
Text(NSLocalizedString("Текущая сессия не найдена", comment: "Сообщение об отсутствии текущей сессии"))
.font(.footnote)
.foregroundColor(.secondary)
.padding(.vertical, 8)
}
}
if !otherSessions.isEmpty{
Section {
revokeOtherSessionsButton
}
}
if !otherSessions.isEmpty {
Section(header: Text(String(format: NSLocalizedString("Другие устройства (%d)", comment: "Заголовок секции других устройств с количеством"), otherSessions.count))) {
ForEach(otherSessions) { session in
let isRevoking = isRevoking(session: session)
sessionRow(for: session, isRevoking: isRevoking)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
sessionPendingRevoke = session
} label: {
Label(NSLocalizedString("Завершить", comment: "Кнопка завершения конкретной сессии"), systemImage: "trash")
}
.disabled(isRevoking)
}
.disabled(isRevoking)
}
}
}
}
}
.navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий"))
.navigationBarTitleDisplayMode(.inline)
.task {
await loadSessions()
}
.refreshable {
await loadSessions(force: true)
}
.confirmationDialog(
NSLocalizedString("Завершить эту сессию?", comment: "Заголовок подтверждения завершения отдельной сессии"),
isPresented: Binding(
get: { sessionPendingRevoke != nil },
set: { if !$0 { sessionPendingRevoke = nil } }
),
presenting: sessionPendingRevoke
) { session in
Button(NSLocalizedString("Завершить", comment: "Подтверждение завершения конкретной сессии"), role: .destructive) {
sessionPendingRevoke = nil
Task { await revoke(session: session) }
}
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {
sessionPendingRevoke = nil
}
} message: { _ in
Text(NSLocalizedString("Вы выйдете из выбранной сессии.", comment: "Описание подтверждения завершения конкретной сессии"))
}
.alert(item: $activeAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
)
}
.confirmationDialog(
NSLocalizedString("Завершить сессии на других устройствах?", comment: "Заголовок подтверждения завершения сессий"),
isPresented: $showRevokeConfirmation
) {
Button(NSLocalizedString("Завершить", comment: "Подтверждение завершения других сессий"), role: .destructive) {
Task { await revokeOtherSessions() }
}
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {}
} message: {
Text(NSLocalizedString("Вы выйдете со всех устройств, кроме текущего.", comment: "Описание подтверждения завершения сессий"))
}
}
private var loadingState: some View {
Section {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func errorState(_ message: String) -> some View {
Section {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 40))
.foregroundColor(.orange)
Text(message)
.font(.body)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
}
private var emptyState: some View {
Section {
VStack(spacing: 12) {
Image(systemName: "iphone")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text(NSLocalizedString("Активные сессии не найдены", comment: "Пустой список активных сессий"))
.font(.headline)
.multilineTextAlignment(.center)
Text(NSLocalizedString("Войдите с другого устройства, чтобы увидеть его здесь.", comment: "Подсказка при отсутствии активных сессий"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
.listRowSeparator(.hidden)
}
private func sessionRow(for session: SessionViewData, isRevoking: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text(session.clientTypeDisplay)
.font(.headline)
if let ip = session.ipAddress, !ip.isEmpty {
Label(ip, systemImage: "globe")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
Spacer()
if session.isCurrent {
Text(NSLocalizedString("Текущая", comment: "Маркер текущей сессии"))
.font(.caption2.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.accentColor.opacity(0.15))
.foregroundColor(.accentColor)
.clipShape(Capsule())
} else if isRevoking {
ProgressView()
.progressViewStyle(.circular)
}
}
if let userAgent = session.userAgent, !userAgent.isEmpty {
Text(userAgent)
.font(.footnote)
.foregroundColor(.secondary)
.lineLimit(3)
}
VStack(alignment: .leading, spacing: 6) {
Label(session.firstLoginText, systemImage: "calendar")
.font(.caption)
.foregroundColor(.secondary)
Label(session.lastLoginText, systemImage: "arrow.clockwise")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 6)
}
@MainActor
private func loadSessions(force: Bool = false) async {
if isLoading && !force {
return
}
isLoading = true
loadError = nil
do {
let payloads = try await sessionsService.fetchSessions()
sessions = payloads.map(SessionViewData.init)
} catch {
loadError = error.localizedDescription
if AppConfig.DEBUG {
print("[ActiveSessionsView] load sessions failed: \(error)")
}
}
isLoading = false
}
@MainActor
private func revokeOtherSessions() async {
if revokeInProgress {
return
}
revokeInProgress = true
defer { revokeInProgress = false }
do {
let message = try await sessionsService.revokeAllExceptCurrent()
activeAlert = SessionsAlert(
title: NSLocalizedString("Готово", comment: "Заголовок успешного уведомления"),
message: message
)
await loadSessions(force: true)
} catch {
activeAlert = SessionsAlert(
title: NSLocalizedString("Ошибка", comment: "Заголовок сообщения об ошибке"),
message: error.localizedDescription
)
}
}
@MainActor
private func revoke(session: SessionViewData) async {
guard !session.isCurrent, !isRevoking(session: session) else {
return
}
revokingSessionIds.insert(session.id)
defer { revokingSessionIds.remove(session.id) }
do {
let message = try await sessionsService.revoke(sessionId: session.id)
sessions.removeAll { $0.id == session.id }
activeAlert = SessionsAlert(
title: NSLocalizedString("Готово", comment: "Заголовок успешного уведомления"),
message: message
)
} catch {
activeAlert = SessionsAlert(
title: NSLocalizedString("Ошибка", comment: "Заголовок сообщения об ошибке"),
message: error.localizedDescription
)
}
}
private func isRevoking(session: SessionViewData) -> Bool {
revokingSessionIds.contains(session.id)
}
private var revokeOtherSessionsButton: some View {
let primaryColor: Color = revokeInProgress ? .secondary : .red
return Button {
if !revokeInProgress {
showRevokeConfirmation = true
}
} label: {
HStack(spacing: 12) {
if revokeInProgress {
ProgressView()
.progressViewStyle(.circular)
} else {
Image(systemName: "xmark.circle")
.foregroundColor(primaryColor)
}
VStack(alignment: .leading, spacing: 4) {
Text(NSLocalizedString("Завершить другие сессии", comment: "Кнопка завершения других сессий"))
.foregroundColor(primaryColor)
Text(NSLocalizedString("Текущая сессия останется активной", comment: "Подсказка под кнопкой завершения других сессий"))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.disabled(revokeInProgress)
}
}
private struct SessionViewData: Identifiable, Equatable {
let id: UUID
let ipAddress: String?
let userAgent: String?
let clientType: String
let isActive: Bool
let createdAt: Date
let lastRefreshAt: Date
let isCurrent: Bool
init(payload: UserSessionPayload) {
self.id = payload.id
self.ipAddress = payload.ipAddress
self.userAgent = payload.userAgent
self.clientType = payload.clientType
self.isActive = payload.isActive
self.createdAt = payload.createdAt
self.lastRefreshAt = payload.lastRefreshAt
self.isCurrent = payload.isCurrent
}
var clientTypeDisplay: String {
let normalized = clientType.lowercased()
switch normalized {
case "mobile":
return NSLocalizedString("Мобильное приложение", comment: "Тип сессии — мобильное приложение")
case "web":
return NSLocalizedString("Веб", comment: "Тип сессии — веб")
case "desktop":
return NSLocalizedString("Десктоп", comment: "Тип сессии — десктоп")
case "bot":
return NSLocalizedString("Бот", comment: "Тип сессии — бот")
default:
return clientType.capitalized
}
}
var firstLoginText: String {
let formatted = Self.dateFormatter.string(from: createdAt)
return String(format: NSLocalizedString("Первый вход: %@", comment: "Дата первого входа в сессию"), formatted)
}
var lastLoginText: String {
let formatted = Self.dateFormatter.string(from: lastRefreshAt)
return String(format: NSLocalizedString("Последний вход: %@", comment: "Дата последнего входа в сессию"), formatted)
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.timeZone = .current
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()
}
private struct SessionsAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}

View File

@ -1,82 +0,0 @@
import SwiftUI
struct AppLockSettingsView: View {
@State private var desiredPassword: String = ""
@State private var confirmationPassword: String = ""
@State private var activeAlert: AppLockAlert?
@FocusState private var focusedField: Field?
private enum Field: Hashable {
case desired
case confirmation
}
var body: some View {
Form {
Section(header: Text(NSLocalizedString("Пароль-приложение", comment: "Раздел формы установки пароля на приложение"))) {
SecureField(NSLocalizedString("Введите пароль", comment: "Поле ввода пароля на приложение"), text: $desiredPassword)
.focused($focusedField, equals: .desired)
SecureField(NSLocalizedString("Повторите пароль", comment: "Поле подтверждения пароля на приложение"), text: $confirmationPassword)
.focused($focusedField, equals: .confirmation)
Button(NSLocalizedString("Сохранить пароль", comment: "Кнопка сохранения пароля на приложение")) {
handleSaveTapped()
}
.disabled(desiredPassword.isEmpty || confirmationPassword.isEmpty)
}
Section {
Text(NSLocalizedString("Настоящая защита приложения появится позже. Пока вы можете ознакомится с макетом.", comment: "Описание заглушки для пароля на приложение"))
.font(.callout)
.foregroundColor(.secondary)
}
}
.navigationTitle(NSLocalizedString("Пароль на приложение", comment: "Заголовок экрана пароля на приложение"))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
focusedField = .desired
}
}
.alert(item: $activeAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
)
}
}
private func handleSaveTapped() {
guard !desiredPassword.isEmpty, desiredPassword == confirmationPassword else {
activeAlert = AppLockAlert(
title: NSLocalizedString("Пароли не совпадают", comment: "Заголовок ошибки несовпадения паролей"),
message: NSLocalizedString("Проверьте ввод и попробуйте снова.", comment: "Сообщение ошибки несовпадения паролей"))
return
}
activeAlert = AppLockAlert(
title: NSLocalizedString("Скоро", comment: "Заголовок заглушки"),
message: NSLocalizedString("Защита приложением будет добавлена в будущих обновлениях.", comment: "Сообщение заглушки пароля на приложение")
)
desiredPassword.removeAll()
confirmationPassword.removeAll()
}
}
private struct AppLockAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
#if DEBUG
struct AppLockSettingsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
AppLockSettingsView()
}
}
}
#endif

View File

@ -1,65 +0,0 @@
import SwiftUI
struct EmailSecuritySettingsView: View {
@State private var isLoginCodesEnabled = false
@State private var activeAlert: EmailSecurityAlert?
var body: some View {
Form {
Section(header: Text(NSLocalizedString("Защита входа", comment: "Раздел защиты входа через email"))) {
Toggle(NSLocalizedString("Получать коды на email при входе", comment: "Переключатель отправки кодов при входе"), isOn: Binding(
get: { isLoginCodesEnabled },
set: { _ in
activeAlert = EmailSecurityAlert(
title: NSLocalizedString("Скоро", comment: "Заголовок заглушки"),
message: NSLocalizedString("Функция пока недоступна.", comment: "Сообщение заглушки")
)
isLoginCodesEnabled = false
}
))
Text(NSLocalizedString("Мы отправим код подтверждения на привязанный email каждый раз при входе.", comment: "Описание работы кодов при входе"))
.font(.footnote)
.foregroundColor(.secondary)
}
Section(header: Text(NSLocalizedString("Подтверждение email", comment: "Раздел подтверждения email"))) {
Text(NSLocalizedString("Email не подтверждён. Подтвердите, чтобы активировать дополнительные проверки.", comment: "Описание необходимости подтверждения email"))
.font(.callout)
.foregroundColor(.secondary)
Button(NSLocalizedString("Отправить письмо подтверждения", comment: "Кнопка отправки письма подтверждения")) {
activeAlert = EmailSecurityAlert(
title: NSLocalizedString("Скоро", comment: "Заголовок заглушки"),
message: NSLocalizedString("Мы отправим письмо, как только функция будет готова.", comment: "Сообщение при недоступной отправке письма")
)
}
}
}
.navigationTitle(NSLocalizedString("Email", comment: "Заголовок экрана настроек email"))
.navigationBarTitleDisplayMode(.inline)
.alert(item: $activeAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
)
}
}
}
private struct EmailSecurityAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
#if DEBUG
struct EmailSecuritySettingsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
EmailSecuritySettingsView()
}
}
}
#endif

View File

@ -1,226 +0,0 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct TwoFactorAuthView: View {
@State private var isTwoFactorEnabled = false
@State private var showEnableConfirmation = false
@State private var showDisableConfirmation = false
@State private var secretKey: String = TwoFactorAuthView.generateSecret()
@State private var verificationCode: String = ""
@State private var backupCodes: [String] = []
@State private var activeAlert: TwoFactorAlert?
@FocusState private var isCodeFieldFocused: Bool
var body: some View {
List {
Section(header: Text(NSLocalizedString("Статус защиты", comment: "Раздел состояния 2FA"))) {
Toggle(isOn: Binding(
get: { isTwoFactorEnabled },
set: { handleToggleChange($0) }
)) {
Label(NSLocalizedString("Включить 2FA", comment: "Тоггл активации 2FA"), systemImage: "lock.shield")
}
}
if isTwoFactorEnabled {
Section(header: Text(NSLocalizedString("Настройка приложения", comment: "Раздел инструкций подключения"))) {
Text(NSLocalizedString("Добавьте новый аккаунт в приложении аутентификации и введите следующий ключ:", comment: "Инструкция по добавлению ключа 2FA"))
.font(.callout)
keyRow
}
Section(header: Text(NSLocalizedString("Проверочный код", comment: "Раздел верификации 2FA"))) {
VStack(alignment: .leading, spacing: 12) {
TextField(NSLocalizedString("Введите код из приложения", comment: "Поле ввода кода 2FA"), text: $verificationCode)
.keyboardType(.numberPad)
.focused($isCodeFieldFocused)
.onChange(of: verificationCode) { newValue in
verificationCode = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
}
Button(action: verifyCode) {
Text(NSLocalizedString("Подтвердить", comment: "Кнопка подтверждения кода 2FA"))
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(verificationCode.isEmpty)
}
.padding(.vertical, 4)
}
Section(header: Text(NSLocalizedString("Коды восстановления", comment: "Раздел кодов восстановления 2FA"))) {
if backupCodes.isEmpty {
Text(NSLocalizedString("Сгенерируйте резервные коды и сохраните их в надежном месте.", comment: "Подсказка о необходимости генерации кодов"))
.font(.callout)
.foregroundColor(.secondary)
} else {
ForEach(backupCodes, id: \.self) { code in
HStack {
Text(code)
.font(.system(.body, design: .monospaced))
Spacer()
Button(action: { copyToPasteboard(code) }) {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.plain)
.accessibilityLabel(NSLocalizedString("Скопировать код", comment: "Кнопка копирования кода восстановления"))
}
}
}
Button(action: generateBackupCodes) {
Label(NSLocalizedString("Создать новые коды", comment: "Кнопка генерации резервных кодов"), systemImage: "arrow.clockwise")
}
}
Section(footer: Text(NSLocalizedString("Вы всегда можете отключить двухфакторную защиту, но мы рекомендуем оставлять её включённой для безопасности.", comment: "Рекомендация оставить 2FA включенной"))) {
EmptyView()
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(NSLocalizedString("Двухфакторная аутентификация", comment: "Заголовок экрана 2FA"))
.navigationBarTitleDisplayMode(.inline)
.alert(item: $activeAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
)
}
.confirmationDialog(
NSLocalizedString("Включить двухфакторную аутентификацию?", comment: "Заголовок подтверждения включения 2FA"),
isPresented: $showEnableConfirmation,
titleVisibility: .visible
) {
Button(NSLocalizedString("Включить", comment: "Кнопка подтверждения включения 2FA"), role: .destructive) {
enableTwoFactor()
}
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {}
}
.confirmationDialog(
NSLocalizedString("Отключить двухфакторную аутентификацию?", comment: "Заголовок подтверждения отключения 2FA"),
isPresented: $showDisableConfirmation,
titleVisibility: .visible
) {
Button(NSLocalizedString("Отключить", comment: "Кнопка подтверждения отключения 2FA"), role: .destructive) {
disableTwoFactor()
}
Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {}
}
}
}
private extension TwoFactorAuthView {
var keyRow: some View {
HStack(alignment: .center, spacing: 12) {
Text(secretKey)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Spacer()
Button(action: { copyToPasteboard(secretKey) }) {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.plain)
.accessibilityLabel(NSLocalizedString("Скопировать ключ", comment: "Кнопка копирования секретного ключа"))
}
.padding(8)
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(10)
}
func handleToggleChange(_ newValue: Bool) {
if newValue {
showEnableConfirmation = true
} else {
showDisableConfirmation = true
}
}
func enableTwoFactor() {
isTwoFactorEnabled = true
showEnableConfirmation = false
secretKey = Self.generateSecret()
verificationCode = ""
generateBackupCodes()
activeAlert = TwoFactorAlert(
title: NSLocalizedString("2FA включена", comment: "Заголовок уведомления об успешной активации 2FA"),
message: NSLocalizedString("Сохраните секретный ключ и введите код из приложения, чтобы завершить настройку.", comment: "Сообщение после активации 2FA")
)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
isCodeFieldFocused = true
}
}
func disableTwoFactor() {
isTwoFactorEnabled = false
showDisableConfirmation = false
verificationCode = ""
backupCodes.removeAll()
activeAlert = TwoFactorAlert(
title: NSLocalizedString("2FA отключена", comment: "Заголовок уведомления об отключении 2FA"),
message: NSLocalizedString("Вы можете включить защиту снова в любой момент.", comment: "Сообщение после отключения 2FA")
)
}
func verifyCode() {
let normalized = verificationCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized.count == 6, normalized.allSatisfy(\.isNumber) else {
activeAlert = TwoFactorAlert(
title: NSLocalizedString("Неверный код", comment: "Заголовок ошибки неправильного кода 2FA"),
message: NSLocalizedString("Проверьте цифры и попробуйте снова.", comment: "Описание ошибки неверного кода 2FA")
)
return
}
verificationCode = ""
activeAlert = TwoFactorAlert(
title: NSLocalizedString("Код принят", comment: "Заголовок успешного подтверждения кода 2FA"),
message: NSLocalizedString("Двухфакторная аутентификация настроена.", comment: "Сообщение после успешного подтверждения кода 2FA")
)
}
func generateBackupCodes() {
backupCodes = Self.generateBackupCodes()
}
func copyToPasteboard(_ value: String) {
#if canImport(UIKit)
UIPasteboard.general.string = value
#endif
activeAlert = TwoFactorAlert(
title: NSLocalizedString("Скопировано", comment: "Заголовок уведомления о копировании"),
message: NSLocalizedString("Значение сохранено в буфере обмена.", comment: "Сообщение после копирования")
)
}
static func generateSecret() -> String {
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
return String((0..<16).compactMap { _ in alphabet.randomElement() })
}
static func generateBackupCodes(count: Int = 8) -> [String] {
let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
return (0..<count).map { _ in
String((0..<8).compactMap { _ in alphabet.randomElement() })
}
}
}
private struct TwoFactorAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
#if DEBUG
struct TwoFactorAuthView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
TwoFactorAuthView()
}
}
}
#endif

View File

@ -1,76 +0,0 @@
import SwiftUI
struct SecuritySettingsView: View {
@ObservedObject var viewModel: LoginViewModel
@State private var isTwoFactorActive = false
@State private var isEmailSettingsActive = false
@State private var isAppLockActive = false
var body: some View {
List {
Section(header: Text(NSLocalizedString("Вход и защита аккаунта (заглушка)", comment: "Раздел настроек безопасности для аутентификации"))) {
NavigationLink(isActive: $isTwoFactorActive) {
TwoFactorAuthView()
} label: {
Label(NSLocalizedString("Двухфакторная аутентификация", comment: "Переход к настройкам двухфакторной аутентификации"), systemImage: "lock.shield")
}
NavigationLink(isActive: $isEmailSettingsActive) {
EmailSecuritySettingsView()
} label: {
Label(NSLocalizedString("Настройки email", comment: "Переход к настройкам безопасности email"), systemImage: "envelope")
}
NavigationLink(isActive: $isAppLockActive) {
AppLockSettingsView()
} label: {
Label(NSLocalizedString("Пароль на приложение", comment: "Переход к настройкам пароля на приложение"), systemImage: "lock.square")
}
}
Section(header: Text(NSLocalizedString("Приватность и контроль", comment: ""))) {
NavigationLink(destination: EditPrivacyView()) {
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
}
NavigationLink(destination: ChangePasswordView()) {
Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key")
}
NavigationLink(destination: ActiveSessionsView()) {
Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(NSLocalizedString("Безопасность", comment: "Заголовок экрана настроек безопасности"))
.navigationBarTitleDisplayMode(.inline)
// .onAppear { handleSecuritySettingsOnboardingIfNeeded() }
// .onChange(of: viewModel.onboardingDestination) { _ in
// handleSecuritySettingsOnboardingIfNeeded()
// }
}
// private func handleSecuritySettingsOnboardingIfNeeded() {
// guard viewModel.onboardingDestination == .securitySettings else { return }
// guard !isTwoFactorActive else {
// viewModel.onboardingDestination = nil
// return
// }
// DispatchQueue.main.async {
// isTwoFactorActive = true
// viewModel.onboardingDestination = nil
// }
// }
}
#if DEBUG
struct SecuritySettingsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
SecuritySettingsView(viewModel: LoginViewModel())
}
}
}
#endif

View File

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

View File

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

View File

@ -2,13 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<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/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

View File

@ -10,9 +10,6 @@ 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()
@ -22,7 +19,7 @@ struct yobbleApp: App {
WindowGroup {
ZStack(alignment: .top) {
Group {
if viewModel.isInitialLoading {
if viewModel.isLoading {
SplashScreenView()
} else if viewModel.isLoggedIn {
MainView(viewModel: viewModel)
@ -58,6 +55,23 @@ struct yobbleApp: App {
}
}
}
.environmentObject(messageCenter)
}
.fullScreenCover(item: AppConfig.PRESENT_CHAT_AS_SHEET ? .constant(nil) : $messageCenter.presentedChat) { chatItem in
NavigationView {
PrivateChatView(
chat: chatItem,
currentUserId: messageCenter.currentUserId
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Закрыть", comment: "")) {
messageCenter.presentedChat = nil
}
}
}
}
.environmentObject(messageCenter)
}
.environmentObject(messageCenter)
.environmentObject(themeManager)