Compare commits

..

1 Commits

Author SHA1 Message Date
5c0c24cff2 patch view 2025-10-21 20:52:46 +03:00
94 changed files with 761 additions and 11986 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 = 14;
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 = 14;
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

@ -3,22 +3,4 @@
uuid = "AEE1609A-17B4-4FCC-80A6-0D556940F4D7"
type = "1"
version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "09699199-8124-4F89-892D-6880A0EB7C04"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yobble/Views/Contacts/ContactEditView.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "74"
endingLineNumber = "74"
landmarkName = "ContactEditView"
landmarkType = "14">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

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

@ -1,113 +0,0 @@
import SwiftUI
struct ProfileHeaderCardView: View {
struct PresenceStatus {
let text: String
let isOnline: Bool
}
struct StatusTag: Identifiable {
let id = UUID()
let icon: String
let text: String
let background: Color
let tint: Color
}
private let avatar: AnyView
private let displayName: String
private let presenceStatus: PresenceStatus?
private let statusTags: [StatusTag]
private let isOfficial: Bool
init<Avatar: View>(
avatar: Avatar,
displayName: String,
presenceStatus: PresenceStatus?,
statusTags: [StatusTag],
isOfficial: Bool
) {
self.avatar = AnyView(avatar)
self.displayName = displayName
self.presenceStatus = presenceStatus
self.statusTags = statusTags
self.isOfficial = isOfficial
}
var body: some View {
VStack(spacing: 16) {
avatar
.overlay(alignment: .bottomTrailing) {
officialBadge
}
VStack(spacing: 6) {
Text(displayName)
.font(.title2)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
if let presenceStatus {
HStack(spacing: 6) {
Circle()
.fill(presenceStatus.isOnline ? Color.green : Color.gray.opacity(0.4))
.frame(width: 8, height: 8)
Text(presenceStatus.text)
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
if !statusTags.isEmpty {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], spacing: 8) {
ForEach(statusTags) { tag in
HStack(spacing: 6) {
Image(systemName: tag.icon)
.font(.system(size: 12, weight: .semibold))
Text(tag.text)
.font(.caption)
.fontWeight(.medium)
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
.foregroundColor(tag.tint)
.background(tag.background)
.clipShape(Capsule())
}
}
}
}
.padding(24)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 32, style: .continuous)
.fill(headerGradient)
)
.overlay(
RoundedRectangle(cornerRadius: 32, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.08), radius: 20, x: 0, y: 10)
}
@ViewBuilder
private var officialBadge: some View {
if isOfficial {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.padding(6)
.background(Circle().fill(Color.accentColor))
.offset(x: 6, y: 6)
}
}
private var headerGradient: LinearGradient {
let first = isOfficial ? Color.accentColor : Color.accentColor.opacity(0.6)
let second = Color.accentColor.opacity(isOfficial ? 0.6 : 0.3)
let third = Color(UIColor.secondarySystemBackground)
return LinearGradient(colors: [first, second, third], startPoint: .topLeading, endPoint: .bottomTrailing)
}
}

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

View File

@ -83,7 +83,6 @@ struct MessageItem: Decodable, Identifiable {
let content: String?
let mediaLink: String?
let isViewed: Bool?
let viewedAt: Date?
let createdAt: Date?
let updatedAt: Date?
let forwardMetadata: ForwardMetadata?
@ -99,7 +98,6 @@ struct MessageItem: Decodable, Identifiable {
case content
case mediaLink
case isViewed
case viewedAt
case createdAt
case updatedAt
case forwardMetadata
@ -115,7 +113,6 @@ struct MessageItem: Decodable, Identifiable {
self.content = try container.decodeIfPresent(String.self, forKey: .content)
self.mediaLink = try container.decodeIfPresent(String.self, forKey: .mediaLink)
self.isViewed = try container.decodeIfPresent(Bool.self, forKey: .isViewed)
self.viewedAt = try container.decodeIfPresent(Date.self, forKey: .viewedAt)
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt)
self.forwardMetadata = try container.decodeIfPresent(ForwardMetadata.self, forKey: .forwardMetadata)
@ -130,7 +127,6 @@ struct MessageItem: Decodable, Identifiable {
content: String?,
mediaLink: String?,
isViewed: Bool?,
viewedAt: Date?,
createdAt: Date?,
updatedAt: Date?,
forwardMetadata: ForwardMetadata?
@ -143,7 +139,6 @@ struct MessageItem: Decodable, Identifiable {
self.content = content
self.mediaLink = mediaLink
self.isViewed = isViewed
self.viewedAt = viewedAt
self.createdAt = createdAt
self.updatedAt = updatedAt
self.forwardMetadata = forwardMetadata
@ -172,12 +167,10 @@ struct ChatProfile: Decodable {
let bio: String?
let lastSeen: Int?
let createdAt: Date?
let avatars: Avatars?
let stories: [JSONValue]
let permissions: ChatPermissions?
let profilePermissions: ChatProfilePermissions?
let relationship: RelationshipStatus?
let rating: Double?
let isOfficial: Bool
private enum CodingKeys: String, CodingKey {
@ -188,12 +181,10 @@ struct ChatProfile: Decodable {
case bio
case lastSeen
case createdAt
case avatars
case stories
case permissions
case profilePermissions
case relationship
case rating
case isOfficial
case isVerified
}
@ -207,50 +198,16 @@ struct ChatProfile: Decodable {
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
self.lastSeen = try container.decodeIfPresent(Int.self, forKey: .lastSeen)
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars)
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
self.permissions = try container.decodeIfPresent(ChatPermissions.self, forKey: .permissions)
self.profilePermissions = try container.decodeIfPresent(ChatProfilePermissions.self, forKey: .profilePermissions)
self.relationship = try container.decodeIfPresent(RelationshipStatus.self, forKey: .relationship)
let ratingPayload = try container.decodeIfPresent(ChatProfileRatingPayload.self, forKey: .rating)
self.rating = ratingPayload?.resolvedRating
let explicitOfficial = try container.decodeIfPresent(Bool.self, forKey: .isOfficial)
let verifiedFlag = try container.decodeIfPresent(Bool.self, forKey: .isVerified)
self.isOfficial = explicitOfficial ?? verifiedFlag ?? false
}
}
private struct ChatProfileRatingPayload: Decodable {
let status: String?
let rating: Double?
private enum CodingKeys: String, CodingKey {
case rating
case status
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
status = try container.decodeIfPresent(String.self, forKey: .status)
if let doubleValue = try? container.decode(Double.self, forKey: .rating) {
rating = doubleValue
} else if let stringValue = try? container.decode(String.self, forKey: .rating),
let doubleValue = Double(stringValue) {
rating = doubleValue
} else if let intValue = try? container.decode(Int.self, forKey: .rating) {
rating = Double(intValue)
} else {
rating = nil
}
}
var resolvedRating: Double? {
guard status?.lowercased() == "fine" else { return nil }
return rating
}
}
extension ChatProfile {
init(
userId: String,
@ -260,12 +217,10 @@ extension ChatProfile {
bio: String? = nil,
lastSeen: Int? = nil,
createdAt: Date? = nil,
avatars: Avatars? = nil,
stories: [JSONValue] = [],
permissions: ChatPermissions? = nil,
profilePermissions: ChatProfilePermissions? = nil,
relationship: RelationshipStatus? = nil,
rating: Double? = nil,
isOfficial: Bool = false
) {
self.userId = userId
@ -275,29 +230,14 @@ extension ChatProfile {
self.bio = bio
self.lastSeen = lastSeen
self.createdAt = createdAt
self.avatars = avatars
self.stories = stories
self.permissions = permissions
self.profilePermissions = profilePermissions
self.relationship = relationship
self.rating = rating
self.isOfficial = isOfficial
}
}
struct AvatarInfo: Decodable {
let fileId: String
let mime: String?
let size: Int?
let width: Int?
let height: Int?
let createdAt: Date?
}
struct Avatars: Decodable {
let current: AvatarInfo?
}
struct ChatPermissions: Decodable {
let youCanSendMessage: Bool
let youCanPublicInvitePermission: Bool
@ -315,39 +255,9 @@ struct ChatProfilePermissions: Decodable {
}
struct RelationshipStatus: Decodable {
let isTargetInContactsOfCurrentUser: Bool
let isCurrentUserInContactsOfTarget: Bool
let isTargetUserBlockedByCurrentUser: Bool
let isCurrentUserInBlacklistOfTarget: Bool
private enum CodingKeys: String, CodingKey {
case isTargetInContactsOfCurrentUser
case isCurrentUserInContactsOfTarget
case isTargetUserBlockedByCurrentUser
case isCurrentUserInBlacklistOfTarget
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.isTargetInContactsOfCurrentUser = try container.decodeIfPresent(Bool.self, forKey: .isTargetInContactsOfCurrentUser) ?? false
self.isCurrentUserInContactsOfTarget = try container.decodeIfPresent(Bool.self, forKey: .isCurrentUserInContactsOfTarget) ?? false
self.isTargetUserBlockedByCurrentUser = try container.decodeIfPresent(Bool.self, forKey: .isTargetUserBlockedByCurrentUser) ?? false
self.isCurrentUserInBlacklistOfTarget = try container.decodeIfPresent(Bool.self, forKey: .isCurrentUserInBlacklistOfTarget) ?? false
}
}
extension RelationshipStatus {
init(
isTargetInContactsOfCurrentUser: Bool,
isCurrentUserInContactsOfTarget: Bool,
isTargetUserBlockedByCurrentUser: Bool,
isCurrentUserInBlacklistOfTarget: Bool
) {
self.isTargetInContactsOfCurrentUser = isTargetInContactsOfCurrentUser
self.isCurrentUserInContactsOfTarget = isCurrentUserInContactsOfTarget
self.isTargetUserBlockedByCurrentUser = isTargetUserBlockedByCurrentUser
self.isCurrentUserInBlacklistOfTarget = isCurrentUserInBlacklistOfTarget
}
}
enum JSONValue: Decodable {

View File

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

View File

@ -5,26 +5,20 @@ struct ProfileDataPayload: Decodable {
let login: String
let fullName: String?
let bio: String?
let avatars: Avatars?
let balances: [WalletBalancePayload]
let createdAt: Date?
let isVerified: Bool
let stories: [JSONValue]
let profilePermissions: ProfilePermissionsPayload
let rating: Double?
private enum CodingKeys: String, CodingKey {
case userId
case login
case fullName
case bio
case avatars
case balances
case createdAt
case isVerified
case stories
case profilePermissions
case rating
}
init(from decoder: Decoder) throws {
@ -33,61 +27,13 @@ struct ProfileDataPayload: Decodable {
self.login = try container.decode(String.self, forKey: .login)
self.fullName = try container.decodeIfPresent(String.self, forKey: .fullName)
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars)
self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? []
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
self.isVerified = try container.decode(Bool.self, forKey: .isVerified)
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions)
let ratingPayload = try container.decodeIfPresent(ProfileRatingPayload.self, forKey: .rating)
self.rating = ratingPayload?.resolvedRating
}
}
private struct ProfileRatingPayload: Decodable {
let status: String?
let rating: Double?
private enum CodingKeys: String, CodingKey {
case rating
case status
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
status = try container.decodeIfPresent(String.self, forKey: .status)
if let doubleValue = try? container.decode(Double.self, forKey: .rating) {
rating = doubleValue
} else if let stringValue = try? container.decode(String.self, forKey: .rating),
let doubleValue = Double(stringValue) {
rating = doubleValue
} else if let intValue = try? container.decode(Int.self, forKey: .rating) {
rating = Double(intValue)
} else {
rating = nil
}
}
var resolvedRating: Double? {
guard status?.lowercased() == "fine" else { return nil }
return rating
}
}
//struct AvatarInfo: Decodable {
// let fileId: String
// let mime: String?
// let size: Int?
// let width: Int?
// let height: Int?
// let createdAt: Date?
//}
//
//struct Avatars: Decodable {
// let current: AvatarInfo?
//}
struct WalletBalancePayload: Decodable {
let currency: String
let balance: Decimal
@ -210,39 +156,6 @@ struct ProfilePermissionsRequestPayload: Encodable {
}
}
extension ProfilePermissionsRequestPayload {
init(payload: ProfilePermissionsPayload) {
self.init(
isSearchable: payload.isSearchable,
allowMessageForwarding: payload.allowMessageForwarding,
allowMessagesFromNonContacts: payload.allowMessagesFromNonContacts,
showProfilePhotoToNonContacts: payload.showProfilePhotoToNonContacts,
lastSeenVisibility: payload.lastSeenVisibility,
showBioToNonContacts: payload.showBioToNonContacts,
showStoriesToNonContacts: payload.showStoriesToNonContacts,
allowServerChats: payload.allowServerChats,
publicInvitePermission: payload.publicInvitePermission,
groupInvitePermission: payload.groupInvitePermission,
callPermission: payload.callPermission,
forceAutoDeleteMessagesInPrivate: payload.forceAutoDeleteMessagesInPrivate,
maxMessageAutoDeleteSeconds: payload.maxMessageAutoDeleteSeconds,
autoDeleteAfterDays: payload.autoDeleteAfterDays
)
}
}
struct ProfileUpdateRequestPayload: Encodable {
let fullName: String?
let bio: String?
let profilePermissions: ProfilePermissionsRequestPayload
init(fullName: String? = nil, bio: String? = nil, profilePermissions: ProfilePermissionsRequestPayload) {
self.fullName = fullName
self.bio = bio
self.profilePermissions = profilePermissions
}
}
struct UploadAvatarPayload: Decodable {
let fileId: String
}

View File

@ -1,5 +1,4 @@
import Foundation
import UIKit
enum ProfileServiceError: LocalizedError {
case unexpectedStatus(String)
@ -74,57 +73,6 @@ final class ProfileService {
}
}
func fetchProfile(userId: UUID, completion: @escaping (Result<ChatProfile, Error>) -> Void) {
fetchProfile(userId: userId.uuidString, completion: completion)
}
func fetchProfile(userId: String, completion: @escaping (Result<ChatProfile, Error>) -> Void) {
let sanitizedId = userId.trimmingCharacters(in: .whitespacesAndNewlines)
client.request(
path: "/v1/profile/\(sanitizedId)",
method: .get,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let profile = try Self.decodeProfileResponse(
data: response.data,
decoder: decoder,
requestedId: sanitizedId
)
completion(.success(profile))
} catch {
if AppConfig.DEBUG {
print("[ProfileService] decode profile by id failed: \(error)")
}
completion(.failure(error))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func fetchProfile(userId: UUID) async throws -> ChatProfile {
try await fetchProfile(userId: userId.uuidString)
}
func fetchProfile(userId: String) async throws -> ChatProfile {
try await withCheckedThrowingContinuation { continuation in
fetchProfile(userId: userId, completion: { result in
continuation.resume(with: result)
})
}
}
func updateProfile(_ payload: ProfileUpdateRequestPayload, completion: @escaping (Result<String, Error>) -> Void) {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
@ -185,126 +133,6 @@ final class ProfileService {
}
}
func uploadAvatar(image: UIImage, completion: @escaping (Result<String, Error>) -> Void) {
guard let imageData = image.jpegData(compressionQuality: 0.9) else {
let message = NSLocalizedString("Не удалось подготовить изображение для загрузки.", comment: "Avatar encoding error")
completion(.failure(ProfileServiceError.encoding(message)))
return
}
let boundary = "Boundary-\(UUID().uuidString)"
let body = Self.makeMultipartBody(
data: imageData,
boundary: boundary,
fieldName: "file",
filename: "avatar.jpg",
mimeType: "image/jpeg"
)
client.request(
path: "/v1/storage/upload/avatar",
method: .post,
body: body,
contentType: "multipart/form-data; boundary=\(boundary)",
requiresAuth: true
) { result in
switch result {
case .success(let response):
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let apiResponse = try decoder.decode(APIResponse<UploadAvatarPayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить аватар.", comment: "Avatar upload unexpected status")
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data.fileId))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[ProfileService] decode upload avatar failed: \(debugMessage)")
}
if AppConfig.DEBUG {
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
} else {
let message = NSLocalizedString("Не удалось обработать ответ сервера.", comment: "Avatar upload decode error")
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
}
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func uploadAvatar(image: UIImage) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
uploadAvatar(image: image) { result in
continuation.resume(with: result)
}
}
}
private static func decodeProfileResponse(
data: Data,
decoder: JSONDecoder,
requestedId: String
) throws -> ChatProfile {
let defaultErrorMessage = NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile unexpected status")
var dictionaryDecodeError: String?
do {
let apiResponse = try decoder.decode(APIResponse<[String: ChatProfile]>.self, from: data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? defaultErrorMessage
throw ProfileServiceError.unexpectedStatus(message)
}
let normalizedKey = requestedId.lowercased()
if let profile = apiResponse.data[requestedId]
?? apiResponse.data[normalizedKey]
?? apiResponse.data[requestedId.uppercased()]
?? apiResponse.data.first?.value {
return profile
}
throw ProfileServiceError.unexpectedStatus(
NSLocalizedString("Профиль не найден.", comment: "Profile by id missing")
)
} catch let error as ProfileServiceError {
throw error
} catch {
dictionaryDecodeError = Self.describeDecodingError(error: error, data: data)
}
do {
let apiResponse = try decoder.decode(APIResponse<ChatProfile>.self, from: data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? defaultErrorMessage
throw ProfileServiceError.unexpectedStatus(message)
}
return apiResponse.data
} catch let error as ProfileServiceError {
throw error
} catch {
let singleError = Self.describeDecodingError(error: error, data: data)
let combined: String
if let dictionaryDecodeError {
combined = dictionaryDecodeError + "\nOR\n" + singleError
} else {
combined = singleError
}
throw ProfileServiceError.decoding(debugDescription: combined)
}
}
private static func decodeDate(from decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
@ -370,31 +198,6 @@ final class ProfileService {
return String(string[string.startIndex..<index]) + ""
}
private static func makeMultipartBody(
data: Data,
boundary: String,
fieldName: String,
filename: String,
mimeType: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
if let boundaryData = "--\(boundary)\(lineBreak)".data(using: .utf8) {
body.append(boundaryData)
}
if let dispositionData = "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(filename)\"\(lineBreak)".data(using: .utf8) {
body.append(dispositionData)
}
if let typeData = "Content-Type: \(mimeType)\(lineBreak)\(lineBreak)".data(using: .utf8) {
body.append(typeData)
}
body.append(data)
if let closingData = "\(lineBreak)--\(boundary)--\(lineBreak)".data(using: .utf8) {
body.append(closingData)
}
return body
}
private static func errorMessage(from data: Data) -> String? {
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
if let detail = apiError.detail, !detail.isEmpty {

View File

@ -63,27 +63,14 @@ extension UserSearchResult {
}
var avatarInitial: String {
let nameSource: String?
if let customName = preferredCustomName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = customName
} else if let fullName = officialFullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = fullName
} else {
nameSource = nil
}
let source = preferredCustomName
?? officialFullName
?? login
?? userId.uuidString
if let name = nameSource {
let components = name.split(separator: " ")
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials.map { String($0) }.joined().uppercased()
}
if let character = source.first(where: { !$0.isWhitespace && $0 != "@" }) {
return String(character).uppercased()
}
if let login = login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return String(login.prefix(1)).uppercased()
}
return "?"
}

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,202 +0,0 @@
import Foundation
import SwiftUI
import UIKit
struct AppUpdateNotice: Identifiable {
enum Kind {
case need
case force
case soft
}
let id = UUID()
let kind: Kind
let appStoreURL: URL
let skipBuild: Int?
init(kind: Kind, appStoreURL: URL, skipBuild: Int? = nil) {
self.kind = kind
self.appStoreURL = appStoreURL
self.skipBuild = skipBuild
}
var canSkip: Bool { skipBuild != nil }
var title: String {
switch kind {
case .need:
return NSLocalizedString("Обновление обязательно", comment: "Need update alert title")
case .force:
return NSLocalizedString("Рекомендуется обновление", comment: "Force update alert title")
case .soft:
return NSLocalizedString("Доступно обновление", comment: "Soft update alert title")
}
}
var message: String {
switch kind {
case .need:
return NSLocalizedString("Для продолжения работы необходимо обновить приложение до последней версии.", comment: "Need update alert message")
case .force:
return NSLocalizedString("Эта версия приложения устарела. Некоторые функции могут работать некорректно.", comment: "Force update alert message")
case .soft:
return NSLocalizedString("Вышла новая версия приложения с улучшениями и исправлениями.", comment: "Soft update alert message")
}
}
}
final class AppUpdateChecker: ObservableObject {
@AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
@AppStorage("lastCheckedAppBuild") private var lastCheckedAppBuild: Int = 0
@Published private(set) var needUpdateNotice: AppUpdateNotice?
@Published private(set) var softUpdateNotice: AppUpdateNotice?
@Published private(set) var forceUpdateNotice: AppUpdateNotice?
private let session: URLSession
private var didStartCheck = false
init(session: URLSession = .shared) {
self.session = session
}
func checkForUpdatesIfNeeded() {
guard !didStartCheck else { return }
didStartCheck = true
Task { await fetchRemoteConfig() }
}
func dismissSoftUpdateIfNeeded(skipBuild: Int? = nil) {
if let skipBuild {
lastCheckedAppBuild = skipBuild
}
softUpdateNotice = nil
}
func openAppStore(link overrideURL: URL? = nil) {
guard let url = overrideURL
?? needUpdateNotice?.appStoreURL
?? forceUpdateNotice?.appStoreURL
?? softUpdateNotice?.appStoreURL else {
return
}
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
private func fetchRemoteConfig() async {
guard
let buildType = AppConfig.APP_BUILD.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(string: "https://static.yobble.org/config/ios/\(buildType).json")
else {
log("Unable to build remote config URL")
return
}
do {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
log("Unexpected response when fetching remote config")
return
}
let decoder = JSONDecoder()
let remoteConfig = try decoder.decode(RemoteBuildConfiguration.self, from: data)
await MainActor.run {
self.apply(remoteConfig)
}
} catch {
log("Failed to fetch remote config: \(error)")
}
}
@MainActor
private func apply(_ config: RemoteBuildConfiguration) {
guard let buildNumber = currentBuildNumber() else {
log("Unable to read current build number")
return
}
needUpdateNotice = nil
forceUpdateNotice = nil
softUpdateNotice = nil
guard let appStoreURL = config.appStoreURL else {
log("Config missing App Store URL")
return
}
// print("buildNumber", buildNumber)
// print("config", config.notSupportedBuild, config.minSupportedBuild, config.recommendedBuild)
let requiresNeedUpdate = buildNumber <= config.notSupportedBuild
if requiresNeedUpdate {
isAppBlocked = true
needUpdateNotice = AppUpdateNotice(kind: .need, appStoreURL: appStoreURL)
return
} else {
isAppBlocked = false
}
let requiresForcedUpdate = buildNumber < config.minSupportedBuild
if requiresForcedUpdate {
softUpdateNotice = AppUpdateNotice(kind: .force, appStoreURL: appStoreURL)
return
}
if buildNumber < config.recommendedBuild && config.recommendedBuild != lastCheckedAppBuild {
// lastCheckedAppBuild = config.recommendedBuild
softUpdateNotice = AppUpdateNotice(
kind: .soft,
appStoreURL: appStoreURL,
skipBuild: config.recommendedBuild
)
return
}
}
private func currentBuildNumber() -> Int? {
guard let rawValue = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
return nil
}
return Int(rawValue)
}
private func log(_ message: String) {
if AppConfig.DEBUG {
print("[AppUpdateChecker]", message)
}
}
}
private struct RemoteBuildConfiguration: Decodable {
let schemaVersion: Int
let notSupportedBuild: Int
let minSupportedBuild: Int
let recommendedBuild: Int
let appStoreURL: URL?
enum CodingKeys: String, CodingKey {
case schemaVersion = "schema_version"
case notSupportedBuild = "not_supported_build"
case minSupportedBuild = "min_supported_build"
case recommendedBuild = "recommended_build"
case appStoreURL = "appstore_url"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
notSupportedBuild = try container.decode(Int.self, forKey: .notSupportedBuild)
minSupportedBuild = try container.decode(Int.self, forKey: .minSupportedBuild)
recommendedBuild = try container.decode(Int.self, forKey: .recommendedBuild)
if let urlString = try container.decodeIfPresent(String.self, forKey: .appStoreURL) {
appStoreURL = URL(string: urlString)
} else {
appStoreURL = nil
}
}
}

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,8 +1,5 @@
import Foundation
import SwiftUICore
import Security
import UIKit
import Combine
//let username = "user1"
@ -114,163 +111,3 @@ class KeychainService {
}
}
}
class AvatarCacheService {
static let shared = AvatarCacheService()
private let fileManager = FileManager.default
private var baseCacheDirectory: URL? {
fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("avatar_cache")
}
private init() {}
private func cacheDirectory(for userId: String) -> URL? {
baseCacheDirectory?.appendingPathComponent(userId)
}
private func filePath(forKey key: String, userId: String) -> URL? {
cacheDirectory(for: userId)?.appendingPathComponent(key)
}
func getImage(forKey key: String, userId: String) -> UIImage? {
guard let url = filePath(forKey: key, userId: userId),
fileManager.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url) else {
return nil
}
return UIImage(data: data)
}
func saveImage(_ image: UIImage, forKey key: String, userId: String) {
guard let directory = cacheDirectory(for: userId),
let url = filePath(forKey: key, userId: userId),
let data = image.jpegData(compressionQuality: 0.8) else {
return
}
if !fileManager.fileExists(atPath: directory.path) {
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
}
try? data.write(to: url)
}
func clearCache(forUserId userId: String) {
guard let directory = cacheDirectory(for: userId) else { return }
// Try to delete files inside first, ignoring errors
if let fileUrls = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: []) {
for fileUrl in fileUrls {
try? fileManager.removeItem(at: fileUrl)
}
}
// Then try to delete the directory itself
try? fileManager.removeItem(at: directory)
}
func clearAllCache() {
guard let directory = baseCacheDirectory else { return }
try? fileManager.removeItem(at: directory)
}
func getAllCachedUserIds() -> [String] {
guard let baseDir = baseCacheDirectory else { return [] }
do {
let directoryContents = try fileManager.contentsOfDirectory(at: baseDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
return directoryContents.map { $0.lastPathComponent }
} catch {
// This can happen if the directory doesn't exist yet, which is not an error.
return []
}
}
func sizeOfCache(forUserId userId: String) -> Int64 {
guard let directory = cacheDirectory(for: userId) else { return 0 }
return directorySize(url: directory)
}
func sizeOfAllCache() -> Int64 {
guard let directory = baseCacheDirectory else { return 0 }
return directorySize(url: directory)
}
private func directorySize(url: URL) -> Int64 {
let contents: [URL]
do {
contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey], options: .skipsHiddenFiles)
} catch {
return 0
}
var totalSize: Int64 = 0
for url in contents {
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
totalSize += Int64(fileSize)
}
return totalSize
}
}
class ImageLoader: ObservableObject {
@Published var image: UIImage?
private let url: URL
private let fileId: String
private let userId: String
private var cancellable: AnyCancellable?
private let cache = AvatarCacheService.shared
init(url: URL, fileId: String, userId: String) {
self.url = url
self.fileId = fileId
self.userId = userId
}
deinit {
cancellable?.cancel()
}
func load() {
if let cachedImage = cache.getImage(forKey: fileId, userId: userId) {
self.image = cachedImage
return
}
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.replaceError(with: nil)
.receive(on: DispatchQueue.main)
.sink { [weak self] loadedImage in
guard let self = self, let image = loadedImage else { return }
self.image = image
self.cache.saveImage(image, forKey: self.fileId, userId: self.userId)
}
}
}
struct CachedAvatarView<Placeholder: View>: View {
@StateObject private var loader: ImageLoader
private let placeholder: Placeholder
init(url: URL, fileId: String, userId: String, @ViewBuilder placeholder: () -> Placeholder) {
self.placeholder = placeholder()
_loader = StateObject(wrappedValue: ImageLoader(url: url, fileId: fileId, userId: userId))
}
var body: some View {
content
.onAppear(perform: loader.load)
}
private var content: some View {
Group {
if let image = loader.image {
Image(uiImage: image)
.resizable()
} else {
placeholder
}
}
}
}

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

@ -7,69 +7,27 @@
import Foundation
import Combine
import SwiftUI
class LoginViewModel: ObservableObject {
// @AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
@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"
@ -85,10 +43,6 @@ class LoginViewModel: ObservableObject {
autoLogin()
}
deinit {
resendTimer?.invalidate()
}
private func observeSocketState() {
socketService.connectionStatePublisher
.receive(on: DispatchQueue.main)
@ -137,7 +91,6 @@ class LoginViewModel: ObservableObject {
self?.socketService.disconnect()
}
self?.isLoading = false
self?.isInitialLoading = false
}
}
}
@ -146,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 {
@ -167,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
@ -263,7 +123,6 @@ class LoginViewModel: ObservableObject {
self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
self?.loadStoredUser()
self?.socketService.connectForCurrentUser()
self?.onboardingDestination = .afterRegister
} else {
self?.socketService.disconnect()
}
@ -310,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

@ -6,15 +6,14 @@ final class PrivateChatViewModel: ObservableObject {
@Published private(set) var isInitialLoading: Bool = false
@Published private(set) var isLoadingMore: Bool = false
@Published var errorMessage: String?
@Published var sendingErrorMessage: String?
@Published private(set) var isSending: Bool = false
@Published private(set) var hasMore: Bool = true
private let chatService: ChatService
private let chatId: String
private let currentUserId: String?
private let pageSize: Int
let maxMessageLength: Int = 4096
private let maxMessageLength: Int = 4096
private var hasMore: Bool = true
private var didLoadInitially: Bool = false
private var messageObserver: NSObjectProtocol?
@ -74,9 +73,12 @@ final class PrivateChatViewModel: ObservableObject {
completion(false)
return
}
guard trimmed.count <= maxMessageLength else {
errorMessage = NSLocalizedString("Сообщение слишком длинное.", comment: "")
completion(false)
return
}
guard !isSending else {
sendingErrorMessage = NSLocalizedString("Дождитесь отправки предыдущего сообщения.", comment: "")
completion(false)
return
}
@ -86,63 +88,36 @@ final class PrivateChatViewModel: ObservableObject {
}
isSending = true
sendingErrorMessage = nil
let chunks = splitMessage(trimmed, maxLength: maxMessageLength)
let dispatchGroup = DispatchGroup()
var overallSuccess = true
chatService.sendPrivateMessage(chatId: chatId, content: trimmed) { [weak self] result in
guard let self else { return }
for chunk in chunks {
dispatchGroup.enter()
chatService.sendPrivateMessage(chatId: chatId, content: chunk) { [weak self] result in
guard let self else {
dispatchGroup.leave()
return
}
switch result {
case .success(let data):
let newMessage = MessageItem(
messageId: data.messageId,
messageType: "text",
chatId: data.chatId,
senderId: currentUserId,
senderData: nil,
content: trimmed,
mediaLink: nil,
isViewed: true,
createdAt: data.createdAt,
updatedAt: data.createdAt,
forwardMetadata: nil
)
switch result {
case .success(let data):
let newMessage = MessageItem(
messageId: data.messageId,
messageType: "text",
chatId: data.chatId,
senderId: currentUserId,
senderData: nil,
content: chunk,
mediaLink: nil,
isViewed: true,
viewedAt: nil,
createdAt: data.createdAt,
updatedAt: data.createdAt,
forwardMetadata: nil
)
self.messages = Self.merge(existing: self.messages, newMessages: [newMessage])
case .failure(let error):
self.sendingErrorMessage = self.message(for: error)
overallSuccess = false
}
dispatchGroup.leave()
}
}
dispatchGroup.notify(queue: .main) {
self.isSending = false
if overallSuccess {
self.messages = Self.merge(existing: self.messages, newMessages: [newMessage])
self.errorMessage = nil
completion(true)
case .failure(let error):
self.errorMessage = self.message(for: error)
completion(false)
}
completion(overallSuccess)
}
}
private func splitMessage(_ message: String, maxLength: Int) -> [String] {
var chunks: [String] = []
var remaining = message
while !remaining.isEmpty {
let chunk = String(remaining.prefix(maxLength))
chunks.append(chunk)
remaining = String(remaining.dropFirst(maxLength))
self.isSending = false
}
return chunks
}
func refresh() {
@ -152,21 +127,11 @@ final class PrivateChatViewModel: ObservableObject {
func loadMoreIfNeeded(for message: MessageItem) {
guard didLoadInitially, !isInitialLoading, hasMore, !isLoadingMore else { return }
guard let messageIndex = messages.firstIndex(where: { $0.id == message.id }) else {
return
}
let threshold = 10
guard messageIndex < threshold else {
return
}
guard let oldestMessage = messages.first else { return }
guard let first = messages.first, first.id == message.id else { return }
isLoadingMore = true
chatService.fetchPrivateChatHistory(chatId: chatId, beforeMessageId: oldestMessage.id, limit: pageSize) { [weak self] result in
chatService.fetchPrivateChatHistory(chatId: chatId, beforeMessageId: message.id, limit: pageSize) { [weak self] result in
guard let self else { return }
switch result {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,196 +0,0 @@
import SwiftUI
struct ContactAddView: View {
let contact: ContactEditInfo
let onContactAdded: ((ContactPayload) -> Void)?
@Environment(\.dismiss) private var dismiss
private let contactsService = ContactsService()
private let initialName: String
@State private var displayName: String
@State private var activeAlert: ContactAddAlert?
@State private var isSaving = false
init(contact: ContactEditInfo, onContactAdded: ((ContactPayload) -> Void)? = nil) {
self.contact = contact
self.onContactAdded = onContactAdded
// let initialName = contact.preferredName
self.initialName = contact.preferredName
_displayName = State(initialValue: "")
}
var body: some View {
Form {
avatarSection
Section(header: Text(NSLocalizedString("Публичная информация", comment: "Contact add public info section title"))) {
// TextField(NSLocalizedString("Отображаемое имя", comment: "Display name field placeholder"), text: $displayName)
TextField(NSLocalizedString("\(initialName)", comment: "Display name field placeholder"), text: $displayName)
.disabled(isSaving)
}
}
.navigationTitle(NSLocalizedString("Новый контакт", comment: "Contact add title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView()
} else {
Button(NSLocalizedString("Сохранить", comment: "Contact add save button")) {
handleSaveTap()
}
.disabled(!hasChanges)
}
}
}
.alert(item: $activeAlert) { item in
Alert(
title: Text(item.title),
message: Text(item.message),
dismissButton: .default(Text(NSLocalizedString("Понятно", comment: "Placeholder alert dismiss")))
)
}
}
private var avatarSection: some View {
Section {
HStack {
Spacer()
VStack(spacing: 8) {
avatarView
.frame(width: 120, height: 120)
.clipShape(Circle())
Button(NSLocalizedString("Изменить фото", comment: "Edit avatar button title")) {
showAvatarUnavailableAlert()
}
}
Spacer()
}
}
.listRowBackground(Color(UIColor.systemGroupedBackground))
}
@ViewBuilder
private var avatarView: some View {
if let url = avatarURL,
let fileId = contact.avatarFileId {
CachedAvatarView(url: url, fileId: fileId, userId: contact.userId) {
avatarPlaceholder
}
.aspectRatio(contentMode: .fill)
} else {
avatarPlaceholder
}
}
private var avatarPlaceholder: some View {
Circle()
.fill(Color.accentColor.opacity(0.15))
.overlay(
Text(avatarInitial)
.font(.system(size: 48, weight: .semibold))
.foregroundColor(.accentColor)
)
}
private var avatarInitial: String {
let trimmedName = displayName.trimmedNonEmpty ?? contact.preferredName
if let initials = initials(from: trimmedName) {
return initials
}
if let login = contact.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
return String(login.prefix(1)).uppercased()
}
return "?"
}
private var avatarURL: URL? {
guard let fileId = contact.avatarFileId else { return nil }
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(contact.userId)?file_id=\(fileId)")
}
private var hasChanges: Bool {
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
// guard !trimmed.isEmpty else { return false }
if let existing = contact.customName?.trimmedNonEmpty {
return trimmed != existing
}
return true
}
private func showAvatarUnavailableAlert() {
activeAlert = ContactAddAlert(
title: NSLocalizedString("Изменение фото недоступно", comment: "Contact add avatar unavailable title"),
message: NSLocalizedString("Мы пока не можем обновить фото контакта.", comment: "Contact add avatar unavailable message")
)
}
private func handleSaveTap() {
guard !isSaving else { return }
guard let userId = UUID(uuidString: contact.userId) else {
activeAlert = ContactAddAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Не удалось определить пользователя для добавления.", comment: "Contact add invalid user id error")
)
return
}
let trimmedName = displayName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
// guard !trimmedName.isEmpty else {
// activeAlert = ContactAddAlert(
// title: NSLocalizedString("Ошибка", comment: "Common error title"),
// message: NSLocalizedString("Имя не может быть пустым.", comment: "Contact add empty name error")
// )
// return
// }
isSaving = true
let customName = trimmedName
Task {
do {
let payload = try await contactsService.addContact(userId: userId, customName: customName)
await MainActor.run {
isSaving = false
onContactAdded?(payload)
dismiss()
}
} catch {
await MainActor.run {
isSaving = false
activeAlert = ContactAddAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: error.localizedDescription
)
}
}
}
}
}
private struct ContactAddAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
private func initials(from text: String) -> String? {
let components = text
.split { $0.isWhitespace }
.filter { !$0.isEmpty }
let letters = components.prefix(2).compactMap { $0.first }
guard !letters.isEmpty else { return nil }
return letters.map { String($0).uppercased() }.joined()
}

View File

@ -1,347 +0,0 @@
import SwiftUI
struct ContactEditInfo {
let userId: String
let login: String?
let fullName: String?
let customName: String?
let avatarFileId: String?
init(userId: String, login: String?, fullName: String?, customName: String?, avatarFileId: String?) {
self.userId = userId
self.login = login
self.fullName = fullName
self.customName = customName
self.avatarFileId = avatarFileId
}
init(userId: UUID, login: String?, fullName: String?, customName: String?, avatarFileId: String?) {
self.init(userId: userId.uuidString, login: login, fullName: fullName, customName: customName, avatarFileId: avatarFileId)
}
init(profile: ChatProfile) {
self.init(
userId: profile.userId,
login: profile.login,
fullName: profile.fullName,
customName: profile.customName,
avatarFileId: profile.avatars?.current?.fileId
)
}
init(payload: ContactPayload) {
self.init(
userId: payload.userId,
login: payload.login,
fullName: payload.fullName,
customName: payload.customName,
avatarFileId: nil
)
}
var preferredName: String {
if let full = fullName?.trimmedNonEmpty {
return full
}
if let login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "@\(login)"
}
return NSLocalizedString("Неизвестный пользователь", comment: "Message profile fallback title")
}
var loadCustomName: String {
if let custom = customName?.trimmedNonEmpty {
return custom
} else {
return ""
}
}
}
struct ContactEditView: View {
let contact: ContactEditInfo
let onContactDeleted: (() -> Void)?
let onContactUpdated: ((String) -> Void)?
@Environment(\.dismiss) private var dismiss
private let contactsService = ContactsService()
private let initialName: String
@State private var displayName: String
@State private var activeAlert: ContactEditAlert?
@State private var isSaving = false
@State private var isDeleting = false
@State private var showDeleteConfirmation = false
init(
contact: ContactEditInfo,
onContactDeleted: (() -> Void)? = nil,
onContactUpdated: ((String) -> Void)? = nil
) {
self.contact = contact
self.onContactDeleted = onContactDeleted
self.onContactUpdated = onContactUpdated
self.initialName = contact.preferredName
let initialCustomName = contact.loadCustomName
_displayName = State(initialValue: initialCustomName)
}
var body: some View {
Form {
avatarSection
Section(header: Text(NSLocalizedString("Публичная информация", comment: "Profile info section title"))) {
// TextField(NSLocalizedString("Отображаемое имя", comment: "Display name field placeholder"), text: $displayName)
TextField(NSLocalizedString("\(self.initialName)", comment: "Display name field placeholder"), text: $displayName)
.disabled(isSaving || isDeleting)
}
Section {
Button(role: .destructive) {
handleDeleteTap()
} label: {
deleteButtonLabel
}
.disabled(isDeleting)
}
}
.navigationTitle(NSLocalizedString("Контакт", comment: "Contact edit title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView()
} else {
Button(NSLocalizedString("Сохранить", comment: "Contact edit save button")) {
handleSaveTap()
}
.disabled(!hasChanges || isDeleting)
}
}
}
.alert(item: $activeAlert) { item in
Alert(
title: Text(item.title),
message: Text(item.message),
dismissButton: .default(Text(NSLocalizedString("Понятно", comment: "Placeholder alert dismiss")))
)
}
.confirmationDialog(
NSLocalizedString("Удалить контакт?", comment: "Contact delete confirmation title"),
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(NSLocalizedString("Удалить", comment: "Contact delete confirm action"), role: .destructive) {
confirmDelete()
}
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
showDeleteConfirmation = false
}
} message: {
Text(String(
format: NSLocalizedString("Контакт \"%1$@\" будет удалён из списка.", comment: "Contact delete confirmation message"),
contact.preferredName
))
}
}
@ViewBuilder
private var deleteButtonLabel: some View {
if isDeleting {
HStack(spacing: 8) {
ProgressView()
Text(NSLocalizedString("Удаляем...", comment: "Contact delete in progress"))
}
.frame(maxWidth: .infinity, alignment: .center)
} else {
Text(NSLocalizedString("Удалить контакт", comment: "Contact edit delete action"))
.frame(maxWidth: .infinity, alignment: .center)
}
}
private var avatarSection: some View {
Section {
HStack {
Spacer()
VStack(spacing: 8) {
avatarView
.frame(width: 120, height: 120)
.clipShape(Circle())
Button(NSLocalizedString("Изменить фото", comment: "Edit avatar button title")) {
showAvatarUnavailableAlert()
}
}
Spacer()
}
}
.listRowBackground(Color(UIColor.systemGroupedBackground))
}
@ViewBuilder
private var avatarView: some View {
if let url = avatarURL,
let fileId = contact.avatarFileId {
CachedAvatarView(url: url, fileId: fileId, userId: contact.userId) {
avatarPlaceholder
}
.aspectRatio(contentMode: .fill)
} else {
avatarPlaceholder
}
}
private var avatarPlaceholder: some View {
Circle()
.fill(Color.accentColor.opacity(0.15))
.overlay(
Text(avatarInitial)
.font(.system(size: 48, weight: .semibold))
.foregroundColor(.accentColor)
)
}
private var avatarInitial: String {
let trimmedName = displayName.trimmedNonEmpty ?? contact.preferredName
if let initials = initials(from: trimmedName) {
return initials
}
if let login = contact.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
return String(login.prefix(1)).uppercased()
}
return "?"
}
private var avatarURL: URL? {
guard let fileId = contact.avatarFileId else { return nil }
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(contact.userId)?file_id=\(fileId)")
}
private var hasChanges: Bool {
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
// guard !trimmed.isEmpty else { return false }
if let existing = contact.customName?.trimmedNonEmpty {
return trimmed != existing
}
return true
}
private func showAvatarUnavailableAlert() {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Изменение фото недоступно", comment: "Contact edit avatar unavailable title"),
message: NSLocalizedString("Мы пока не можем обновить фото контакта.", comment: "Contact edit avatar unavailable message")
)
}
private func handleSaveTap() {
guard !isSaving, !isDeleting else { return }
guard let userId = UUID(uuidString: contact.userId) else {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Не удалось определить контакт.", comment: "Contact edit invalid user id error")
)
return
}
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
// guard !trimmed.isEmpty else {
// activeAlert = ContactEditAlert(
// title: NSLocalizedString("Ошибка", comment: "Common error title"),
// message: NSLocalizedString("Имя не может быть пустым.", comment: "Contact edit empty name error")
// )
// return
// }
if trimmed.count > 32 {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Слишком длинное имя", comment: "Contact edit name too long title"),
message: NSLocalizedString("Имя контакта должно быть короче 32 символов.", comment: "Contact edit name too long message")
)
return
}
isSaving = true
Task {
do {
try await contactsService.updateContact(userId: userId, customName: trimmed)
await MainActor.run {
isSaving = false
onContactUpdated?(trimmed)
dismiss()
}
} catch {
await MainActor.run {
isSaving = false
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: error.localizedDescription
)
}
}
}
}
private func handleDeleteTap() {
guard !isDeleting else { return }
showDeleteConfirmation = true
}
private func confirmDelete() {
guard !isDeleting else { return }
guard let userId = UUID(uuidString: contact.userId) else {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Не удалось определить контакт.", comment: "Contact delete invalid user id error")
)
return
}
isDeleting = true
showDeleteConfirmation = false
Task {
do {
try await contactsService.removeContact(userId: userId)
await MainActor.run {
isDeleting = false
onContactDeleted?()
dismiss()
}
} catch {
await MainActor.run {
isDeleting = false
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: error.localizedDescription
)
}
}
}
}
}
private struct ContactEditAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
private func initials(from text: String) -> String? {
let components = text
.split { $0.isWhitespace }
.filter { !$0.isEmpty }
let letters = components.prefix(2).compactMap { $0.first }
guard !letters.isEmpty else { return nil }
return letters.map { String($0).uppercased() }.joined()
}

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: 17, minorVersion: 0, patchVersion: 0)
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
#else
return false
#endif
}
private func applyMessengerModeSelection() {
isMessengerModeEnabled = pendingMessengerMode
dismissMessengerPrompt()
}
private func dismissMessengerPrompt() {
withAnimation {
isShowingMessengerPrompt = false
}
}
}
struct PasswordLoginView: View {
@ObservedObject var viewModel: LoginViewModel
let onShowModePrompt: () -> Void
@EnvironmentObject private var themeManager: ThemeManager
@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/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
isPresented = false
} label: {
Text("Понятно")
.bold()
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.systemBackground))
)
.frame(maxWidth: 320)
.shadow(radius: 10)
}
}
}
private struct PasswordlessVerifyView: View {
@ObservedObject var viewModel: LoginViewModel
let shouldAutofocus: Bool
let onShowModePrompt: () -> Void
@FocusState private var isCodeFieldFocused: Bool
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
Button {
// focusedField = nil
withAnimation {
viewModel.showPasswordlessRequest()
}
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.left")
Text(NSLocalizedString("Назад", comment: ""))
}
.font(.footnote)
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Вход в аккаунт", comment: ""))
.font(.largeTitle).bold()
Text(String(format: NSLocalizedString("@%@", comment: ""), viewModel.passwordlessLogin))
.foregroundColor(.secondary)
// Text(NSLocalizedString("Введите код", comment: ""))
// .font(.largeTitle).bold()
//
// Text(String(format: NSLocalizedString("Код отправлен. Аккаунт: @%@", comment: ""), viewModel.passwordlessLogin))
// .foregroundColor(.secondary)
}
OTPInputView(code: $viewModel.verificationCode, isFocused: $isCodeFieldFocused)
if viewModel.isVerifyingCode {
HStack(spacing: 8) {
ProgressView()
Text(NSLocalizedString("Проверяем код…", comment: ""))
.font(.subheadline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Не получили код?", comment: ""))
.font(.subheadline)
if viewModel.resendSecondsRemaining > 0 {
Text(String(format: NSLocalizedString("Попробовать снова можно через %d сек", comment: ""), viewModel.resendSecondsRemaining))
.foregroundColor(.secondary)
}
Button {
withAnimation {
viewModel.resendPasswordlessCode()
}
} label: {
if viewModel.isSendingCode {
ProgressView()
.padding(.vertical, 8)
} else {
Text(NSLocalizedString("Отправить код ещё раз", comment: ""))
}
}
.disabled(viewModel.resendSecondsRemaining > 0 || viewModel.isSendingCode)
}
Divider()
// Button {
// withAnimation {
// viewModel.backToPasswordlessRequest()
// }
// } label: {
// Text(NSLocalizedString("Изменить способ входа", comment: ""))
// .frame(maxWidth: .infinity)
// }
Button {
withAnimation {
viewModel.showPasswordLogin()
}
} label: {
Text(NSLocalizedString("Войти по паролю", comment: ""))
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
isCodeFieldFocused = true
}
.onAppear(perform: scheduleFocusIfNeeded)
.onChange(of: shouldAutofocus) { newValue in
if newValue {
scheduleFocusIfNeeded()
} else {
isCodeFieldFocused = false
}
}
.onAppear {
triggerAutoVerificationIfNeeded()
}
.onChange(of: viewModel.verificationCode) { _ in
triggerAutoVerificationIfNeeded()
}
.loginErrorAlert(viewModel: viewModel)
}
private func hideKeyboardAndShowModePrompt() {
isCodeFieldFocused = false
onShowModePrompt()
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func scheduleFocusIfNeeded() {
guard shouldAutofocus else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if shouldAutofocus {
isCodeFieldFocused = true
}
}
}
private func triggerAutoVerificationIfNeeded() {
guard viewModel.canVerifyPasswordlessCode else { return }
viewModel.verifyPasswordlessCode()
}
}
private struct OTPInputView: View {
@Binding var code: String
var length: Int = 6
let isFocused: FocusState<Bool>.Binding
var body: some View {
ZStack {
HStack(spacing: 12) {
ForEach(0..<length, id: \.self) { index in
Text(symbol(at: index))
.font(.title2.monospacedDigit())
.frame(width: 48, height: 56)
.background(
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor(for: index), lineWidth: 1.5)
)
}
}
TextField("", text: textBinding)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.focused(isFocused)
.frame(width: 0, height: 0)
.opacity(0.01)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture {
isFocused.wrappedValue = true
}
}
private var textBinding: Binding<String> {
Binding(
get: { code },
set: { newValue in
let filtered = newValue.filter { $0.isNumber }
let trimmed = String(filtered.prefix(length))
// избегаем nested updates
if code != trimmed {
// отключаем анимации и делаем обновление вне view update фазы
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
code = trimmed
}
}
}
)
}
private func symbol(at index: Int) -> String {
guard index < code.count else { return "" }
let idx = code.index(code.startIndex, offsetBy: index)
return String(code[idx])
}
private func borderColor(for index: Int) -> Color {
if index == code.count && code.count < length {
return .blue
}
return .gray.opacity(0.6)
}
}
private struct MessengerModePrompt: View {
@Binding var selection: Bool
let onAccept: () -> Void
let onSkip: () -> Void
var body: some View {
VStack(spacing: 20) {
Text(NSLocalizedString("Какой режим попробовать?", comment: ""))
.font(.title3.bold())
.multilineTextAlignment(.center)
Text(NSLocalizedString("По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент.", comment: ""))
.font(.callout)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 12) {
optionButton(
title: NSLocalizedString("Соцсеть (готово 10%)", comment: ""),
subtitle: NSLocalizedString("Лента, истории, подписки", comment: ""),
isMessenger: false
)
optionButton(
title: NSLocalizedString("Только чаты (готово 60%)", comment: ""),
subtitle: NSLocalizedString("Минимум отвлечений, чистый мессенджер", comment: ""),
isMessenger: true
)
}
HStack(spacing: 12) {
// Button(action: onSkip) {
// Text(NSLocalizedString("Позже", comment: ""))
// .font(.callout)
// .frame(maxWidth: .infinity)
// .padding()
// .background(
// RoundedRectangle(cornerRadius: 14, style: .continuous)
// .stroke(Color.secondary.opacity(0.3))
// )
// }
Button(action: onAccept) {
Text(NSLocalizedString("Применить", comment: ""))
.font(.callout.bold())
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.accentColor)
)
}
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(.systemBackground))
)
.shadow(color: Color.black.opacity(0.2), radius: 30, x: 0, y: 12)
}
private func optionButton(title: String, subtitle: String, isMessenger: Bool) -> some View {
Button {
selection = isMessenger
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title)
.font(.headline)
Spacer()
if selection == isMessenger {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
Text(subtitle)
.font(.footnote)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(selection == isMessenger ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground))
)
}
.buttonStyle(.plain)
}
}
private extension View {
func loginErrorAlert(viewModel: LoginViewModel) -> some View {
alert(isPresented: Binding(
get: { viewModel.showError },
set: { viewModel.showError = $0 }
)) {
Alert(
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
message: Text(viewModel.errorMessage.isEmpty ? NSLocalizedString("Произошла ошибка.", comment: "") : viewModel.errorMessage),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
)
}
}
}
struct LoginView_Previews: PreviewProvider {
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,62 +0,0 @@
import SwiftUI
struct NeedUpdateView: View {
let title: String
let message: String
let onUpdate: () -> Void
var body: some View {
ZStack {
LinearGradient(
colors: [Color(.systemBackground), Color(.systemGray6)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 24) {
Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 56, weight: .bold))
.foregroundColor(.orange)
.accessibilityHidden(true)
VStack(spacing: 12) {
Text(title)
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text(message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
}
Button(action: onUpdate) {
Text(NSLocalizedString("Обновить приложение", comment: ""))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(14)
}
Spacer()
}
.padding(32)
.frame(maxWidth: 480)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .contain)
}
}
struct NeedUpdateView_Previews: PreviewProvider {
static var previews: some View {
NeedUpdateView(title: "Требуется обновление",
message: "Эта версия приложения устарела и больше не поддерживается.",
onUpdate: {})
}
}

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

@ -18,6 +18,7 @@ struct ChatsTab: View {
private let chatService = ChatService()
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
@StateObject private var viewModel = PrivateChatsViewModel()
@State private var selectedChatId: String?
@State private var searchDragStartProgress: CGFloat = 0
@State private var isSearchGestureActive: Bool = false
@State private var globalSearchResults: [UserSearchResult] = []
@ -31,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
@ -50,7 +50,6 @@ struct ChatsTab: View {
var body: some View {
content
.navigationTitle(NSLocalizedString("Чаты", comment: "Chats tab title"))
.background(Color(UIColor.systemBackground))
.onAppear {
viewModel.loadInitialChats()
@ -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,75 +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)
}
.buttonStyle(.borderless)
}
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
if isSearching {
Section(header: localSearchHeader) {
if localSearchResults.isEmpty {
emptySearchResultView
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.listRowSeparator(.hidden)
} else {
ForEach(localSearchResults) { chat in
chatRowItem(for: chat)
}
}
}
if isSearching {
Section(header: 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
@ -200,8 +186,7 @@ struct ChatsTab: View {
// .background(Color(UIColor.systemBackground))
// }
pendingChatNavigationLink
}
pendingChatNavigationLink
}
}
@ -232,14 +217,6 @@ struct ChatsTab: View {
}
}
private func scrollChatsToTop(using proxy: ScrollViewProxy) {
DispatchQueue.main.async {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
proxy.scrollTo(scrollToTopAnchorId, anchor: .top)
}
}
}
private var searchBarGesture: some Gesture {
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
@ -342,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))
}
}
}
@ -365,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 {
@ -408,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()
@ -446,7 +409,7 @@ struct ChatsTab: View {
@ViewBuilder
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
Button {
openChat(chat)
selectedChatId = chat.chatId
} label: {
ChatRowView(
chat: chat,
@ -469,7 +432,17 @@ struct ChatsTab: View {
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
}
}
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
.background(
NavigationLink(
destination: PrivateChatView(chat: chat, currentUserId: currentUserId),
tag: chat.chatId,
selection: $selectedChatId
) {
EmptyView()
}
.hidden()
)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
// .listRowSeparator(.hidden)
.onAppear {
guard !isSearching else { return }
@ -477,12 +450,6 @@ struct ChatsTab: View {
}
}
private func openChat(_ chat: PrivateChatListItem) {
pendingChatItem = chat
isPendingChatActive = true
}
private var globalSearchLoadingRow: some View {
HStack {
ProgressView()
@ -973,27 +940,24 @@ private struct ChatRowView: View {
}
private var initial: String {
let nameSource: String?
if let customName = chat.chatData?.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = customName
} else if let fullName = chat.chatData?.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = fullName
let sourceName: String
if let custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
sourceName = custom
} else if let displayName = officialDisplayName {
sourceName = displayName
} else if let full = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !full.isEmpty {
sourceName = full
} else if let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
sourceName = login
} else {
nameSource = nil
sourceName = NSLocalizedString("Неизвестный пользователь", comment: "")
}
if let name = nameSource {
let components = name.split(separator: " ")
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials.map { String($0) }.joined().uppercased()
}
if let character = sourceName.first(where: { !$0.isWhitespace && $0 != "@" }) {
return String(character).uppercased()
}
if let login = chat.chatData?.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return String(login.prefix(1)).uppercased()
}
return "?"
}
@ -1020,159 +984,112 @@ private struct ChatRowView: View {
guard let message = chat.lastMessage else { return .secondary }
return message.isViewed == true ? Color.accentColor : Color.secondary
}
private var avatarUrl: URL? {
guard let chatData = chat.chatData,
let fileId = chatData.avatars?.current?.fileId else {
return nil
}
let userId = chatData.userId
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)")
}
var body: some View {
HStack(spacing: 12) {
if let url = avatarUrl, let fileId = chat.chatData?.avatars?.current?.fileId, let loggedInUserId = currentUserId {
CachedAvatarView(url: url, fileId: fileId, userId: loggedInUserId) {
placeholderAvatar
}
.aspectRatio(contentMode: .fill)
Circle()
.fill(avatarBackgroundColor)
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
placeholderAvatar
}
.overlay(
Group {
if isDeletedUser {
Image(systemName: deletedUserSymbolName)
.symbolRenderingMode(.hierarchical)
.font(.system(size: avatarSize * 0.45, weight: .semibold))
.foregroundColor(avatarTextColor)
} else {
Text(initial)
.font(.system(size: avatarSize * 0.5, weight: .semibold))
.foregroundColor(avatarTextColor)
}
}
)
VStack(alignment: .leading, spacing: 4) {
HStack{
if let officialName = officialDisplayName {
HStack(spacing: 6) {
if #available(iOS 16.0, *) {
Text(officialName)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.strikethrough(isDeletedUser, color: Color.secondary)
} else {
Text(officialName)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
Image(systemName: "checkmark.seal.fill")
.foregroundColor(Color.accentColor)
.font(.caption)
}
} else {
if let officialName = officialDisplayName {
HStack(spacing: 6) {
if #available(iOS 16.0, *) {
Text(title)
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
Text(officialName)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.strikethrough(isDeletedUser, color: Color.secondary)
} else {
Text(title)
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
Text(officialName)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
Image(systemName: "checkmark.seal.fill")
.foregroundColor(Color.accentColor)
.font(.caption)
}
if let timestamp {
Spacer()
HStack(spacing: 4) {
if shouldShowReadStatus {
Image(systemName: readStatusIconName)
.foregroundColor(readStatusColor)
.font(.caption2)
}
Text(timestamp)
.font(.caption)
.foregroundColor(.secondary)
}
// if let login = loginDisplay {
// Text(login)
// .font(.footnote)
// .foregroundColor(.secondary)
// .lineLimit(1)
// .truncationMode(.tail)
// }
} else {
if #available(iOS 16.0, *) {
Text(title)
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.strikethrough(isDeletedUser, color: Color.secondary)
} else {
Text(title)
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
}
HStack {
Text(messagePreview)
.font(.subheadline)
.foregroundColor(subtitleColor)
.lineLimit(messageLimitLine)
.truncationMode(.tail)
if chat.unreadCount > 0 {
Spacer()
Text("\(chat.unreadCount)")
.font(.caption2.bold())
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule().fill(Color.accentColor)
)
}
}
Text(messagePreview)
.font(.subheadline)
.foregroundColor(subtitleColor)
.lineLimit(messageLimitLine)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
// Spacer()
Spacer()
// VStack(alignment: .trailing, spacing: 6) {
//// if let timestamp {
//// HStack(spacing: 4) {
//// if shouldShowReadStatus {
//// Image(systemName: readStatusIconName)
//// .foregroundColor(readStatusColor)
//// .font(.caption2)
//// }
////
//// Text(timestamp)
//// .font(.caption)
//// .foregroundColor(.secondary)
//// }
//// }
//
// if chat.unreadCount > 0 {
// Text("\(chat.unreadCount)")
// .font(.caption2.bold())
// .foregroundColor(.white)
// .padding(.horizontal, 8)
// .padding(.vertical, 4)
// .background(
// Capsule().fill(Color.accentColor)
// )
// }
// }
}
.padding(.vertical, 8)
}
@ViewBuilder
private var placeholderAvatar: some View {
Circle()
.fill(avatarBackgroundColor)
.frame(width: avatarSize, height: avatarSize)
.overlay(
Group {
if isDeletedUser {
Image(systemName: deletedUserSymbolName)
.symbolRenderingMode(.hierarchical)
.font(.system(size: avatarSize * 0.45, weight: .semibold))
.foregroundColor(avatarTextColor)
} else {
Text(initial)
.font(.system(size: avatarSize * 0.5, weight: .semibold))
.foregroundColor(avatarTextColor)
VStack(alignment: .trailing, spacing: 6) {
if let timestamp {
HStack(spacing: 4) {
if shouldShowReadStatus {
Image(systemName: readStatusIconName)
.foregroundColor(readStatusColor)
.font(.caption2)
}
Text(timestamp)
.font(.caption)
.foregroundColor(.secondary)
}
}
)
if chat.unreadCount > 0 {
Text("\(chat.unreadCount)")
.font(.caption2.bold())
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule().fill(Color.accentColor)
)
}
}
}
.padding(.vertical, 8)
}
private static func formattedTimestamp(for date: Date) -> String {
@ -1264,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,767 +0,0 @@
import SwiftUI
import Foundation
struct ContactsTab: View {
@ObservedObject private var loginViewModel: LoginViewModel
@State private var contacts: [Contact] = []
@State private var isLoading = false
@State private var loadError: String?
@State private var pagingError: String?
@State private var activeAlert: ContactsAlert?
@State private var hasMore = true
@State private var offset = 0
@State private var creatingChatForContactId: UUID?
@State private var pendingChatItem: PrivateChatListItem?
@State private var isPendingChatActive = false
@State private var contactAvatars: [UUID: AvatarInfo] = [:]
@State private var avatarLoadedIds: Set<UUID> = []
@State private var avatarLoadingIds: Set<UUID> = []
@State private var contactToEdit: Contact?
@State private var contactPendingBlock: Contact?
@State private var contactPendingDelete: Contact?
@State private var showBlockConfirmation = false
@State private var showDeleteConfirmation = false
@State private var blockingContactIds: Set<UUID> = []
@State private var deletingContactIds: Set<UUID> = []
private let contactsService = ContactsService()
private let chatService = ChatService()
private let profileService = ProfileService()
private let blockedUsersService = BlockedUsersService()
private let pageSize = 25
private var currentUserId: String? {
let identifier = loginViewModel.userId
return identifier.isEmpty ? nil : identifier
}
init(viewModel: LoginViewModel) {
self._loginViewModel = ObservedObject(wrappedValue: viewModel)
}
var body: some View {
List {
if isLoading && contacts.isEmpty {
loadingState
}
if let loadError, contacts.isEmpty {
errorState(loadError)
} else if contacts.isEmpty {
emptyState
} else {
ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in
Button {
openChat(for: contact)
} label: {
ContactRow(
contact: contact,
avatarInfo: contactAvatars[contact.id],
currentUserId: currentUserId,
isLoading: isRowBusy(contact)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(isRowBusy(contact))
.contextMenu {
Button {
handleContactAction(.edit, for: contact)
} label: {
Label(
NSLocalizedString("Изменить контакт", comment: "Contacts context action edit"),
systemImage: "square.and.pencil"
)
}
.disabled(contact.isDeleted)
Button {
handleContactAction(.block, for: contact)
} label: {
Label(
NSLocalizedString("Заблокировать контакт", comment: "Contacts context action block"),
systemImage: "hand.raised.fill"
)
}
.disabled(contact.isDeleted)
Button(role: .destructive) {
handleContactAction(.delete, for: contact)
} label: {
Label(
NSLocalizedString("Удалить контакт", comment: "Contacts context action delete"),
systemImage: "trash"
)
}
}
.listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12))
.onAppear {
loadAvatarIfNeeded(for: contact)
if index >= contacts.count - 5 {
Task {
await loadContacts(reset: false)
}
}
}
}
if isLoading && !contacts.isEmpty {
loadingState
} else if let pagingError, !contacts.isEmpty {
pagingErrorState(pagingError)
}
}
}
.navigationTitle(NSLocalizedString("Контакты", comment: "Contacts tab title"))
.background(Color(UIColor.systemBackground))
.listStyle(.plain)
.task {
await loadContacts(reset: false)
}
.alert(item: $activeAlert) { alert in
switch alert {
case .error(_, let message):
return Alert(
title: Text(NSLocalizedString("Ошибка", comment: "Contacts load error title")),
message: Text(message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
case .info(_, let title, let message):
return Alert(
title: Text(title),
message: Text(message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
}
}
.sheet(item: $contactToEdit) { contact in
NavigationView {
ContactEditView(
contact: contactEditInfo(for: contact),
onContactDeleted: {
handleContactRemoved(contact.id)
},
onContactUpdated: { newName in
handleContactRenamed(contact.id, newName: newName)
}
)
}
}
.confirmationDialog(
NSLocalizedString("Заблокировать контакт?", comment: "Contacts block confirmation title"),
isPresented: $showBlockConfirmation,
presenting: contactPendingBlock
) { contact in
Button(NSLocalizedString("Заблокировать", comment: "Contacts block confirm action"), role: .destructive) {
showBlockConfirmation = false
contactPendingBlock = nil
performBlockContact(contact)
}
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
showBlockConfirmation = false
contactPendingBlock = nil
}
} message: { contact in
Text(String(
format: NSLocalizedString("Пользователь \"%1$@\" будет добавлен в чёрный список.", comment: "Contacts block confirmation message"),
contact.displayName
))
}
.confirmationDialog(
NSLocalizedString("Удалить контакт?", comment: "Contacts delete confirmation title"),
isPresented: $showDeleteConfirmation,
presenting: contactPendingDelete
) { contact in
Button(NSLocalizedString("Удалить", comment: "Contacts delete confirm action"), role: .destructive) {
showDeleteConfirmation = false
contactPendingDelete = nil
performDeleteContact(contact)
}
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
showDeleteConfirmation = false
contactPendingDelete = nil
}
} message: { contact in
Text(String(
format: NSLocalizedString("Контакт \"%1$@\" будет удалён из списка.", comment: "Contacts delete confirmation message"),
contact.displayName
))
}
.overlay(pendingChatNavigationLink)
}
private var loadingState: some View {
HStack {
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Spacer()
}
.padding(.vertical, 18)
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
.listRowSeparator(.hidden)
}
private func errorState(_ message: String) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer()
Button(action: { Task { await refreshContacts() } }) {
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
.font(.subheadline)
}
}
.padding(.vertical, 10)
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
.listRowSeparator(.hidden)
}
private func pagingErrorState(_ message: String) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer()
Button(action: { Task { await loadContacts(reset: false) } }) {
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
.font(.subheadline)
}
}
.padding(.vertical, 10)
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
.listRowSeparator(.hidden)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "person.crop.circle.badge.questionmark")
.font(.system(size: 52))
.foregroundColor(.secondary)
Text(NSLocalizedString("Контактов пока нет", comment: "Contacts empty state title"))
.font(.headline)
.multilineTextAlignment(.center)
Text(NSLocalizedString("Добавьте контакты, чтобы быстрее выходить на связь.", comment: "Contacts empty state subtitle"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.listRowInsets(EdgeInsets(top: 20, leading: 12, bottom: 20, trailing: 12))
.listRowSeparator(.hidden)
}
@MainActor
private func refreshContacts() async {
hasMore = true
offset = 0
pagingError = nil
loadError = nil
contacts.removeAll()
await loadContacts(reset: true)
}
@MainActor
private func loadContacts(reset: Bool) async {
if isLoading { return }
if !reset && !hasMore { return }
isLoading = true
if offset == 0 {
loadError = nil
}
pagingError = nil
do {
let payload = try await contactsService.fetchContacts(limit: pageSize, offset: offset)
let newContacts = payload.items.map(Contact.init)
if reset {
contacts = newContacts
} else {
contacts.append(contentsOf: newContacts)
}
offset += newContacts.count
hasMore = payload.hasMore
} catch {
let message = error.localizedDescription
if contacts.isEmpty {
loadError = message
} else {
pagingError = message
}
if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") }
}
isLoading = false
}
private func handleContactAction(_ action: ContactAction, for contact: Contact) {
guard !isRowBusy(contact) else { return }
switch action {
case .edit:
contactToEdit = contact
case .block:
contactPendingBlock = contact
showBlockConfirmation = true
case .delete:
contactPendingDelete = contact
showDeleteConfirmation = true
}
}
private var pendingChatNavigationLink: some View {
NavigationLink(
destination: pendingChatDestination,
isActive: Binding(
get: { isPendingChatActive && pendingChatItem != nil },
set: { newValue in
if !newValue {
isPendingChatActive = false
pendingChatItem = nil
}
}
)
) {
EmptyView()
}
.hidden()
}
@ViewBuilder
private var pendingChatDestination: some View {
if let pendingChatItem {
PrivateChatView(chat: pendingChatItem, currentUserId: currentUserId)
} else {
EmptyView()
}
}
private func openChat(for contact: Contact) {
guard creatingChatForContactId == nil else { return }
creatingChatForContactId = contact.id
chatService.createOrFindPrivateChat(targetUserId: contact.id.uuidString) { result in
DispatchQueue.main.async {
creatingChatForContactId = nil
switch result {
case .success(let data):
let chatItem = PrivateChatListItem(
chatId: data.chatId,
chatType: data.chatType,
chatData: chatProfile(for: contact),
lastMessage: nil,
createdAt: nil,
unreadCount: 0
)
pendingChatItem = chatItem
isPendingChatActive = true
case .failure(let error):
activeAlert = .error(message: friendlyChatCreationMessage(for: error))
}
}
}
}
private func chatProfile(for contact: Contact) -> ChatProfile {
ChatProfile(
userId: contact.id.uuidString,
login: contact.login,
fullName: contact.fullName,
customName: contact.customName,
createdAt: contact.createdAt,
isOfficial: false
)
}
private func friendlyChatCreationMessage(for error: Error) -> String {
if let chatError = error as? ChatServiceError {
return chatError.errorDescription ?? NSLocalizedString("Не удалось открыть чат.", comment: "Chat creation fallback")
}
if let networkError = error as? NetworkError {
switch networkError {
case .unauthorized:
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Chat creation unauthorized")
case .invalidURL, .noResponse:
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Chat creation connection")
case .network(let underlying):
return String(format: NSLocalizedString("Ошибка сети: %@", comment: "Chat creation network error"), underlying.localizedDescription)
case .server(let statusCode, let data):
if let data {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let payload = try? decoder.decode(ErrorResponse.self, from: data) {
if let detail = payload.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty {
return detail
}
if let message = payload.data?.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
return message
}
}
if let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
return raw
}
}
return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: "Chat creation server status"), "\(statusCode)")
}
}
return NSLocalizedString("Произошла неизвестная ошибка. Попробуйте позже.", comment: "Chat creation unknown error")
}
private func loadAvatarIfNeeded(for contact: Contact) {
guard !contact.isDeleted else { return }
let contactId = contact.id
if avatarLoadedIds.contains(contactId) || avatarLoadingIds.contains(contactId) {
return
}
avatarLoadingIds.insert(contactId)
Task {
do {
let profile = try await profileService.fetchProfile(userId: contactId)
await MainActor.run {
if let info = profile.avatars?.current {
contactAvatars[contactId] = info
}
avatarLoadedIds.insert(contactId)
avatarLoadingIds.remove(contactId)
}
} catch {
if AppConfig.DEBUG {
print("[ContactsTab] load avatar failed for \(contactId): \(error)")
}
await MainActor.run {
avatarLoadingIds.remove(contactId)
}
}
}
}
private func performBlockContact(_ contact: Contact) {
let contactId = contact.id
guard !blockingContactIds.contains(contactId) else { return }
blockingContactIds.insert(contactId)
Task {
do {
_ = try await blockedUsersService.add(userId: contactId)
await MainActor.run {
blockingContactIds.remove(contactId)
handleContactRemoved(contactId)
}
} catch {
await MainActor.run {
blockingContactIds.remove(contactId)
activeAlert = .error(message: error.localizedDescription)
}
}
}
}
private func performDeleteContact(_ contact: Contact) {
let contactId = contact.id
guard !deletingContactIds.contains(contactId) else { return }
deletingContactIds.insert(contactId)
Task {
do {
try await contactsService.removeContact(userId: contactId)
await MainActor.run {
deletingContactIds.remove(contactId)
handleContactRemoved(contactId)
}
} catch {
await MainActor.run {
deletingContactIds.remove(contactId)
activeAlert = .error(message: error.localizedDescription)
}
}
}
}
private func contactEditInfo(for contact: Contact) -> ContactEditInfo {
ContactEditInfo(
userId: contact.id,
login: contact.login,
fullName: contact.fullName,
customName: contact.customName,
avatarFileId: contactAvatars[contact.id]?.fileId
)
}
private func handleContactRenamed(_ contactId: UUID, newName: String) {
guard let index = contacts.firstIndex(where: { $0.id == contactId }) else { return }
contacts[index] = contacts[index].updatingCustomName(newName)
}
private func handleContactRemoved(_ contactId: UUID) {
contacts.removeAll { $0.id == contactId }
contactAvatars.removeValue(forKey: contactId)
avatarLoadedIds.remove(contactId)
avatarLoadingIds.remove(contactId)
if creatingChatForContactId == contactId {
creatingChatForContactId = nil
}
blockingContactIds.remove(contactId)
deletingContactIds.remove(contactId)
if contactToEdit?.id == contactId {
contactToEdit = nil
}
if contactPendingBlock?.id == contactId {
contactPendingBlock = nil
showBlockConfirmation = false
}
if contactPendingDelete?.id == contactId {
contactPendingDelete = nil
showDeleteConfirmation = false
}
}
private func isRowBusy(_ contact: Contact) -> Bool {
creatingChatForContactId == contact.id
|| blockingContactIds.contains(contact.id)
|| deletingContactIds.contains(contact.id)
}
}
private struct ContactRow: View {
let contact: Contact
let avatarInfo: AvatarInfo?
let currentUserId: String?
let isLoading: Bool
private let avatarSize: CGFloat = 40
init(contact: Contact, avatarInfo: AvatarInfo? = nil, currentUserId: String? = nil, isLoading: Bool = false) {
self.contact = contact
self.avatarInfo = avatarInfo
self.currentUserId = currentUserId
self.isLoading = isLoading
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
avatarView
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
if #available(iOS 16.0, *) {
Text(contact.displayName)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.strikethrough(contact.isDeleted, color: .secondary)
} else {
Text(contact.displayName)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.strikethrough(contact.isDeleted)
}
Spacer()
Text(contact.formattedCreatedAt)
.font(.caption2)
.foregroundColor(.secondary)
}
if let handle = contact.handle {
Text(handle)
.font(.caption2)
.foregroundColor(.secondary)
}
if contact.friendCode {
friendCodeBadge
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if isLoading {
ProgressView()
.scaleEffect(0.8)
.padding(.top, 4)
}
}
.padding(.vertical, 6)
}
@ViewBuilder
private var avatarView: some View {
if let fileId = avatarInfo?.fileId,
let url = avatarURL(for: fileId),
let currentUserId {
CachedAvatarView(url: url, fileId: fileId, userId: currentUserId) {
placeholderAvatar
}
.aspectRatio(contentMode: .fill)
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
placeholderAvatar
}
}
private func avatarURL(for fileId: String) -> URL? {
let userId = contact.id.uuidString
let path = "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)"
return URL(string: path)
}
private var placeholderAvatar: some View {
Circle()
.fill(avatarBackgroundColor)
.frame(width: avatarSize, height: avatarSize)
.overlay(
Group {
if contact.isDeleted {
Image(systemName: "person.slash")
.symbolRenderingMode(.hierarchical)
.font(.system(size: avatarSize * 0.45, weight: .semibold))
.foregroundColor(avatarTextColor)
} else {
Text(contact.initials)
.font(.system(size: avatarSize * 0.5, weight: .semibold))
.foregroundColor(avatarTextColor)
}
}
)
}
private var avatarBackgroundColor: Color {
contact.isDeleted ? Color(.systemGray5) : Color.accentColor.opacity(0.15)
}
private var avatarTextColor: Color {
contact.isDeleted ? Color.accentColor : Color.accentColor
}
private var friendCodeBadge: some View {
Text(NSLocalizedString("Код дружбы", comment: "Friend code badge"))
.font(.caption2.weight(.medium))
.foregroundColor(Color.accentColor)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.accentColor.opacity(0.12))
.clipShape(Capsule())
}
}
private struct Contact: Identifiable, Equatable {
let id: UUID
let login: String?
let fullName: String?
let customName: String?
let friendCode: Bool
let createdAt: Date
let displayName: String
let handle: String?
var isDeleted: Bool {
login?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
}
var initials: String {
if isDeleted { return "" }
let nameSource: String?
if let customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = customName
} else if let fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = fullName
} else {
nameSource = nil
}
if let name = nameSource {
let components = name.split(separator: " ")
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials.map { String($0) }.joined().uppercased()
}
}
return String(login!.prefix(1)).uppercased()
}
var formattedCreatedAt: String {
Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date())
}
init(payload: ContactPayload) {
self.id = payload.userId
self.login = payload.login
self.fullName = payload.fullName
self.customName = payload.customName
self.friendCode = payload.friendCode
self.createdAt = payload.createdAt
let isUserDeleted = payload.login?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
if isUserDeleted {
self.displayName = NSLocalizedString("Неизвестный пользователь", comment: "Deleted user display name")
self.handle = nil
} else {
if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = customName
} else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = fullName
} else {
self.displayName = payload.login!
}
if let login = payload.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.handle = "@\(login)"
} else {
self.handle = nil
}
}
}
func updatingCustomName(_ newName: String) -> Contact {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
let updatedCustomName = trimmed.isEmpty ? nil : trimmed
let payload = ContactPayload(
userId: id,
login: login,
fullName: fullName,
customName: updatedCustomName,
friendCode: friendCode,
createdAt: createdAt
)
return Contact(payload: payload)
}
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter
}()
}
private enum ContactsAlert: Identifiable {
case error(id: UUID = UUID(), message: String)
case info(id: UUID = UUID(), title: String, message: String)
var id: String {
switch self {
case .error(let id, _), .info(let id, _, _):
return id.uuidString
}
}
}
private enum ContactAction {
case edit
case block
case delete
}

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(viewModel: viewModel)
.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,416 +0,0 @@
import SwiftUI
struct BlockedUsersView: View {
@State private var blockedUsers: [BlockedUser] = []
@State private var isLoading = false
@State private var hasMore = true
@State private var offset = 0
@State private var loadError: String?
@State private var pendingUnblock: BlockedUser?
@State private var showUnblockConfirmation = false
@State private var removingUserIds: Set<UUID> = []
@State private var activeAlert: ActiveAlert?
@State private var errorMessageDown: String?
@State private var isAddUserSheetPresented = false
@State private var newBlockedUserLogin = ""
@State private var addBlockedUserError: String?
@State private var isProcessingAddBlockedUser = false
@FocusState private var isAddBlockedUserFieldFocused: Bool
private let blockedUsersService = BlockedUsersService()
private let limit = 20
var body: some View {
List {
if isLoading && blockedUsers.isEmpty {
initialLoadingState
} else if let loadError, blockedUsers.isEmpty {
errorState(loadError)
} else if blockedUsers.isEmpty {
emptyState
} else {
usersSection
}
}
.navigationTitle(NSLocalizedString("Чёрный список", comment: ""))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
isAddUserSheetPresented = true
} label: {
Image(systemName: "plus")
}
}
}
.task {
await loadBlockedUsers()
}
.sheet(isPresented: $isAddUserSheetPresented, onDismiss: resetAddBlockedUserForm) {
addBlockedUserSheet
}
.alert(item: $activeAlert) { alert in
switch alert {
case .error(_, let message):
return Alert(
title: Text(NSLocalizedString("Ошибка", comment: "Common error title")),
message: Text(message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
}
}
.confirmationDialog(
NSLocalizedString("Удалить из заблокированных?", comment: "Unblock confirmation title"),
isPresented: $showUnblockConfirmation,
presenting: pendingUnblock
) { user in
Button(NSLocalizedString("Разблокировать", comment: "Unblock confirmation action"), role: .destructive) {
pendingUnblock = nil
showUnblockConfirmation = false
Task {
await unblock(user)
}
}
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
pendingUnblock = nil
showUnblockConfirmation = false
}
} message: { user in
Text(String(format: NSLocalizedString("Пользователь \"%1$@\" будет удалён из списка заблокированных.", comment: "Unblock confirmation message"), user.displayName))
}
}
private var addBlockedUserSheet: some View {
NavigationView {
Form {
Section(
header: Text(NSLocalizedString("Логин пользователя", comment: "Blocked users add login header")),
footer: Text(NSLocalizedString("Введите юзернейм человека, которого нужно заблокировать. Символ @ указывать не нужно.", comment: "Blocked users add login footer"))
.font(.footnote)
.foregroundColor(.secondary)
) {
TextField(NSLocalizedString("Например, username", comment: "Blocked users add login placeholder"), text: $newBlockedUserLogin)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.asciiCapable)
.focused($isAddBlockedUserFieldFocused)
}
if let addBlockedUserError {
Section {
Text(addBlockedUserError)
.font(.footnote)
.foregroundColor(.red)
.multilineTextAlignment(.leading)
}
}
}
.navigationTitle(NSLocalizedString("Заблокировать", comment: "Blocked users add title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Отмена", comment: "Common cancel")) {
isAddUserSheetPresented = false
}
}
ToolbarItem(placement: .confirmationAction) {
if isProcessingAddBlockedUser {
ProgressView()
} else {
Button(NSLocalizedString("Заблокировать", comment: "Blocked users add confirm")) {
submitAddBlockedUser()
}
.disabled(!canSubmitNewBlockedUser)
}
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isAddBlockedUserFieldFocused = true
}
}
}
}
private var usersSection: some View {
Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) {
ForEach(Array(blockedUsers.enumerated()), id: \.element.id) { index, user in
userRow(user, index: index)
}
if isLoading && !blockedUsers.isEmpty {
Text("Идет загрузка...")
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else if let errorMessage = errorMessageDown {
Text(errorMessage)
.foregroundColor(.red)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
}
private var trimmedNewBlockedUserLogin: String {
newBlockedUserLogin.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSubmitNewBlockedUser: Bool {
!trimmedNewBlockedUserLogin.isEmpty && !isProcessingAddBlockedUser
}
private func userRow(_ user: BlockedUser, index: Int) -> some View {
HStack(spacing: 12) {
Circle()
.fill(user.isDeleted ? Color(.systemGray5) : Color.accentColor.opacity(0.15))
.frame(width: 44, height: 44)
.overlay(
Group {
if user.isDeleted {
Image(systemName: "person.slash")
.font(.headline)
.foregroundColor(Color(.systemGray2))
} else {
Text(user.initials)
.font(.headline)
.foregroundColor(.accentColor)
}
}
)
VStack(alignment: .leading, spacing: 4) {
if #available(iOS 16.0, *) {
Text(user.displayName)
.font(.body)
.strikethrough(user.isDeleted, color: .secondary)
} else {
Text(user.displayName)
.font(.body)
.strikethrough(user.isDeleted)
}
if let handle = user.handle {
Text(handle)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(.vertical, 0)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
pendingUnblock = user
showUnblockConfirmation = true
} label: {
Label(NSLocalizedString("Разблокировать", comment: ""), systemImage: "person.crop.circle.badge.xmark")
}
// .disabled(removingUserIds.contains(user.id) || user.isDeleted)
.disabled(removingUserIds.contains(user.id))
}
.onAppear {
if index >= blockedUsers.count - 5 {
Task {
await loadBlockedUsers()
}
}
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "hand.raised")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text(NSLocalizedString("У вас нет заблокированных пользователей", comment: ""))
.font(.headline)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 32)
.listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
.listRowSeparator(.hidden)
}
private var initialLoadingState: some View {
Section {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func errorState(_ message: String) -> some View {
Section {
Text(message)
.foregroundColor(.red)
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func submitAddBlockedUser() {
guard canSubmitNewBlockedUser else { return }
let login = trimmedNewBlockedUserLogin
isProcessingAddBlockedUser = true
addBlockedUserError = nil
Task {
await performAddBlockedUser(login: login)
}
}
private func resetAddBlockedUserForm() {
newBlockedUserLogin = ""
addBlockedUserError = nil
isProcessingAddBlockedUser = false
isAddBlockedUserFieldFocused = false
}
private func performAddBlockedUser(login: String) async {
do {
let payload = try await blockedUsersService.add(login: login)
let newUser = BlockedUser(payload: payload)
await MainActor.run {
let existed = blockedUsers.contains(where: { $0.id == newUser.id })
blockedUsers.removeAll { $0.id == newUser.id }
blockedUsers.insert(newUser, at: 0)
if !existed {
offset += 1
}
isAddUserSheetPresented = false
}
} catch {
if AppConfig.DEBUG {
print("[BlockedUsersView] add blocked user failed: \(error)")
}
await MainActor.run {
addBlockedUserError = error.localizedDescription
}
}
await MainActor.run {
isProcessingAddBlockedUser = false
}
}
@MainActor
private func loadBlockedUsers() async {
errorMessageDown = nil
guard !isLoading, hasMore else {
return
}
isLoading = true
defer { isLoading = false }
if offset == 0 {
loadError = nil
}
do {
let payload = try await blockedUsersService.fetchBlockedUsers(limit: limit, offset: offset)
blockedUsers.append(contentsOf: payload.items.map(BlockedUser.init))
offset += payload.items.count
hasMore = payload.hasMore
} catch {
let message = error.localizedDescription
if offset == 0 {
loadError = message
}
// activeAlert = .error(message: message)
errorMessageDown = message
if AppConfig.DEBUG { print("[BlockedUsersView] load blocked users failed: \(error)") }
}
}
@MainActor
private func unblock(_ user: BlockedUser) async {
guard !removingUserIds.contains(user.id) else { return }
removingUserIds.insert(user.id)
defer { removingUserIds.remove(user.id) }
do {
_ = try await blockedUsersService.remove(userId: user.id)
blockedUsers.removeAll { $0.id == user.id }
} catch {
activeAlert = .error(message: error.localizedDescription)
if AppConfig.DEBUG { print("[BlockedUsersView] unblock failed: \(error)") }
}
}
}
private struct BlockedUser: Identifiable, Equatable {
let id: UUID
let login: String?
let fullName: String?
let customName: String?
let createdAt: Date
private(set) var displayName: String
private(set) var handle: String?
var isDeleted: Bool {
login?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
}
var initials: String {
if isDeleted { return "" }
let nameSource: String?
if let customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = customName
} else if let fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = fullName
} else {
nameSource = nil
}
if let name = nameSource {
let components = name.split(separator: " ")
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials.map { String($0) }.joined().uppercased()
}
}
return String(login!.prefix(1)).uppercased()
}
init(payload: BlockedUserInfo) {
self.id = payload.userId
self.login = payload.login
self.fullName = payload.fullName
self.customName = payload.customName
self.createdAt = payload.createdAt
let isUserDeleted = payload.login?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
if isUserDeleted {
self.displayName = NSLocalizedString("Неизвестный пользователь", comment: "Deleted user display name")
self.handle = nil
} else {
if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = customName
} else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = fullName
} else {
self.displayName = payload.login!
}
self.handle = "@\(payload.login!)"
}
}
}
private enum ActiveAlert: Identifiable {
case error(id: UUID = UUID(), message: String)
var id: String {
switch self {
case .error(let id, _):
return id.uuidString
}
}
}

View File

@ -1,141 +0,0 @@
//
// DataSettingsView.swift
// yobble
//
// Created by cheykrym on 10.12.2025.
//
import SwiftUI
struct DataSettingsView: View {
let currentUserId: String
private let cacheService = AvatarCacheService.shared
@State private var cachedUsers: [CachedUserInfo] = []
@State private var totalCacheSize: Int64 = 0
@State private var showClearAllConfirmation = false
@State private var showClearOthersConfirmation = false
@State private var showClearCurrentConfirmation = false
var body: some View {
Form {
Section(header: Text("Общая информация")) {
HStack {
Text("Общий размер")
Spacer()
Text(format(bytes: totalCacheSize))
.foregroundColor(.secondary)
}
}
Section(header: Text("Массовая отчистка")) {
Button("Очистить кэш текущего пользователя", role: .destructive) {
showClearCurrentConfirmation = true
}
.confirmationDialog(
"Вы уверены, что хотите очистить кэш для текущего пользователя?",
isPresented: $showClearCurrentConfirmation,
titleVisibility: .visible
) {
Button("Очистить", role: .destructive) {
clearCache(for: currentUserId)
}
}
Button("Очистить кэш (кроме текущего)", role: .destructive) {
showClearOthersConfirmation = true
}
.confirmationDialog(
"Вы уверены, что хотите очистить кэш для всех, кроме текущего пользователя?",
isPresented: $showClearOthersConfirmation,
titleVisibility: .visible
) {
Button("Очистить", role: .destructive, action: clearOtherUsersCache)
}
Button("Очистить весь кэш", role: .destructive) {
showClearAllConfirmation = true
}
.confirmationDialog(
"Вы уверены, что хотите очистить весь кэш аватаров? Это действие необратимо.",
isPresented: $showClearAllConfirmation,
titleVisibility: .visible
) {
Button("Очистить всё", role: .destructive, action: clearAllCache)
}
}
Section(header: Text("Кэш по пользователям")) {
if cachedUsers.isEmpty {
Text("Кэш пуст")
.foregroundColor(.secondary)
} else {
ForEach(cachedUsers) { user in
HStack {
VStack(alignment: .leading) {
Text(user.id)
.font(.system(.body, design: .monospaced))
.lineLimit(1)
.truncationMode(.middle)
if user.id == currentUserId {
Text("Текущий")
.font(.caption)
.foregroundColor(.accentColor)
}
}
Spacer()
Text(format(bytes: user.size))
.foregroundColor(.secondary)
Button("Очистить") {
clearCache(for: user.id)
}
.buttonStyle(.borderless)
}
}
}
}
}
.navigationTitle("Данные и кэш")
.onAppear(perform: refreshCacheStats)
}
private func refreshCacheStats() {
let userIds = cacheService.getAllCachedUserIds()
self.cachedUsers = userIds.map { id in
let size = cacheService.sizeOfCache(forUserId: id)
return CachedUserInfo(id: id, size: size)
}.sorted { $0.size > $1.size }
self.totalCacheSize = cacheService.sizeOfAllCache()
}
private func clearCache(for userId: String) {
cacheService.clearCache(forUserId: userId)
refreshCacheStats()
}
private func clearAllCache() {
cacheService.clearAllCache()
refreshCacheStats()
}
private func clearOtherUsersCache() {
let otherUsers = cachedUsers.filter { $0.id != currentUserId }
for user in otherUsers {
cacheService.clearCache(forUserId: user.id)
}
refreshCacheStats()
}
private func format(bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useAll]
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
}
struct CachedUserInfo: Identifiable {
let id: String
let size: Int64
}

View File

@ -70,27 +70,35 @@ struct EditPrivacyView: View {
Toggle(NSLocalizedString("Показывать био не-контактам", comment: ""), isOn: $profilePermissions.showBioToNonContacts)
Toggle(NSLocalizedString("Показывать сторисы не-контактам", comment: ""), isOn: $profilePermissions.showStoriesToNonContacts)
privacyScopePicker(
title: NSLocalizedString("Видимость статуса 'был в сети'", comment: ""),
selection: $profilePermissions.lastSeenVisibility
)
Picker(NSLocalizedString("Видимость статуса 'был в сети'", comment: ""), selection: $profilePermissions.lastSeenVisibility) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
}
Section(header: Text(NSLocalizedString("Приглашения и звонки", comment: ""))) {
privacyScopePicker(
title: NSLocalizedString("Кто может приглашать в паблики", comment: ""),
selection: $profilePermissions.publicInvitePermission
)
privacyScopePicker(
title: NSLocalizedString("Кто может приглашать в беседы", comment: ""),
selection: $profilePermissions.groupInvitePermission
)
privacyScopePicker(
title: NSLocalizedString("Кто может звонить", comment: ""),
selection: $profilePermissions.callPermission
)
Picker(NSLocalizedString("Кто может приглашать в паблики", comment: ""), selection: $profilePermissions.publicInvitePermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
Picker(NSLocalizedString("Кто может приглашать в беседы", comment: ""), selection: $profilePermissions.groupInvitePermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
Picker(NSLocalizedString("Кто может звонить", comment: ""), selection: $profilePermissions.callPermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
}
Section(header: Text(NSLocalizedString("Чаты и хранение", comment: ""))) {
@ -184,24 +192,6 @@ struct EditPrivacyView: View {
return "\(secondsString) (≈ \(formattedHours) ч.)"
}
}
@ViewBuilder
private func privacyScopePicker(title: String, selection: Binding<Int>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.body)
.foregroundColor(.primary)
Picker("", selection: selection) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
}
private enum PrivacyScope: Int, CaseIterable, Identifiable {

View File

@ -1,729 +1,24 @@
import SwiftUI
struct EditProfileView: View {
// State for form fields
@State private var displayName = ""
@State private var description = ""
@State private var originalDisplayName = ""
@State private var originalDescription = ""
// State for profile data and avatar
@State private var profile: ProfileDataPayload?
@State private var avatarImage: UIImage?
@State private var showImagePicker = false
// State for loading and errors
@State private var isLoading = false
@State private var isSaving = false
@State private var isUploadingAvatar = false
@State private var isPreparingDownload = false
@State private var alertMessage: String?
@State private var showAlert = false
@State private var avatarViewerState: AvatarViewerState?
@State private var shareItems: [Any] = []
@State private var showShareSheet = false
private let profileService = ProfileService()
private let descriptionLimit = 1024
private let nameLimit = 32
var body: some View {
ZStack {
Form {
Section {
HStack {
Spacer()
VStack {
if let image = avatarImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 120, height: 120)
.clipShape(Circle())
.contentShape(Rectangle())
.onTapGesture {
presentAvatarViewer()
}
} else if let profile = profile,
let fileId = profile.avatars?.current?.fileId,
let url = avatarUrl(for: profile, fileId: fileId) {
CachedAvatarView(url: url, fileId: fileId, userId: profile.userId.uuidString) {
avatarPlaceholder
}
.aspectRatio(contentMode: .fill)
.frame(width: 120, height: 120)
.clipShape(Circle())
.contentShape(Rectangle())
.onTapGesture {
presentAvatarViewer()
}
} else {
avatarPlaceholder
.onTapGesture {
presentAvatarViewer()
}
}
Button("Изменить фото") {
showImagePicker = true
}
.padding(.top, 8)
}
Spacer()
}
}
.listRowBackground(Color(UIColor.systemGroupedBackground))
Section(header: Text("Публичная информация")) {
TextField("Отображаемое имя", text: $displayName)
.onChange(of: displayName) { newValue in
if newValue.count > nameLimit {
displayName = String(newValue.prefix(nameLimit))
}
}
VStack(alignment: .leading, spacing: 5) {
Text("Описание")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $description)
.frame(height: 150)
.onChange(of: description) { newValue in
if newValue.count > descriptionLimit {
description = String(newValue.prefix(descriptionLimit))
}
}
HStack {
Spacer()
Text("\(description.count) / \(descriptionLimit)")
.font(.caption)
.foregroundColor(description.count > descriptionLimit ? .red : .secondary)
}
}
}
Button(action: {
Task {
await applyProfileChanges()
}
}) {
if isSaving {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
} else {
Text("Применить")
.frame(maxWidth: .infinity, alignment: .center)
}
}
.disabled(!hasProfileChanges || isBusy)
}
.navigationTitle("Профиль")
.onAppear(perform: loadProfile)
.sheet(isPresented: $showImagePicker) {
ImagePicker(image: $avatarImage, allowsEditing: true)
}
.onChange(of: avatarImage) { newValue in
guard let image = newValue else { return }
Task {
await uploadAvatarImage(image)
}
}
.alert("Ошибка", isPresented: $showAlert, presenting: alertMessage) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
.fullScreenCover(item: $avatarViewerState) { state in
AvatarViewerView(
state: state,
onClose: { avatarViewerState = nil },
onDownload: { handleAvatarDownload(for: state) },
onDelete: handleAvatarDeletion
)
}
.sheet(isPresented: $showShareSheet) {
ActivityView(activityItems: shareItems)
}
if isBusy {
Color.black.opacity(0.4).ignoresSafeArea()
ProgressView(busyMessage)
.padding()
.background(Color.secondary.colorInvert())
.cornerRadius(10)
}
}
}
private var avatarPlaceholder: some View {
Circle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 120, height: 120)
.overlay(
Text(profileInitials)
.font(.system(size: 48, weight: .semibold))
.foregroundColor(.gray)
)
}
private var profileInitials: String {
if let initials = initials(from: displayName) {
return initials
}
if let profile = profile,
let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty,
let initials = initials(from: name) {
return initials
}
if let username = profile?.login.trimmingCharacters(in: .whitespacesAndNewlines), !username.isEmpty {
return String(username.prefix(1)).uppercased()
}
return "?"
}
private func avatarUrl(for profile: ProfileDataPayload, fileId: String) -> URL? {
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(profile.userId)?file_id=\(fileId)")
}
private func loadProfile() {
isLoading = true
Task {
do {
let profile = try await profileService.fetchMyProfile()
await MainActor.run {
self.updateForm(with: profile)
self.isLoading = false
}
} catch {
await MainActor.run {
self.alertMessage = error.localizedDescription
self.showAlert = true
self.isLoading = false
}
}
}
}
private var hasProfileChanges: Bool {
displayName != originalDisplayName || description != originalDescription
}
private var isBusy: Bool {
isLoading || isSaving || isUploadingAvatar || isPreparingDownload
}
private var busyMessage: String {
if isUploadingAvatar {
return "Обновление аватара..."
}
if isPreparingDownload {
return "Подготовка изображения..."
}
if isSaving {
return "Сохранение..."
}
return "Загрузка..."
}
@MainActor
private func applyProfileChanges() async {
guard !isSaving else { return }
guard let currentProfile = profile else {
alertMessage = NSLocalizedString("Профиль пока не загружен. Попробуйте позже.", comment: "Profile not ready error")
showAlert = true
return
}
isSaving = true
let request = ProfileUpdateRequestPayload(
fullName: displayName,
bio: description,
profilePermissions: ProfilePermissionsRequestPayload(payload: currentProfile.profilePermissions)
)
do {
_ = try await profileService.updateProfile(request)
let refreshedProfile = try await profileService.fetchMyProfile()
updateForm(with: refreshedProfile)
} catch {
let message: String
if let error = error as? LocalizedError, let description = error.errorDescription {
message = description
} else {
message = error.localizedDescription
}
alertMessage = message
showAlert = true
}
isSaving = false
}
@MainActor
private func uploadAvatarImage(_ image: UIImage) async {
guard !isUploadingAvatar else { return }
isUploadingAvatar = true
defer { isUploadingAvatar = false }
do {
_ = try await profileService.uploadAvatar(image: image)
let refreshedProfile = try await profileService.fetchMyProfile()
updateFormPreservingFields(profile: refreshedProfile)
avatarImage = nil
} catch {
let message: String
if let error = error as? LocalizedError, let description = error.errorDescription {
message = description
} else {
message = error.localizedDescription
}
alertMessage = message
showAlert = true
}
}
@MainActor
private func updateForm(with profile: ProfileDataPayload) {
self.profile = profile
applyProfileTexts(from: profile)
}
@MainActor
private func updateFormPreservingFields(profile: ProfileDataPayload) {
self.profile = profile
if !hasProfileChanges {
applyProfileTexts(from: profile)
}
}
@MainActor
private func applyProfileTexts(from profile: ProfileDataPayload) {
let loadedName = profile.fullName ?? ""
let loadedBio = profile.bio ?? ""
self.displayName = loadedName
self.description = loadedBio
self.originalDisplayName = loadedName
self.originalDescription = loadedBio
}
private func presentAvatarViewer() {
if let image = avatarImage {
avatarViewerState = AvatarViewerState(
source: .local(image),
intrinsicSize: image.size
)
return
}
guard let profile,
let fileId = profile.avatars?.current?.fileId,
let url = avatarUrl(for: profile, fileId: fileId) else { return }
let intrinsicSize: CGSize?
if let width = profile.avatars?.current?.width,
let height = profile.avatars?.current?.height,
width > 0,
height > 0 {
intrinsicSize = CGSize(width: CGFloat(width), height: CGFloat(height))
} else {
intrinsicSize = nil
}
avatarViewerState = AvatarViewerState(
source: .remote(url: url, fileId: fileId, userId: profile.userId.uuidString),
intrinsicSize: intrinsicSize
)
}
private func handleAvatarDownload(for state: AvatarViewerState) {
guard !isPreparingDownload else { return }
isPreparingDownload = true
Task {
do {
let image = try await resolveImage(for: state)
await MainActor.run {
shareItems = [image]
showShareSheet = true
}
} catch {
await MainActor.run {
alertMessage = error.localizedDescription
showAlert = true
}
Form {
Section(header: Text("Публичная информация")) {
TextField("Отображаемое имя", text: $displayName)
TextField("Описание", text: $description)
}
await MainActor.run {
isPreparingDownload = false
Button(action: {
// Действие для сохранения профиля
print("DisplayName: \(displayName)")
print("Description: \(description)")
}) {
Text("Применить")
}
}
}
private func handleAvatarDeletion() {
alertMessage = NSLocalizedString("Удаление аватара пока недоступно.", comment: "Avatar delete placeholder")
showAlert = true
}
private func resolveImage(for state: AvatarViewerState) async throws -> UIImage {
switch state.source {
case .local(let image):
return image
case .remote(let url, let fileId, let userId):
if let cached = AvatarCacheService.shared.getImage(forKey: fileId, userId: userId) {
return cached
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw AvatarViewerError.imageDecodingFailed
}
AvatarCacheService.shared.saveImage(image, forKey: fileId, userId: userId)
return image
}
.navigationTitle("Редактировать профиль")
}
}
private func initials(from text: String) -> String? {
let components = text
.split { $0.isWhitespace }
.filter { !$0.isEmpty }
let letters = components.prefix(2).compactMap { $0.first }
guard !letters.isEmpty else { return nil }
return letters.map { String($0).uppercased() }.joined()
}
struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
var allowsEditing: Bool = false
@Environment(\.presentationMode) private var presentationMode
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.allowsEditing = allowsEditing
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let editedImage = info[.editedImage] as? UIImage {
parent.image = editedImage
} else if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}
struct AvatarViewerState: Identifiable {
enum Source {
case local(UIImage)
case remote(url: URL, fileId: String, userId: String)
}
let id = UUID()
let source: Source
let intrinsicSize: CGSize?
}
enum AvatarViewerError: LocalizedError {
case imageDecodingFailed
var errorDescription: String? {
switch self {
case .imageDecodingFailed:
return NSLocalizedString("Не удалось подготовить изображение.", comment: "Avatar decoding error")
}
}
}
struct AvatarViewerView: View {
let state: AvatarViewerState
let onClose: () -> Void
let onDownload: () -> Void
let onDelete: () -> Void
@State private var scale: CGFloat = 1.0
@State private var baseScale: CGFloat = 1.0
@State private var panOffset: CGSize = .zero
@State private var storedPanOffset: CGSize = .zero
@State private var dismissOffset: CGSize = .zero
@State private var dragMode: DragMode?
@State private var containerSize: CGSize = .zero
@State private var loadedImageSize: CGSize?
private enum DragMode {
case vertical
case horizontal
}
private var currentOffset: CGSize {
scale > 1.05 ? panOffset : dismissOffset
}
private var dragProgress: CGFloat {
guard scale <= 1.05 else { return 0 }
let progress = min(1, abs(dismissOffset.height) / 220)
return progress
}
private var backgroundOpacity: Double {
Double(1 - dragProgress * 0.6)
}
private var overlayOpacity: Double {
Double(1 - dragProgress * 0.8)
}
private var effectiveImageSize: CGSize? {
loadedImageSize ?? state.intrinsicSize
}
var body: some View {
ZStack {
Color.black.opacity(backgroundOpacity).ignoresSafeArea()
zoomableContent
topOverlay
.opacity(overlayOpacity)
}
}
private var topOverlay: some View {
VStack(spacing: 8) {
HStack {
Button(action: onClose) {
Image(systemName: "xmark")
.imageScale(.large)
}
.tint(.white)
Spacer()
Text("1 из 1")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white.opacity(0.8))
Spacer()
Menu {
Button(action: onDownload) {
Label(NSLocalizedString("Скачать", comment: "Avatar download"), systemImage: "square.and.arrow.down")
}
Button(role: .destructive, action: onDelete) {
Label(NSLocalizedString("Удалить фото", comment: "Avatar delete"), systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.imageScale(.large)
}
.tint(.white)
}
.padding(.horizontal)
.padding(.top, 24)
Spacer()
}
}
@ViewBuilder
private var zoomableContent: some View {
GeometryReader { proxy in
let size = proxy.size
Color.clear
.onAppear { containerSize = size }
.onChange(of: size) { newValue in
containerSize = newValue
}
.overlay {
content(for: size)
}
}
}
@ViewBuilder
private func content(for size: CGSize) -> some View {
switch state.source {
case .local(let image):
zoomableImage(Image(uiImage: image))
.onAppear {
loadedImageSize = image.size
}
case .remote(let url, let fileId, let userId):
RemoteZoomableImage(url: url, fileId: fileId, userId: userId) { uiImage in
loadedImageSize = uiImage.size
}
.offset(currentOffset)
.scaleEffect(scale, anchor: .center)
.gesture(dragGesture)
.simultaneousGesture(magnificationGesture)
}
}
private func zoomableImage(_ image: Image) -> some View {
image
.resizable()
.scaledToFit()
.offset(currentOffset)
.scaleEffect(scale, anchor: .center)
.gesture(dragGesture)
.simultaneousGesture(magnificationGesture)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
if scale > 1.05 {
dismissOffset = .zero
let adjustedTranslation = CGSize(
width: value.translation.width / scale,
height: value.translation.height / scale
)
panOffset = CGSize(
width: storedPanOffset.width + adjustedTranslation.width,
height: storedPanOffset.height + adjustedTranslation.height
)
panOffset = clampedOffset(panOffset)
} else {
if dragMode == nil {
if abs(value.translation.height) > abs(value.translation.width) {
dragMode = .vertical
} else {
dragMode = .horizontal
}
}
switch dragMode {
case .horizontal:
let limitedWidth = min(max(value.translation.width, -80), 80)
dismissOffset = CGSize(width: limitedWidth, height: 0)
case .vertical, .none:
dismissOffset = CGSize(width: 0, height: value.translation.height)
}
}
}
.onEnded { value in
if scale > 1.05 {
let adjustedTranslation = CGSize(
width: value.translation.width / scale,
height: value.translation.height / scale
)
var newOffset = CGSize(
width: storedPanOffset.width + adjustedTranslation.width,
height: storedPanOffset.height + adjustedTranslation.height
)
newOffset = clampedOffset(newOffset)
storedPanOffset = newOffset
} else {
if abs(value.translation.height) > 120 {
onClose()
} else {
withAnimation(.spring()) {
dismissOffset = .zero
}
}
dragMode = nil
}
}
}
private var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
let newScale = baseScale * value
scale = min(max(newScale, 1), 4)
}
.onEnded { _ in
baseScale = scale
if baseScale <= 1.02 {
baseScale = 1
withAnimation(.spring()) {
scale = 1
storedPanOffset = .zero
panOffset = .zero
dismissOffset = .zero
}
}
}
}
private func clampedOffset(_ offset: CGSize) -> CGSize {
guard scale > 1.01,
containerSize != .zero else { return offset }
let fittedSize = fittedContentSize(in: containerSize)
let scaledWidth = fittedSize.width * scale
let scaledHeight = fittedSize.height * scale
let maxX = max(0, (scaledWidth - containerSize.width) / 2)
let maxY = max(0, (scaledHeight - containerSize.height) / 2)
let clampedX = max(-maxX, min(offset.width, maxX))
let clampedY = max(-maxY, min(offset.height, maxY))
return CGSize(width: clampedX, height: clampedY)
}
private func fittedContentSize(in container: CGSize) -> CGSize {
guard let imageSize = effectiveImageSize,
imageSize.width > 0,
imageSize.height > 0 else {
return container
}
let widthRatio = container.width / imageSize.width
let heightRatio = container.height / imageSize.height
let ratio = min(widthRatio, heightRatio)
return CGSize(width: imageSize.width * ratio, height: imageSize.height * ratio)
}
}
private struct RemoteZoomableImage: View {
@StateObject private var loader: ImageLoader
let onImageLoaded: (UIImage) -> Void
init(url: URL, fileId: String, userId: String, onImageLoaded: @escaping (UIImage) -> Void) {
_loader = StateObject(wrappedValue: ImageLoader(url: url, fileId: fileId, userId: userId))
self.onImageLoaded = onImageLoaded
}
var body: some View {
Group {
if let image = loader.image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.onAppear {
onImageLoaded(image)
}
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear(perform: loader.load)
}
}
struct ActivityView: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

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

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

@ -3,18 +3,8 @@ import SwiftUI
struct SettingsView: View {
@ObservedObject var viewModel: LoginViewModel
@EnvironmentObject private var themeManager: ThemeManager
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
@State private var isThemeExpanded = false
@State private var isSecurityActive = false
@State private var messengerProfile: ProfileDataPayload?
@State private var isMessengerProfileLoading = false
@State private var messengerProfileError: String?
private let themeOptions = ThemeOption.ordered
private let profileService = ProfileService()
private let messengerAvatarSize: CGFloat = 96
private let bannerRowInsets = EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)
private let aboutRowInsets = EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)
private let compactSectionSpacing: CGFloat = 6
private var selectedThemeOption: ThemeOption {
ThemeOption.option(for: themeManager.theme)
@ -22,48 +12,27 @@ struct SettingsView: View {
var body: some View {
Form {
if shouldShowLegacySupportBanner {
LegacySupportBanner()
.listRowInsets(bannerRowInsets)
.listRowBackground(Color.clear)
}
if isMessengerModeEnabled {
messengerProfileHeaderSection
aboutSection
}
// MARK: - Профиль
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
// NavigationLink(destination: EditProfileView()) {
// 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")
}
}
@ -87,11 +56,11 @@ struct SettingsView: View {
Label("Темы", systemImage: "moon.fill")
}
NavigationLink(destination: DataSettingsView(currentUserId: viewModel.userId)) {
NavigationLink(destination: Text("Заглушка: Хранилище данных")) {
Label("Данные", systemImage: "externaldrive")
}
NavigationLink(destination: OtherSettingsView()) {
NavigationLink(destination: Text("Заглушка: Другие настройки")) {
Label("Другое", systemImage: "ellipsis.circle")
}
}
@ -138,14 +107,8 @@ struct SettingsView: View {
.padding(.vertical, 4)
}
// MARK: - Выход
Section (
header: Spacer()
.frame(height: 32)
.listRowInsets(EdgeInsets())
){
Section {
Button(action: {
viewModel.logoutCurrentUser()
}) {
@ -158,176 +121,13 @@ struct SettingsView: View {
}
}
.navigationTitle("Настройки")
.onAppear {
loadMessengerProfileIfNeeded()
}
.onChange(of: isMessengerModeEnabled) { newValue in
if newValue {
loadMessengerProfileIfNeeded(force: true)
}
}
.applyFormSectionSpacing(compactSectionSpacing)
}
@ViewBuilder
private var messengerProfileHeaderSection: some View {
if messengerProfile != nil || isMessengerProfileLoading || messengerProfileError != nil {
Section {
if let profile = messengerProfile {
NavigationLink(destination: EditProfileView()) {
ProfileHeaderCardView(
avatar: messengerAvatar(for: profile),
displayName: messengerDisplayName(for: profile),
presenceStatus: nil,
statusTags: messengerStatusTags(for: profile),
isOfficial: profile.isVerified
)
.contentShape(Rectangle()) // чтобы тап работал по всей площади
}
.buttonStyle(.plain) // чтобы не было system highlight
.listRowInsets(bannerRowInsets)
.listRowBackground(Color.clear)
} else if isMessengerProfileLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding(.vertical, 12)
.listRowInsets(bannerRowInsets)
.listRowBackground(Color.clear)
} else if let error = messengerProfileError {
VStack(spacing: 8) {
Text(error)
.font(.footnote)
.multilineTextAlignment(.center)
Button(NSLocalizedString("Повторить", comment: "Messenger profile header retry")) {
loadMessengerProfileIfNeeded(force: true)
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.listRowInsets(bannerRowInsets)
.listRowBackground(Color.clear)
}
}
}
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func loadMessengerProfileIfNeeded(force: Bool = false) {
guard isMessengerModeEnabled else { return }
if isMessengerProfileLoading { return }
if !force, messengerProfile != nil { return }
isMessengerProfileLoading = true
messengerProfileError = nil
Task {
do {
let profile = try await profileService.fetchMyProfile()
await MainActor.run {
messengerProfile = profile
isMessengerProfileLoading = false
}
} catch {
await MainActor.run {
messengerProfileError = error.localizedDescription
isMessengerProfileLoading = false
}
}
}
}
@ViewBuilder
private func messengerAvatar(for profile: ProfileDataPayload) -> some View {
if let fileId = profile.avatars?.current?.fileId,
let url = URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(profile.userId.uuidString)?file_id=\(fileId)") {
CachedAvatarView(url: url, fileId: fileId, userId: profile.userId.uuidString) {
messengerAvatarPlaceholder(for: profile)
}
.aspectRatio(contentMode: .fill)
.frame(width: messengerAvatarSize, height: messengerAvatarSize)
.clipShape(Circle())
} else {
messengerAvatarPlaceholder(for: profile)
}
}
private func messengerAvatarPlaceholder(for profile: ProfileDataPayload) -> some View {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: messengerAvatarSize, height: messengerAvatarSize)
.overlay(
Text(messengerInitials(for: profile))
.font(.system(size: messengerAvatarSize * 0.45, weight: .semibold))
.foregroundColor(Color.accentColor)
)
}
private func messengerInitials(for profile: ProfileDataPayload) -> String {
if let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
let components = name.split(separator: " ")
let initials = components.prefix(2).compactMap { $0.first }
if !initials.isEmpty {
return initials.map { String($0) }.joined().uppercased()
}
}
return String(profile.login.prefix(1)).uppercased()
}
private func messengerDisplayName(for profile: ProfileDataPayload) -> String {
if let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
return name
}
return "@\(profile.login)"
}
private func messengerStatusTags(for profile: ProfileDataPayload) -> [ProfileHeaderCardView.StatusTag] {
var tags: [ProfileHeaderCardView.StatusTag] = []
// tags.append(
// ProfileHeaderCardView.StatusTag(
// icon: "at",
// text: "@\(profile.login)",
// background: Color.white.opacity(0.18),
// tint: .white
// )
// )
// if let createdAt = profile.createdAt {
// let formatted = SettingsView.membershipFormatter.string(from: createdAt)
// tags.append(
// ProfileHeaderCardView.StatusTag(
// icon: "calendar",
// text: String(
// format: NSLocalizedString("С %@ на Yobble", comment: "Messenger profile membership"),
// formatted
// ),
// background: Color.white.opacity(0.12),
// tint: .white
// )
// )
// }
if profile.isVerified {
tags.append(
ProfileHeaderCardView.StatusTag(
icon: "checkmark.seal.fill",
text: NSLocalizedString("Подтверждённый профиль", comment: "Message profile verified tag"),
background: Color.white.opacity(0.18),
tint: .white
)
)
}
return tags
}
private func themeRow(for option: ThemeOption) -> some View {
let isSelected = option == selectedThemeOption
@ -360,176 +160,4 @@ struct SettingsView: View {
themeManager.setTheme(mappedTheme)
}
private var shouldShowLegacySupportBanner: Bool {
#if os(iOS)
let requiredVersion = OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
#else
return false
#endif
}
private static let membershipFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()
private static let ratingFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 1
formatter.maximumFractionDigits = 1
return formatter
}()
@ViewBuilder
private var aboutSection: some View {
if let _ = messengerProfile {
Section(
header: Spacer()
.frame(height: 16)
.listRowInsets(EdgeInsets())
){
card {
VStack(spacing: 0) {
infoRow(
title: NSLocalizedString("Юзернейм", comment: ""),
value: loginDisplay ?? NSLocalizedString("Неизвестный пользователь", comment: "Messenger settings unknown user")
)
if let membership = membershipDescription {
rowDivider
infoRow(
title: NSLocalizedString("Дата регистрации в Yobble", comment: ""),
value: membership
)
}
rowDivider
infoRow(
title: NSLocalizedString("Ваш рейтинг", comment: "Messenger settings rating title"),
value: ratingDisplayValue
)
}
}
.listRowInsets(aboutRowInsets)
.listRowBackground(Color.clear)
}
}
}
private func infoRow(icon: String? = nil, title: String, value: String) -> some View {
HStack(alignment: .top, spacing: 12) {
if let icon {
iconBackground(color: .accentColor.opacity(0.18)) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.accentColor)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.body)
}
Spacer()
}
.padding(.vertical, 4)
}
private func iconBackground<Content: View>(color: Color, @ViewBuilder content: () -> Content) -> some View {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(color)
.frame(width: 44, height: 44)
.overlay(content())
}
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 16) {
content()
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(Color(UIColor.secondarySystemGroupedBackground))
)
}
private var rowDivider: some View {
Divider()
.padding(.vertical, 12)
}
private var loginDisplay: String? {
let login = messengerProfile?.login.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !login.isEmpty else { return nil }
return "@\(login)"
}
private var membershipDescription: String? {
guard let createdAt = messengerProfile?.createdAt else { return nil }
let formatted = SettingsView.membershipFormatter.string(from: createdAt)
return formatted
}
private var ratingDisplayValue: String {
guard let rating = messengerProfile?.rating else {
return NSLocalizedString("Недоступно", comment: "Messenger settings rating unavailable")
}
let clamped = max(0, min(5, rating))
let formatted = SettingsView.ratingFormatter.string(from: NSNumber(value: clamped))
?? String(format: "%.1f", clamped)
return String(
format: NSLocalizedString("%@ из 5", comment: "Message profile rating format"),
formatted
)
}
}
private extension View {
@ViewBuilder
func applyFormSectionSpacing(_ spacing: CGFloat) -> some View {
if #available(iOS 17.0, *) {
self.listSectionSpacing(.custom(spacing))
} else {
self
}
}
}
private struct LegacySupportBanner: View {
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.yellow)
VStack(alignment: .leading, spacing: 4) {
Text("Экспериментальная поддержка iOS 15/16")
.font(.headline)
Text("Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer(minLength: 0)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.yellow.opacity(0.15))
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.yellow.opacity(0.4), lineWidth: 1)
)
}
}

View File

@ -1,7 +1,7 @@
import SwiftUI
struct AppConfig {
static var DEBUG: Bool = false
static var DEBUG: Bool = true
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
static let PROTOCOL = "https"
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
@ -12,10 +12,15 @@ struct AppConfig {
static let APP_BUILD = "appstore" // appstore / freestore
static let APP_VERSION = "0.1"
static let DISABLE_DB = false
/// Controls whether incoming chat opens as a modal sheet (`true`) or navigates to Chats tab (`false`).
static let PRESENT_CHAT_AS_SHEET = false
static let DISABLE_DB = false
/// 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,91 +10,74 @@ 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()
@StateObject private var updateChecker = AppUpdateChecker()
private let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
Group {
if let notice = updateChecker.needUpdateNotice {
NeedUpdateView(
title: notice.title,
message: notice.message,
onUpdate: { updateChecker.openAppStore(link: notice.appStoreURL) }
)
} else {
ZStack(alignment: .top) {
Group {
if viewModel.isInitialLoading {
SplashScreenView()
} else if viewModel.isLoggedIn {
MainView(viewModel: viewModel)
} else {
LoginView(viewModel: viewModel)
}
}
if let banner = messageCenter.banner {
NewMessageBannerView(
banner: banner,
onOpen: { messageCenter.openCurrentChat() },
onDismiss: { messageCenter.dismissBanner() }
)
.padding(.horizontal, 16)
.padding(.top, 12)
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(1)
}
}
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
.sheet(item: AppConfig.PRESENT_CHAT_AS_SHEET ? $messageCenter.presentedChat : .constant(nil)) { chatItem in
NavigationView {
PrivateChatView(
chat: chatItem,
currentUserId: messageCenter.currentUserId
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Закрыть", comment: "")) {
messageCenter.presentedChat = nil
}
}
}
}
}
.alert(item: Binding(
get: { updateChecker.softUpdateNotice },
set: { newValue in
if newValue == nil {
updateChecker.dismissSoftUpdateIfNeeded()
}
}
)) { notice in
Alert(
title: Text(notice.title),
message: Text(notice.message),
primaryButton: .default(Text(NSLocalizedString("Обновить", comment: ""))) {
updateChecker.openAppStore(link: notice.appStoreURL)
},
secondaryButton: .cancel(Text(NSLocalizedString("Позже", comment: ""))) {
updateChecker.dismissSoftUpdateIfNeeded(skipBuild: notice.skipBuild)
}
)
ZStack(alignment: .top) {
Group {
if viewModel.isLoading {
SplashScreenView()
} else if viewModel.isLoggedIn {
MainView(viewModel: viewModel)
} else {
LoginView(viewModel: viewModel)
}
}
if let banner = messageCenter.banner {
NewMessageBannerView(
banner: banner,
onOpen: { messageCenter.openCurrentChat() },
onDismiss: { messageCenter.dismissBanner() }
)
.padding(.horizontal, 16)
.padding(.top, 12)
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(1)
}
}
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
.sheet(item: AppConfig.PRESENT_CHAT_AS_SHEET ? $messageCenter.presentedChat : .constant(nil)) { chatItem in
NavigationView {
PrivateChatView(
chat: chatItem,
currentUserId: messageCenter.currentUserId
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Закрыть", comment: "")) {
messageCenter.presentedChat = nil
}
}
}
}
.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)
.preferredColorScheme(themeManager.theme.colorScheme)
.environment(\.managedObjectContext, persistenceController.viewContext)
.onAppear {
updateChecker.checkForUpdatesIfNeeded()
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
}
.onChange(of: viewModel.userId) { newValue in