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 = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1A38EB882EDE871B00DD8FC4 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */; };
1A38EB8C2EDE8CC700DD8FC4 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */; };
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = 1A85C6CB2EA6FD73009FA847 /* SocketIO */; }; 1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = 1A85C6CB2EA6FD73009FA847 /* SocketIO */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -35,22 +33,9 @@
1A6D61E42E7CD04100B9F736 /* yobbleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = yobbleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1A6D61E42E7CD04100B9F736 /* yobbleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = yobbleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
1A38EB832EDE80E700DD8FC4 /* Exceptions for "yobble" folder in "yobble" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 1A6D61CB2E7CD03E00B9F736 /* yobble */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
1A6D61CE2E7CD03E00B9F736 /* yobble */ = { 1A6D61CE2E7CD03E00B9F736 /* yobble */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
1A38EB832EDE80E700DD8FC4 /* Exceptions for "yobble" folder in "yobble" target */,
);
path = yobble; path = yobble;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -71,8 +56,6 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
1A38EB882EDE871B00DD8FC4 /* FirebaseMessaging in Frameworks */,
1A38EB8C2EDE8CC700DD8FC4 /* FirebaseCore in Frameworks */,
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */, 1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -128,8 +111,6 @@
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */,
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
1A6D61CE2E7CD03E00B9F736 /* yobble */, 1A6D61CE2E7CD03E00B9F736 /* yobble */,
@ -137,8 +118,6 @@
name = yobble; name = yobble;
packageProductDependencies = ( packageProductDependencies = (
1A85C6CB2EA6FD73009FA847 /* SocketIO */, 1A85C6CB2EA6FD73009FA847 /* SocketIO */,
1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */,
1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */,
); );
productName = yobble; productName = yobble;
productReference = 1A6D61CC2E7CD03E00B9F736 /* yobble.app */; productReference = 1A6D61CC2E7CD03E00B9F736 /* yobble.app */;
@ -225,7 +204,6 @@
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */, 1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */,
1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 1A6D61CD2E7CD03E00B9F736 /* Products */; productRefGroup = 1A6D61CD2E7CD03E00B9F736 /* Products */;
@ -288,14 +266,6 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 1A38EB8F2EDE8ECA00DD8FC4 /* FirebaseMessaging */;
};
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 1A38EB912EDE8ED500DD8FC4 /* FirebaseCore */;
};
1A6D61DC2E7CD04000B9F736 /* PBXTargetDependency */ = { 1A6D61DC2E7CD04000B9F736 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = 1A6D61CB2E7CD03E00B9F736 /* yobble */; target = 1A6D61CB2E7CD03E00B9F736 /* yobble */;
@ -434,12 +404,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements; CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = V22H44W47J; DEVELOPMENT_TEAM = V22H44W47J;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = yobble/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@ -451,7 +420,7 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15; IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 11; MACOSX_DEPLOYMENT_TARGET = 11;
@ -475,12 +444,11 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements; CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = V22H44W47J; DEVELOPMENT_TEAM = V22H44W47J;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = yobble/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@ -492,7 +460,7 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15; IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 11; MACOSX_DEPLOYMENT_TARGET = 11;
@ -641,14 +609,6 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 12.6.0;
};
};
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */ = { 1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/socketio/socket.io-client-swift"; repositoryURL = "https://github.com/socketio/socket.io-client-swift";
@ -660,26 +620,6 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */ = {
isa = XCSwiftPackageProductDependency;
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseMessaging;
};
1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */ = {
isa = XCSwiftPackageProductDependency;
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCore;
};
1A38EB8F2EDE8ECA00DD8FC4 /* FirebaseMessaging */ = {
isa = XCSwiftPackageProductDependency;
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseMessaging;
};
1A38EB912EDE8ED500DD8FC4 /* FirebaseCore */ = {
isa = XCSwiftPackageProductDependency;
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCore;
};
1A85C6CB2EA6FD73009FA847 /* SocketIO */ = { 1A85C6CB2EA6FD73009FA847 /* SocketIO */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */; package = 1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */;

View File

@ -1,123 +1,6 @@
{ {
"originHash" : "600d4311db652d123053aed731b25dc6c4fe63e106bb9043d105bb4fedf8b79b", "originHash" : "c9fb241c5f575df8f20b39649006995779013948e60c51c3f85b729f83b054e7",
"pins" : [ "pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "087bb95235f676c1a37e928769a5b6645dcbd325",
"version" : "12.6.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c",
"version" : "3.2.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1",
"version" : "12.5.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7",
"version" : "5.0.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{ {
"identity" : "socket.io-client-swift", "identity" : "socket.io-client-swift",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@ -135,15 +18,6 @@
"revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a", "revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a",
"version" : "4.0.8" "version" : "4.0.8"
} }
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
} }
], ],
"version" : 3 "version" : 3

View File

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

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

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 { struct MessagePayload: Decodable {
let message: String let message: String
} }
struct BlockedUserInfo: Decodable {
let userId: UUID
let login: String?
let fullName: String?
let customName: String?
let createdAt: Date
}
struct BlockedUsersPayload: Decodable {
let hasMore: Bool
let items: [BlockedUserInfo]
}

View File

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

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

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

View File

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

View File

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

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 Foundation
import Combine import Combine
struct ChatNavigationTarget: Identifiable {
let id = UUID()
let chat: PrivateChatListItem
}
final class IncomingMessageCenter: ObservableObject { final class IncomingMessageCenter: ObservableObject {
@Published private(set) var banner: IncomingMessageBanner? @Published private(set) var banner: IncomingMessageBanner?
@Published var presentedChat: PrivateChatListItem? @Published var presentedChat: PrivateChatListItem?
@Published var pendingNavigation: ChatNavigationTarget?
var currentUserId: String? var currentUserId: String?
var activeChatId: String? var activeChatId: String?
@ -38,13 +32,7 @@ final class IncomingMessageCenter: ObservableObject {
guard let banner else { return } guard let banner else { return }
activeChatId = banner.message.chatId activeChatId = banner.message.chatId
let chatItem = makeChatItem(from: banner.message) let chatItem = makeChatItem(from: banner.message)
if AppConfig.PRESENT_CHAT_AS_SHEET {
presentedChat = chatItem presentedChat = chatItem
pendingNavigation = nil
} else {
pendingNavigation = ChatNavigationTarget(chat: chatItem)
presentedChat = nil
}
dismissBanner() dismissBanner()
} }
@ -57,8 +45,7 @@ final class IncomingMessageCenter: ObservableObject {
return return
} }
if AppConfig.PRESENT_CHAT_AS_SHEET, if let presentedChat,
let presentedChat,
presentedChat.chatId == message.chatId { presentedChat.chatId == message.chatId {
return return
} }

View File

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

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

View File

@ -7,69 +7,27 @@
import Foundation import Foundation
import Combine import Combine
import SwiftUI
class LoginViewModel: ObservableObject { class LoginViewModel: ObservableObject {
// @AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
@Published var username: String = "" @Published var username: String = ""
@Published var userId: String = "" @Published var userId: String = ""
@Published var password: String = "" @Published var password: String = ""
@Published var isLoading: Bool = false @Published var isLoading: Bool = true // сразу true, чтобы показать спиннер при автологине
@Published var isInitialLoading: Bool = true // отдельный флаг для сплэша до завершения автологина
@Published var showError: Bool = false @Published var showError: Bool = false
@Published var errorMessage: String = "" @Published var errorMessage: String = ""
@Published var isLoggedIn: Bool = false @Published var isLoggedIn: Bool = false
@Published var socketState: SocketService.ConnectionState @Published var socketState: SocketService.ConnectionState
@Published var chatLoadingState: ChatLoadingState = .idle @Published var chatLoadingState: ChatLoadingState = .idle
@Published var hasAcceptedTerms: Bool = false
@Published var isLoadingTerms: Bool = false
@Published var termsContent: String = ""
@Published var termsErrorMessage: String?
@Published var onboardingDestination: OnboardingDestination?
@Published var loginFlowStep: LoginFlowStep = .passwordlessRequest
@Published var passwordlessLogin: String = "" {
didSet {
if passwordlessLogin.count > 32 {
passwordlessLogin = String(passwordlessLogin.prefix(32))
}
}
}
@Published var verificationCode: String = "" {
didSet {
let filtered = verificationCode
.filter { $0.isNumber }
.prefix(Constants.verificationCodeLength)
if filtered != verificationCode {
verificationCode = String(filtered)
}
}
}
@Published var isSendingCode: Bool = false
@Published var isVerifyingCode: Bool = false
@Published var resendSecondsRemaining: Int = 0
private let authService = AuthService() private let authService = AuthService()
private let socketService = SocketService.shared private let socketService = SocketService.shared
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var resendTimer: Timer?
enum LoginFlowStep: Equatable {
case passwordlessRequest
case passwordlessVerify
case password
case registration
}
enum ChatLoadingState: Equatable { enum ChatLoadingState: Equatable {
case idle case idle
case loading case loading
} }
enum OnboardingDestination: Equatable {
case afterRegister
}
private enum DefaultsKeys { private enum DefaultsKeys {
static let currentUser = "currentUser" static let currentUser = "currentUser"
static let userId = "userId" static let userId = "userId"
@ -85,10 +43,6 @@ class LoginViewModel: ObservableObject {
autoLogin() autoLogin()
} }
deinit {
resendTimer?.invalidate()
}
private func observeSocketState() { private func observeSocketState() {
socketService.connectionStatePublisher socketService.connectionStatePublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -137,7 +91,6 @@ class LoginViewModel: ObservableObject {
self?.socketService.disconnect() self?.socketService.disconnect()
} }
self?.isLoading = false self?.isLoading = false
self?.isInitialLoading = false
} }
} }
} }
@ -146,13 +99,8 @@ class LoginViewModel: ObservableObject {
func login() { func login() {
isLoading = true isLoading = true
showError = false showError = false
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedLogin != passwordlessLogin {
passwordlessLogin = trimmedLogin
}
username = trimmedLogin
authService.login(username: trimmedLogin, password: password) { [weak self] success, error in authService.login(username: username, password: password) { [weak self] success, error in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.isLoading = false self?.isLoading = false
if success { if success {
@ -168,94 +116,6 @@ class LoginViewModel: ObservableObject {
} }
} }
func requestPasswordlessCode() {
guard LoginViewModel.isLoginValid(passwordlessLogin) else {
errorMessage = NSLocalizedString("Неверный логин", comment: "")
showError = true
return
}
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
isSendingCode = true
showError = false
authService.requestLoginCode(identifier: trimmedLogin) { [weak self] success, message in
DispatchQueue.main.async {
guard let self else { return }
self.isSendingCode = false
if success {
self.passwordlessLogin = trimmedLogin
self.verificationCode = ""
self.loginFlowStep = .passwordlessVerify
self.startResendTimer()
} else {
if self.handlePasswordlessRedirect(message: message, login: trimmedLogin) {
return
}
self.errorMessage = message ?? NSLocalizedString("Не удалось отправить код.", comment: "")
self.showError = true
}
}
}
}
func verifyPasswordlessCode() {
guard verificationCode.count == Constants.verificationCodeLength,
!passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
}
isVerifyingCode = true
showError = false
authService.loginWithCode(identifier: passwordlessLogin, code: verificationCode) { [weak self] success, message in
DispatchQueue.main.async {
guard let self else { return }
self.isVerifyingCode = false
if success {
self.resendTimer?.invalidate()
self.loadStoredUser()
self.isLoggedIn = true
self.socketService.connectForCurrentUser()
self.verificationCode = ""
} else {
self.errorMessage = message ?? NSLocalizedString("Проверьте введённый код и попробуйте снова.", comment: "")
self.showError = true
// self.verificationCode = ""
}
}
}
}
func resendPasswordlessCode() {
guard resendSecondsRemaining == 0 else { return }
requestPasswordlessCode()
}
func showPasswordLogin() {
resendTimer?.invalidate()
loginFlowStep = .password
}
func showPasswordlessRequest() {
loginFlowStep = .passwordlessRequest
}
func backToPasswordlessRequest() {
verificationCode = ""
loginFlowStep = .passwordlessRequest
}
func showRegistration() {
loginFlowStep = .registration
}
func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) { func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
authService.register(username: username, password: password, invite: invite) { [weak self] success, message in authService.register(username: username, password: password, invite: invite) { [weak self] success, message in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -263,7 +123,6 @@ class LoginViewModel: ObservableObject {
self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
self?.loadStoredUser() self?.loadStoredUser()
self?.socketService.connectForCurrentUser() self?.socketService.connectForCurrentUser()
self?.onboardingDestination = .afterRegister
} else { } else {
self?.socketService.disconnect() self?.socketService.disconnect()
} }
@ -310,121 +169,4 @@ class LoginViewModel: ObservableObject {
if AppConfig.DEBUG{ print("username: \(username) | userId: \(userId)")} if AppConfig.DEBUG{ print("username: \(username) | userId: \(userId)")}
} }
func loadTermsIfNeeded() {
guard !isLoadingTerms else { return }
if !termsContent.isEmpty {
termsErrorMessage = nil
return
}
isLoadingTerms = true
termsErrorMessage = nil
NetworkClient.shared.request(
path: "/legal/terms",
headers: ["Accept": "text/plain"],
requiresAuth: false,
callbackQueue: .main
) { [weak self] result in
guard let self else { return }
self.isLoadingTerms = false
switch result {
case .success(let response):
if let content = String(data: response.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!content.isEmpty {
self.termsContent = content
return
}
if let jsonObject = try? JSONSerialization.jsonObject(with: response.data, options: []),
let json = jsonObject as? [String: Any],
let content = (json["content"] as? String) ?? (json["text"] as? String),
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.termsContent = content
} else {
self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "")
}
case .failure:
self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "")
}
}
}
func reloadTerms() {
termsContent = ""
termsErrorMessage = nil
loadTermsIfNeeded()
}
private func startResendTimer(duration: Int = Constants.defaultResendDelay) {
resendTimer?.invalidate()
resendSecondsRemaining = duration
guard duration > 0 else { return }
resendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
guard let self else {
timer.invalidate()
return
}
if self.resendSecondsRemaining > 0 {
self.resendSecondsRemaining -= 1
} else {
timer.invalidate()
}
}
}
}
extension LoginViewModel {
var isVerificationCodeComplete: Bool {
verificationCode.count == Constants.verificationCodeLength
}
var canRequestPasswordlessCode: Bool {
LoginViewModel.isLoginValid(passwordlessLogin) && !isSendingCode
}
var canVerifyPasswordlessCode: Bool {
isVerificationCodeComplete && !isVerifyingCode
}
static func isLoginValid(_ login: String) -> Bool {
let trimmed = login.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed == login else { return false }
let pattern = "^[A-Za-z0-9_]{3,32}$"
return trimmed.range(of: pattern, options: .regularExpression) != nil
}
}
private extension LoginViewModel {
func handlePasswordlessRedirect(message: String?, login: String) -> Bool {
guard let message else { return false }
switch message {
case "otp_not_found":
username = login
passwordlessLogin = login
loginFlowStep = .password
return true
case "account_not_found":
username = login
passwordlessLogin = login
hasAcceptedTerms = false
loginFlowStep = .registration
return true
default:
return false
}
}
enum Constants {
static let verificationCodeLength = 6
static let defaultResendDelay = 60
}
} }

View File

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

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 { struct LoginView: View {
@ObservedObject var viewModel: LoginViewModel @ObservedObject var viewModel: LoginViewModel
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
@State private var isShowingMessengerPrompt: Bool = true
@State private var pendingMessengerMode: Bool = UserDefaults.standard.bool(forKey: "messengerModeEnabled")
@State private var showLegacySupportNotice = false
var body: some View {
ZStack {
content
.animation(.easeInOut(duration: 0.25), value: viewModel.loginFlowStep)
.allowsHitTesting(!isAnyBlockingOverlayPresented)
.blur(radius: isAnyBlockingOverlayPresented ? 3 : 0)
if showLegacySupportNotice {
LegacySupportNoticeView(isPresented: $showLegacySupportNotice)
.transition(.opacity)
}
if isShowingMessengerPrompt && !showLegacySupportNotice {
Color.black.opacity(0.35)
.ignoresSafeArea()
.transition(.opacity)
MessengerModePrompt(
selection: $pendingMessengerMode,
onAccept: applyMessengerModeSelection,
onSkip: dismissMessengerPrompt
)
.padding(.horizontal, 24)
.transition(.scale.combined(with: .opacity))
}
}
.onAppear {
showModePrompt()
showLegacySupportNoticeIfNeeded()
}
}
private var content: some View {
ZStack {
switch viewModel.loginFlowStep {
case .passwordlessRequest:
PasswordlessRequestView(
viewModel: viewModel,
shouldAutofocus: !isShowingMessengerPrompt,
onShowModePrompt: showModePrompt
)
.transition(unifiedTransition)
case .passwordlessVerify:
PasswordlessVerifyView(
viewModel: viewModel,
shouldAutofocus: !isShowingMessengerPrompt,
onShowModePrompt: showModePrompt
)
.transition(unifiedTransition)
case .password:
PasswordLoginView(viewModel: viewModel, onShowModePrompt: showModePrompt)
.transition(unifiedTransition)
case .registration:
RegistrationView(viewModel: viewModel, onShowModePrompt: showModePrompt)
.transition(unifiedTransition)
}
}
}
private func showModePrompt() {
pendingMessengerMode = isMessengerModeEnabled
withAnimation {
isShowingMessengerPrompt = true
}
}
private var isAnyBlockingOverlayPresented: Bool {
isShowingMessengerPrompt || showLegacySupportNotice
}
private var unifiedTransition: AnyTransition {
.opacity.combined(with: .scale(scale: 0.98, anchor: .center))
}
private func showLegacySupportNoticeIfNeeded() {
guard shouldShowLegacySupportNotice else { return }
withAnimation {
showLegacySupportNotice = true
}
}
private var shouldShowLegacySupportNotice: Bool {
#if os(iOS)
let requiredVersion = OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
#else
return false
#endif
}
private func applyMessengerModeSelection() {
isMessengerModeEnabled = pendingMessengerMode
dismissMessengerPrompt()
}
private func dismissMessengerPrompt() {
withAnimation {
isShowingMessengerPrompt = false
}
}
}
struct PasswordLoginView: View {
@ObservedObject var viewModel: LoginViewModel
let onShowModePrompt: () -> Void
@EnvironmentObject private var themeManager: ThemeManager @EnvironmentObject private var themeManager: ThemeManager
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
private let themeOptions = ThemeOption.ordered private let themeOptions = ThemeOption.ordered
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
@State private var isShowingTerms = false @State private var isShowingRegistration = false
@State private var hasResetTermsOnAppear = false
@State private var isShowingForgotPassword = false
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
private enum Field: Hashable { private enum Field: Hashable {
@ -135,67 +22,75 @@ struct PasswordLoginView: View {
} }
private var isUsernameValid: Bool { private var isUsernameValid: Bool {
LoginViewModel.isLoginValid(viewModel.passwordlessLogin) let pattern = "^[A-Za-z0-9_]{3,32}$"
return viewModel.username.range(of: pattern, options: .regularExpression) != nil
} }
private var isPasswordValid: Bool { private var isPasswordValid: Bool {
return viewModel.password.count >= 8 && viewModel.password.count <= 128 return viewModel.password.count >= 8 && viewModel.password.count <= 128
} }
private var isLoginButtonEnabled: Bool { var body: some View {
// !viewModel.isLoading && isUsernameValid && isPasswordValid && viewModel.hasAcceptedTerms
!viewModel.isLoading && isUsernameValid && isPasswordValid ZStack {
Color.clear // чтобы поймать тап
.contentShape(Rectangle())
.onTapGesture {
focusedField = nil
} }
var body: some View { VStack {
ScrollView(showsIndicators: false) { HStack {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
Button { Button(action: openLanguageSettings) {
focusedField = nil Text("🌍")
withAnimation { .padding()
viewModel.showPasswordlessRequest() }
Spacer()
Menu {
ForEach(themeOptions) { option in
Button(action: { selectTheme(option) }) {
themeMenuContent(for: option)
.opacity(option.isEnabled ? 1.0 : 0.5)
}
.disabled(!option.isEnabled)
} }
} label: { } label: {
HStack(spacing: 6) { Image(systemName: themeIconName)
Image(systemName: "arrow.left") .padding()
Text(NSLocalizedString("Назад", comment: ""))
} }
.font(.footnote) }
.foregroundColor(.blue) .onTapGesture {
focusedField = nil
} }
VStack(alignment: .leading, spacing: 8) { Spacer()
Text(NSLocalizedString("Вход по паролю", comment: ""))
.font(.largeTitle).bold()
// Text(NSLocalizedString("Если предпочитаете классический вход, используйте логин и пароль.", comment: ""))
// .foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 12) { TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
HStack(spacing: 8) { .padding()
Text("@") .background(Color(.secondarySystemBackground))
.foregroundColor(.secondary) .cornerRadius(8)
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.focused($focusedField, equals: .username) .focused($focusedField, equals: .username)
.onChange(of: viewModel.username) { newValue in
if newValue.count > 32 {
viewModel.username = String(newValue.prefix(32))
}
} }
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty { // Показываем ошибку для логина
if !isUsernameValid && !viewModel.username.isEmpty {
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин")) Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
.foregroundColor(.red) .foregroundColor(.red)
.font(.caption) .font(.caption)
} }
SecureField(NSLocalizedString("Введите пароль", comment: ""), text: $viewModel.password) // Показываем поле пароля
SecureField(NSLocalizedString("Пароль", comment: ""), text: $viewModel.password)
.padding() .padding()
.background(Color(.secondarySystemBackground)) .background(Color(.secondarySystemBackground))
.cornerRadius(12) .cornerRadius(8)
.autocapitalization(.none) .autocapitalization(.none)
.focused($focusedField, equals: .password) .focused($focusedField, equals: .password)
.onChange(of: viewModel.password) { newValue in .onChange(of: viewModel.password) { newValue in
@ -204,22 +99,16 @@ struct PasswordLoginView: View {
} }
} }
// Показываем ошибку для пароля
if !isPasswordValid && !viewModel.password.isEmpty { if !isPasswordValid && !viewModel.password.isEmpty {
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль")) Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
.foregroundColor(.red) .foregroundColor(.red)
.font(.caption) .font(.caption)
} }
}
// VStack(alignment: .leading, spacing: 4) { var isButtonEnabled: Bool {
// Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled) !viewModel.isLoading && isUsernameValid && isPasswordValid
// .toggleStyle(SwitchToggleStyle(tint: .accentColor)) }
// Text(isMessengerModeEnabled
// ? "Мессенджер-режим сейчас проработан примерно на 60%."
// : "Основной режим находится в ранней разработке (около 10%).")
// .font(.footnote)
// .foregroundColor(.secondary)
// }
Button(action: { Button(action: {
viewModel.login() viewModel.login()
@ -227,71 +116,49 @@ struct PasswordLoginView: View {
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle()) .progressViewStyle(CircularProgressViewStyle())
.frame(maxWidth: .infinity)
.padding() .padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.6))
.cornerRadius(8)
} else { } else {
Text(NSLocalizedString("Войти", comment: "")) Text(NSLocalizedString("Войти", comment: ""))
.foregroundColor(.white) .foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding() .padding()
}
}
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(12)
.disabled(!isLoginButtonEnabled)
Button(action: {
isShowingForgotPassword = true
}) {
Text(NSLocalizedString("Забыли пароль? Сбросить", comment: ""))
.foregroundColor(.blue)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(isButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(8)
} }
.padding(.top, 4) }
.disabled(!isButtonEnabled)
Spacer(minLength: 0) // Spacer()
// Кнопка регистрации
Button(action: {
isShowingRegistration = true
}) {
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
.foregroundColor(.blue)
} }
.padding(.vertical, 32) .padding(.top, 10)
.sheet(isPresented: $isShowingRegistration) {
RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration)
}
Spacer()
}
.padding()
.alert(isPresented: $viewModel.showError) {
Alert(
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
message: Text(viewModel.errorMessage),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
)
} }
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture { .onTapGesture {
focusedField = nil focusedField = nil
} }
.loginErrorAlert(viewModel: viewModel)
.onAppear {
if !hasResetTermsOnAppear {
viewModel.hasAcceptedTerms = false
hasResetTermsOnAppear = true
}
}
.fullScreenCover(isPresented: $isShowingTerms) {
TermsFullScreenView(
isPresented: $isShowingTerms,
title: NSLocalizedString("Правила сервиса", comment: ""),
content: viewModel.termsContent,
isLoading: viewModel.isLoadingTerms,
errorMessage: viewModel.termsErrorMessage,
onRetry: {
viewModel.reloadTerms()
}
)
.onAppear {
if viewModel.termsContent.isEmpty {
viewModel.loadTermsIfNeeded()
}
}
}
.sheet(isPresented: $isShowingForgotPassword) {
ForgotPasswordInfoView {
isShowingForgotPassword = false
withAnimation {
viewModel.showPasswordlessRequest()
}
} onDismiss: {
isShowingForgotPassword = false
}
} }
} }
private var themeIconName: String { private var themeIconName: String {
@ -305,11 +172,6 @@ struct PasswordLoginView: View {
} }
} }
private func hideKeyboardAndShowModePrompt() {
focusedField = nil
onShowModePrompt()
}
private func openLanguageSettings() { private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return } guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url) UIApplication.shared.open(url)
@ -343,542 +205,10 @@ struct PasswordLoginView: View {
} }
private struct PasswordlessRequestView: View {
@ObservedObject var viewModel: LoginViewModel
let shouldAutofocus: Bool
let onShowModePrompt: () -> Void
@FocusState private var isFieldFocused: Bool
private var isLoginValid: Bool {
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Yobble Passport", comment: ""))
.font(.largeTitle).bold()
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("@")
.foregroundColor(.secondary)
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
.textContentType(.username)
.keyboardType(.default)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($isFieldFocused)
.onChange(of: viewModel.passwordlessLogin) { newValue in
if newValue.count > 32 {
viewModel.passwordlessLogin = String(newValue.prefix(32))
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
Button {
withAnimation {
viewModel.requestPasswordlessCode()
}
} label: {
if viewModel.isSendingCode {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else {
Text(NSLocalizedString("Войти", comment: ""))
.bold()
.frame(maxWidth: .infinity)
.padding()
}
}
.foregroundColor(.white)
.background(viewModel.canRequestPasswordlessCode ? Color.blue : Color.gray)
.cornerRadius(12)
.disabled(!viewModel.canRequestPasswordlessCode)
Divider()
Button {
viewModel.hasAcceptedTerms = false
withAnimation {
viewModel.showRegistration()
}
} label: {
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: ""))
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
isFieldFocused = false
}
.onAppear(perform: scheduleFocusIfNeeded)
.onChange(of: shouldAutofocus) { newValue in
if newValue {
scheduleFocusIfNeeded()
} else {
isFieldFocused = false
}
}
.loginErrorAlert(viewModel: viewModel)
}
private func hideKeyboardAndShowModePrompt() {
isFieldFocused = false
onShowModePrompt()
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func scheduleFocusIfNeeded() {
guard shouldAutofocus else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if shouldAutofocus {
isFieldFocused = true
}
}
}
}
private struct LegacySupportNoticeView: View {
@Binding var isPresented: Bool
var body: some View {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture {
isPresented = false
}
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40, weight: .bold))
.foregroundColor(.yellow)
Text("Экспериментальная поддержка iOS 15")
.font(.headline)
.multilineTextAlignment(.center)
Text("Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
isPresented = false
} label: {
Text("Понятно")
.bold()
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.systemBackground))
)
.frame(maxWidth: 320)
.shadow(radius: 10)
}
}
}
private struct PasswordlessVerifyView: View {
@ObservedObject var viewModel: LoginViewModel
let shouldAutofocus: Bool
let onShowModePrompt: () -> Void
@FocusState private var isCodeFieldFocused: Bool
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
Button {
// focusedField = nil
withAnimation {
viewModel.showPasswordlessRequest()
}
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.left")
Text(NSLocalizedString("Назад", comment: ""))
}
.font(.footnote)
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Вход в аккаунт", comment: ""))
.font(.largeTitle).bold()
Text(String(format: NSLocalizedString("@%@", comment: ""), viewModel.passwordlessLogin))
.foregroundColor(.secondary)
// Text(NSLocalizedString("Введите код", comment: ""))
// .font(.largeTitle).bold()
//
// Text(String(format: NSLocalizedString("Код отправлен. Аккаунт: @%@", comment: ""), viewModel.passwordlessLogin))
// .foregroundColor(.secondary)
}
OTPInputView(code: $viewModel.verificationCode, isFocused: $isCodeFieldFocused)
if viewModel.isVerifyingCode {
HStack(spacing: 8) {
ProgressView()
Text(NSLocalizedString("Проверяем код…", comment: ""))
.font(.subheadline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Не получили код?", comment: ""))
.font(.subheadline)
if viewModel.resendSecondsRemaining > 0 {
Text(String(format: NSLocalizedString("Попробовать снова можно через %d сек", comment: ""), viewModel.resendSecondsRemaining))
.foregroundColor(.secondary)
}
Button {
withAnimation {
viewModel.resendPasswordlessCode()
}
} label: {
if viewModel.isSendingCode {
ProgressView()
.padding(.vertical, 8)
} else {
Text(NSLocalizedString("Отправить код ещё раз", comment: ""))
}
}
.disabled(viewModel.resendSecondsRemaining > 0 || viewModel.isSendingCode)
}
Divider()
// Button {
// withAnimation {
// viewModel.backToPasswordlessRequest()
// }
// } label: {
// Text(NSLocalizedString("Изменить способ входа", comment: ""))
// .frame(maxWidth: .infinity)
// }
Button {
withAnimation {
viewModel.showPasswordLogin()
}
} label: {
Text(NSLocalizedString("Войти по паролю", comment: ""))
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
isCodeFieldFocused = true
}
.onAppear(perform: scheduleFocusIfNeeded)
.onChange(of: shouldAutofocus) { newValue in
if newValue {
scheduleFocusIfNeeded()
} else {
isCodeFieldFocused = false
}
}
.onAppear {
triggerAutoVerificationIfNeeded()
}
.onChange(of: viewModel.verificationCode) { _ in
triggerAutoVerificationIfNeeded()
}
.loginErrorAlert(viewModel: viewModel)
}
private func hideKeyboardAndShowModePrompt() {
isCodeFieldFocused = false
onShowModePrompt()
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func scheduleFocusIfNeeded() {
guard shouldAutofocus else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if shouldAutofocus {
isCodeFieldFocused = true
}
}
}
private func triggerAutoVerificationIfNeeded() {
guard viewModel.canVerifyPasswordlessCode else { return }
viewModel.verifyPasswordlessCode()
}
}
private struct OTPInputView: View {
@Binding var code: String
var length: Int = 6
let isFocused: FocusState<Bool>.Binding
var body: some View {
ZStack {
HStack(spacing: 12) {
ForEach(0..<length, id: \.self) { index in
Text(symbol(at: index))
.font(.title2.monospacedDigit())
.frame(width: 48, height: 56)
.background(
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor(for: index), lineWidth: 1.5)
)
}
}
TextField("", text: textBinding)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.focused(isFocused)
.frame(width: 0, height: 0)
.opacity(0.01)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture {
isFocused.wrappedValue = true
}
}
private var textBinding: Binding<String> {
Binding(
get: { code },
set: { newValue in
let filtered = newValue.filter { $0.isNumber }
let trimmed = String(filtered.prefix(length))
// избегаем nested updates
if code != trimmed {
// отключаем анимации и делаем обновление вне view update фазы
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
code = trimmed
}
}
}
)
}
private func symbol(at index: Int) -> String {
guard index < code.count else { return "" }
let idx = code.index(code.startIndex, offsetBy: index)
return String(code[idx])
}
private func borderColor(for index: Int) -> Color {
if index == code.count && code.count < length {
return .blue
}
return .gray.opacity(0.6)
}
}
private struct MessengerModePrompt: View {
@Binding var selection: Bool
let onAccept: () -> Void
let onSkip: () -> Void
var body: some View {
VStack(spacing: 20) {
Text(NSLocalizedString("Какой режим попробовать?", comment: ""))
.font(.title3.bold())
.multilineTextAlignment(.center)
Text(NSLocalizedString("По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент.", comment: ""))
.font(.callout)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 12) {
optionButton(
title: NSLocalizedString("Соцсеть (готово 10%)", comment: ""),
subtitle: NSLocalizedString("Лента, истории, подписки", comment: ""),
isMessenger: false
)
optionButton(
title: NSLocalizedString("Только чаты (готово 60%)", comment: ""),
subtitle: NSLocalizedString("Минимум отвлечений, чистый мессенджер", comment: ""),
isMessenger: true
)
}
HStack(spacing: 12) {
// Button(action: onSkip) {
// Text(NSLocalizedString("Позже", comment: ""))
// .font(.callout)
// .frame(maxWidth: .infinity)
// .padding()
// .background(
// RoundedRectangle(cornerRadius: 14, style: .continuous)
// .stroke(Color.secondary.opacity(0.3))
// )
// }
Button(action: onAccept) {
Text(NSLocalizedString("Применить", comment: ""))
.font(.callout.bold())
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.accentColor)
)
}
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(.systemBackground))
)
.shadow(color: Color.black.opacity(0.2), radius: 30, x: 0, y: 12)
}
private func optionButton(title: String, subtitle: String, isMessenger: Bool) -> some View {
Button {
selection = isMessenger
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title)
.font(.headline)
Spacer()
if selection == isMessenger {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
Text(subtitle)
.font(.footnote)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(selection == isMessenger ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground))
)
}
.buttonStyle(.plain)
}
}
private extension View {
func loginErrorAlert(viewModel: LoginViewModel) -> some View {
alert(isPresented: Binding(
get: { viewModel.showError },
set: { viewModel.showError = $0 }
)) {
Alert(
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
message: Text(viewModel.errorMessage.isEmpty ? NSLocalizedString("Произошла ошибка.", comment: "") : viewModel.errorMessage),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
)
}
}
}
struct LoginView_Previews: PreviewProvider { struct LoginView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group {
preview(step: .passwordlessRequest)
preview(step: .passwordlessVerify)
preview(step: .password)
preview(step: .registration)
}
.environmentObject(ThemeManager())
}
private static func preview(step: LoginViewModel.LoginFlowStep) -> some View {
let viewModel = LoginViewModel() let viewModel = LoginViewModel()
viewModel.isLoading = false viewModel.isLoading = false // чтобы убрать спиннер
viewModel.isInitialLoading = false
viewModel.loginFlowStep = step
viewModel.passwordlessLogin = "preview@yobble.app"
viewModel.verificationCode = "123456"
return LoginView(viewModel: viewModel) return LoginView(viewModel: viewModel)
} }
} }
private struct ForgotPasswordInfoView: View {
let onUseCode: () -> Void
let onDismiss: () -> Void
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 16) {
Text(NSLocalizedString("Сброс пароля", comment: ""))
.font(.title2.bold())
Text(NSLocalizedString("Прямого сброса пароля нет: сменить его можно только из настроек, уже будучи в аккаунте. Если привязана почта или другое 2FA-устройство, воспользуйтесь входом по коду - он подтвердит вашу личность и пустит в аккаунт. После входа откройте настройки → безопасность и задайте новый пароль.", comment: ""))
.foregroundColor(.secondary)
Button(action: onUseCode) {
Text(NSLocalizedString("Войти", comment: ""))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.cornerRadius(12)
}
Button(action: onDismiss) {
Text(NSLocalizedString("Закрыть", comment: ""))
.frame(maxWidth: .infinity)
.padding()
}
Spacer()
}
.padding()
.navigationBarHidden(true)
}
}
}

View File

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

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() private let chatService = ChatService()
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2 @AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
@StateObject private var viewModel = PrivateChatsViewModel() @StateObject private var viewModel = PrivateChatsViewModel()
@State private var selectedChatId: String?
@State private var searchDragStartProgress: CGFloat = 0 @State private var searchDragStartProgress: CGFloat = 0
@State private var isSearchGestureActive: Bool = false @State private var isSearchGestureActive: Bool = false
@State private var globalSearchResults: [UserSearchResult] = [] @State private var globalSearchResults: [UserSearchResult] = []
@ -31,7 +32,6 @@ struct ChatsTab: View {
@State private var isPendingChatActive: Bool = false @State private var isPendingChatActive: Bool = false
private let searchRevealDistance: CGFloat = 90 private let searchRevealDistance: CGFloat = 90
private let scrollToTopAnchorId = "ChatsListTopAnchor"
private var currentUserId: String? { private var currentUserId: String? {
let userId = loginViewModel.userId let userId = loginViewModel.userId
@ -50,7 +50,6 @@ struct ChatsTab: View {
var body: some View { var body: some View {
content content
.navigationTitle(NSLocalizedString("Чаты", comment: "Chats tab title"))
.background(Color(UIColor.systemBackground)) .background(Color(UIColor.systemBackground))
.onAppear { .onAppear {
viewModel.loadInitialChats() viewModel.loadInitialChats()
@ -102,15 +101,14 @@ struct ChatsTab: View {
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
// if viewModel.isInitialLoading && viewModel.chats.isEmpty { if viewModel.isInitialLoading && viewModel.chats.isEmpty {
// loadingState loadingState
// } } else {
chatList chatList
}
} }
private var chatList: some View { private var chatList: some View {
ScrollViewReader { proxy in
ZStack { ZStack {
List { List {
// VStack(spacing: 0) { // VStack(spacing: 0) {
@ -133,7 +131,6 @@ struct ChatsTab: View {
Text(NSLocalizedString("Обновить", comment: "")) Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline) .font(.subheadline)
} }
.buttonStyle(.borderless)
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
@ -147,10 +144,8 @@ struct ChatsTab: View {
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) .listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
} else { } else {
let firstLocalChatId = localSearchResults.first?.chatId
ForEach(localSearchResults) { chat in ForEach(localSearchResults) { chat in
chatRowItem(for: chat) chatRowItem(for: chat)
.id(chat.chatId == firstLocalChatId ? scrollToTopAnchorId : chat.chatId)
} }
} }
} }
@ -162,18 +157,12 @@ struct ChatsTab: View {
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty { // if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
// errorState(message: message) // errorState(message: message)
// } else // } else
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
loadingState
}
if viewModel.chats.isEmpty { if viewModel.chats.isEmpty {
emptyState emptyState
} else { } else {
let firstChatId = viewModel.chats.first?.chatId
ForEach(viewModel.chats) { chat in ForEach(viewModel.chats) { chat in
chatRowItem(for: chat) chatRowItem(for: chat)
.id(chat.chatId == firstChatId ? scrollToTopAnchorId : chat.chatId)
} }
if viewModel.isLoadingMore { if viewModel.isLoadingMore {
@ -186,9 +175,6 @@ struct ChatsTab: View {
.modifier(ScrollDismissesKeyboardModifier()) .modifier(ScrollDismissesKeyboardModifier())
.simultaneousGesture(searchBarGesture) .simultaneousGesture(searchBarGesture)
.simultaneousGesture(tapToDismissKeyboardGesture) .simultaneousGesture(tapToDismissKeyboardGesture)
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldScrollToTop)) { _ in
scrollChatsToTop(using: proxy)
}
// .safeAreaInset(edge: .top) { // .safeAreaInset(edge: .top) {
// VStack(spacing: 0) { // VStack(spacing: 0) {
// searchBar // searchBar
@ -203,7 +189,6 @@ struct ChatsTab: View {
pendingChatNavigationLink pendingChatNavigationLink
} }
} }
}
private var pendingChatNavigationLink: some View { private var pendingChatNavigationLink: some View {
NavigationLink( NavigationLink(
@ -232,14 +217,6 @@ struct ChatsTab: View {
} }
} }
private func scrollChatsToTop(using proxy: ScrollViewProxy) {
DispatchQueue.main.async {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
proxy.scrollTo(scrollToTopAnchorId, anchor: .top)
}
}
}
private var searchBarGesture: some Gesture { private var searchBarGesture: some Gesture {
DragGesture(minimumDistance: 10, coordinateSpace: .local) DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in .onChanged { value in
@ -342,10 +319,8 @@ struct ChatsTab: View {
if globalSearchResults.isEmpty { if globalSearchResults.isEmpty {
globalSearchEmptyRow globalSearchEmptyRow
} else { } else {
let firstGlobalUserId = globalSearchResults.first?.id
ForEach(globalSearchResults) { user in ForEach(globalSearchResults) { user in
globalSearchRow(for: user) globalSearchRow(for: user)
.id(user.id == firstGlobalUserId ? AnyHashable(scrollToTopAnchorId) : AnyHashable(user.id))
} }
} }
} }
@ -365,26 +340,14 @@ struct ChatsTab: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
// private var loadingState: some View {
// VStack(spacing: 12) {
// ProgressView()
// Text(NSLocalizedString("Загружаем чаты", comment: ""))
// .font(.subheadline)
// .foregroundColor(.secondary)
// }
// .frame(maxWidth: .infinity, maxHeight: .infinity)
// }
private var loadingState: some View { private var loadingState: some View {
HStack { VStack(spacing: 12) {
Spacer()
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle()) Text(NSLocalizedString("Загружаем чаты…", comment: ""))
Spacer() .font(.subheadline)
.foregroundColor(.secondary)
} }
.padding(.vertical, 18) .frame(maxWidth: .infinity, maxHeight: .infinity)
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
.listRowSeparator(.hidden)
} }
private func errorState(message: String) -> some View { private func errorState(message: String) -> some View {
@ -408,15 +371,15 @@ struct ChatsTab: View {
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
// Image(systemName: "bubble.left") Image(systemName: "bubble.left")
// .font(.system(size: 48)) .font(.system(size: 48))
// .foregroundColor(.secondary) .foregroundColor(.secondary)
Text(NSLocalizedString("Пока что у вас нет чатов", comment: "")) Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
// Button(action: triggerChatsReload) { Button(action: triggerChatsReload) {
// Text(NSLocalizedString("Обновить", comment: "")) Text(NSLocalizedString("Обновить", comment: ""))
// } }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
.padding() .padding()
@ -446,7 +409,7 @@ struct ChatsTab: View {
@ViewBuilder @ViewBuilder
private func chatRowItem(for chat: PrivateChatListItem) -> some View { private func chatRowItem(for chat: PrivateChatListItem) -> some View {
Button { Button {
openChat(chat) selectedChatId = chat.chatId
} label: { } label: {
ChatRowView( ChatRowView(
chat: chat, chat: chat,
@ -469,7 +432,17 @@ struct ChatsTab: View {
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash") Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
} }
} }
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .background(
NavigationLink(
destination: PrivateChatView(chat: chat, currentUserId: currentUserId),
tag: chat.chatId,
selection: $selectedChatId
) {
EmptyView()
}
.hidden()
)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
// .listRowSeparator(.hidden) // .listRowSeparator(.hidden)
.onAppear { .onAppear {
guard !isSearching else { return } guard !isSearching else { return }
@ -477,12 +450,6 @@ struct ChatsTab: View {
} }
} }
private func openChat(_ chat: PrivateChatListItem) {
pendingChatItem = chat
isPendingChatActive = true
}
private var globalSearchLoadingRow: some View { private var globalSearchLoadingRow: some View {
HStack { HStack {
ProgressView() ProgressView()
@ -973,25 +940,22 @@ private struct ChatRowView: View {
} }
private var initial: String { private var initial: String {
let nameSource: String? let sourceName: String
if let customName = chat.chatData?.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nameSource = customName if let custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
} else if let fullName = chat.chatData?.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { sourceName = custom
nameSource = fullName } else if let displayName = officialDisplayName {
sourceName = displayName
} else if let full = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !full.isEmpty {
sourceName = full
} else if let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
sourceName = login
} else { } else {
nameSource = nil sourceName = NSLocalizedString("Неизвестный пользователь", comment: "")
} }
if let name = nameSource { if let character = sourceName.first(where: { !$0.isWhitespace && $0 != "@" }) {
let components = name.split(separator: " ") return String(character).uppercased()
let nameInitials = components.prefix(2).compactMap { $0.first }
if !nameInitials.isEmpty {
return nameInitials.map { String($0) }.joined().uppercased()
}
}
if let login = chat.chatData?.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return String(login.prefix(1)).uppercased()
} }
return "?" return "?"
@ -1021,30 +985,27 @@ private struct ChatRowView: View {
return message.isViewed == true ? Color.accentColor : Color.secondary return message.isViewed == true ? Color.accentColor : Color.secondary
} }
private var avatarUrl: URL? {
guard let chatData = chat.chatData,
let fileId = chatData.avatars?.current?.fileId else {
return nil
}
let userId = chatData.userId
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)")
}
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
if let url = avatarUrl, let fileId = chat.chatData?.avatars?.current?.fileId, let loggedInUserId = currentUserId { Circle()
CachedAvatarView(url: url, fileId: fileId, userId: loggedInUserId) { .fill(avatarBackgroundColor)
placeholderAvatar
}
.aspectRatio(contentMode: .fill)
.frame(width: avatarSize, height: avatarSize) .frame(width: avatarSize, height: avatarSize)
.clipShape(Circle()) .overlay(
Group {
if isDeletedUser {
Image(systemName: deletedUserSymbolName)
.symbolRenderingMode(.hierarchical)
.font(.system(size: avatarSize * 0.45, weight: .semibold))
.foregroundColor(avatarTextColor)
} else { } else {
placeholderAvatar Text(initial)
.font(.system(size: avatarSize * 0.5, weight: .semibold))
.foregroundColor(avatarTextColor)
} }
}
)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack{
if let officialName = officialDisplayName { if let officialName = officialDisplayName {
HStack(spacing: 6) { HStack(spacing: 6) {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
@ -1066,6 +1027,14 @@ private struct ChatRowView: View {
.foregroundColor(Color.accentColor) .foregroundColor(Color.accentColor)
.font(.caption) .font(.caption)
} }
// if let login = loginDisplay {
// Text(login)
// .font(.footnote)
// .foregroundColor(.secondary)
// .lineLimit(1)
// .truncationMode(.tail)
// }
} else { } else {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
Text(title) Text(title)
@ -1083,8 +1052,18 @@ private struct ChatRowView: View {
} }
} }
if let timestamp { 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) { HStack(spacing: 4) {
if shouldShowReadStatus { if shouldShowReadStatus {
Image(systemName: readStatusIconName) Image(systemName: readStatusIconName)
@ -1098,17 +1077,7 @@ private struct ChatRowView: View {
} }
} }
}
HStack {
Text(messagePreview)
.font(.subheadline)
.foregroundColor(subtitleColor)
.lineLimit(messageLimitLine)
.truncationMode(.tail)
if chat.unreadCount > 0 { if chat.unreadCount > 0 {
Spacer()
Text("\(chat.unreadCount)") Text("\(chat.unreadCount)")
.font(.caption2.bold()) .font(.caption2.bold())
.foregroundColor(.white) .foregroundColor(.white)
@ -1120,61 +1089,9 @@ private struct ChatRowView: View {
} }
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
// 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) .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)
}
}
)
}
private static func formattedTimestamp(for date: Date) -> String { private static func formattedTimestamp(for date: Date) -> String {
let calendar = Calendar.current let calendar = Calendar.current
let locale = Locale.current let locale = Locale.current
@ -1264,5 +1181,4 @@ extension Notification.Name {
static let debugRefreshChats = Notification.Name("debugRefreshChats") static let debugRefreshChats = Notification.Name("debugRefreshChats")
static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh") static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh")
static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted") static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted")
static let chatsShouldScrollToTop = Notification.Name("chatsShouldScrollToTop")
} }

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

View File

@ -2,9 +2,7 @@ import SwiftUI
struct MainView: View { struct MainView: View {
@ObservedObject var viewModel: LoginViewModel @ObservedObject var viewModel: LoginViewModel
@EnvironmentObject private var messageCenter: IncomingMessageCenter
@State private var selectedTab: Int = 0 @State private var selectedTab: Int = 0
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
// @StateObject private var newHomeTabViewModel = NewHomeTabViewModel() // @StateObject private var newHomeTabViewModel = NewHomeTabViewModel()
// Состояния для TopBarView // Состояния для TopBarView
@ -18,21 +16,14 @@ struct MainView: View {
@State private var chatSearchRevealProgress: CGFloat = 0 @State private var chatSearchRevealProgress: CGFloat = 0
@State private var chatSearchText: String = "" @State private var chatSearchText: String = ""
@State private var isSettingsPresented = false @State private var isSettingsPresented = false
@State private var isQrPresented = false
@State private var deepLinkChatItem: PrivateChatListItem?
@State private var isDeepLinkChatActive = false
@State private var hasTriggeredSecuritySettingsOnboarding = false
@State private var isAfterRegisterPresented = false
private var tabTitle: String { private var tabTitle: String {
switch selectedTab { switch selectedTab {
case 0: return NSLocalizedString("Home", comment: "") case 0: return "Home"
case 1: return NSLocalizedString("Concept", comment: "") case 1: return "Concept"
case 2: return NSLocalizedString("Чаты", comment: "") case 2: return "Chats"
case 3: return NSLocalizedString("Profile", comment: "") case 3: return "Profile"
case 4: return NSLocalizedString("Контакты", comment: "") default: return "Home"
case 5: return NSLocalizedString("Настройки", comment: "")
default: return NSLocalizedString("Home", comment: "")
} }
} }
@ -48,33 +39,16 @@ struct MainView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
TopBarView( TopBarView(
title: tabTitle, title: tabTitle,
isMessengerModeEnabled: isMessengerModeEnabled,
selectedAccount: $selectedAccount, selectedAccount: $selectedAccount,
accounts: accounts, accounts: accounts,
viewModel: viewModel, viewModel: viewModel,
isSettingsPresented: $isSettingsPresented, isSettingsPresented: $isSettingsPresented,
isQrPresented: $isQrPresented,
isSideMenuPresented: $isSideMenuPresented, isSideMenuPresented: $isSideMenuPresented,
chatSearchRevealProgress: $chatSearchRevealProgress, chatSearchRevealProgress: $chatSearchRevealProgress,
chatSearchText: $chatSearchText chatSearchText: $chatSearchText
) )
ZStack { ZStack {
if isMessengerModeEnabled {
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() NewHomeTab()
.opacity(selectedTab == 0 ? 1 : 0) .opacity(selectedTab == 0 ? 1 : 0)
@ -92,10 +66,9 @@ struct MainView: View {
ProfileTab() ProfileTab()
.opacity(selectedTab == 3 ? 1 : 0) .opacity(selectedTab == 3 ? 1 : 0)
} }
}
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
CustomTabBar(selectedTab: $selectedTab, isMessengerModeEnabled: isMessengerModeEnabled) { CustomTabBar(selectedTab: $selectedTab) {
print("Create button tapped") print("Create button tapped")
} }
} }
@ -118,20 +91,15 @@ struct MainView: View {
.allowsHitTesting(menuOffset > 0) .allowsHitTesting(menuOffset > 0)
// Боковое меню // Боковое меню
if !isMessengerModeEnabled {
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented) SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
.frame(width: menuWidth) .frame(width: menuWidth)
.offset(x: -menuWidth + menuOffset) // Новая логика смещения .offset(x: -menuWidth + menuOffset) // Новая логика смещения
.ignoresSafeArea(edges: .vertical) .ignoresSafeArea(edges: .vertical)
} }
} }
deepLinkNavigationLink
}
.gesture( .gesture(
DragGesture() DragGesture()
.onChanged { gesture in .onChanged { gesture in
if !isMessengerModeEnabled {
if !isSideMenuPresented && gesture.startLocation.x > 60 { return } if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
let translation = gesture.translation.width let translation = gesture.translation.width
@ -145,9 +113,7 @@ struct MainView: View {
// Жестко ограничиваем итоговое смещение между 0 и шириной меню // Жестко ограничиваем итоговое смещение между 0 и шириной меню
self.menuOffset = max(0, min(menuWidth, newOffset)) self.menuOffset = max(0, min(menuWidth, newOffset))
} }
}
.onEnded { gesture in .onEnded { gesture in
if !isMessengerModeEnabled {
if !isSideMenuPresented && gesture.startLocation.x > 60 { return } if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
let threshold = menuWidth * 0.4 let threshold = menuWidth * 0.4
@ -162,7 +128,6 @@ struct MainView: View {
self.menuOffset = isSideMenuPresented ? menuWidth : 0 self.menuOffset = isSideMenuPresented ? menuWidth : 0
} }
} }
}
) )
} }
.navigationViewStyle(StackNavigationViewStyle()) .navigationViewStyle(StackNavigationViewStyle())
@ -172,100 +137,11 @@ struct MainView: View {
menuOffset = presented ? menuWidth : 0 menuOffset = presented ? menuWidth : 0
} }
} }
.onAppear {
enforceTabSelectionForMessengerMode()
handleAfterRegisterOnboardingIfNeeded()
}
.onChange(of: isMessengerModeEnabled) { _ in
enforceTabSelectionForMessengerMode()
handleAfterRegisterOnboardingIfNeeded()
}
.onChange(of: viewModel.onboardingDestination) { _ in
handleAfterRegisterOnboardingIfNeeded()
}
.onChange(of: messageCenter.pendingNavigation?.id) { _ in
guard !AppConfig.PRESENT_CHAT_AS_SHEET,
let target = messageCenter.pendingNavigation else { return }
withAnimation(.easeInOut) {
isSideMenuPresented = false
menuOffset = 0
}
if !chatSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
chatSearchText = ""
}
if chatSearchRevealProgress > 0 {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
chatSearchRevealProgress = 0
}
}
deepLinkChatItem = target.chat
isDeepLinkChatActive = true
NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil)
DispatchQueue.main.async {
messageCenter.pendingNavigation = nil
}
}
.onChange(of: selectedTab) { newValue in .onChange(of: selectedTab) { newValue in
if newValue != 3 { if newValue != 3 {
isSettingsPresented = false isSettingsPresented = false
} }
} }
.fullScreenCover(isPresented: $isAfterRegisterPresented) {
AfterRegisterView(isPresented: $isAfterRegisterPresented)
}
}
}
private extension MainView {
func enforceTabSelectionForMessengerMode() {
if isMessengerModeEnabled {
if selectedTab < 2 {
selectedTab = 2
}
} else if selectedTab > 3 {
selectedTab = 0
}
}
func handleAfterRegisterOnboardingIfNeeded() {
guard viewModel.onboardingDestination == .afterRegister else {
return
}
isAfterRegisterPresented = true
viewModel.onboardingDestination = nil
}
var deepLinkNavigationLink: some View {
NavigationLink(
destination: deepLinkChatDestination,
isActive: Binding(
get: { isDeepLinkChatActive && deepLinkChatItem != nil },
set: { newValue in
if !newValue {
isDeepLinkChatActive = false
deepLinkChatItem = nil
}
}
)
) {
EmptyView()
}
.hidden()
}
@ViewBuilder
var deepLinkChatDestination: some View {
if let chatItem = deepLinkChatItem {
PrivateChatView(
chat: chatItem,
currentUserId: messageCenter.currentUserId
)
.id(chatItem.chatId)
} else {
EmptyView()
}
} }
} }
@ -273,7 +149,6 @@ struct MainView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let mockViewModel = LoginViewModel() let mockViewModel = LoginViewModel()
MainView(viewModel: mockViewModel) MainView(viewModel: mockViewModel)
.environmentObject(IncomingMessageCenter())
.environmentObject(ThemeManager()) .environmentObject(ThemeManager())
} }
} }

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

View File

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

View File

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

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

View File

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct AppConfig { struct AppConfig {
static var DEBUG: Bool = false static var DEBUG: Bool = true
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service" //static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
static let PROTOCOL = "https" static let PROTOCOL = "https"
static let API_SERVER = "\(PROTOCOL)://api.yobble.org" static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
@ -12,10 +12,15 @@ struct AppConfig {
static let APP_BUILD = "appstore" // appstore / freestore static let APP_BUILD = "appstore" // appstore / freestore
static let APP_VERSION = "0.1" static let APP_VERSION = "0.1"
static let DISABLE_DB = false
/// Controls whether incoming chat opens as a modal sheet (`true`) or navigates to Chats tab (`false`). /// Controls whether incoming chat opens as a modal sheet (`true`) or navigates to Chats tab (`false`).
static let PRESENT_CHAT_AS_SHEET = false static let PRESENT_CHAT_AS_SHEET = false
static let DISABLE_DB = false
/// Fallback SQLCipher key used until the user sets an application password. /// Fallback SQLCipher key used until the user sets an application password.
static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me" static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
} }
struct AppInfo {
static let text_1 = "\(NSLocalizedString("profile_down_text_1", comment: "")) yobble"
static let text_2 = "\(NSLocalizedString("profile_down_text_2", comment: "")) 0.1test"
static let text_3 = "\(NSLocalizedString("profile_down_text_3", comment: ""))2025"
}

View File

@ -2,10 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>

View File

@ -10,28 +10,16 @@ import CoreData
@main @main
struct yobbleApp: App { struct yobbleApp: App {
// @UIApplicationDelegateAdaptor(PushAppDelegate.self) var appDelegate
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
@StateObject private var themeManager = ThemeManager() @StateObject private var themeManager = ThemeManager()
@StateObject private var viewModel = LoginViewModel() @StateObject private var viewModel = LoginViewModel()
@StateObject private var messageCenter = IncomingMessageCenter() @StateObject private var messageCenter = IncomingMessageCenter()
@StateObject private var updateChecker = AppUpdateChecker()
private let persistenceController = PersistenceController.shared private let persistenceController = PersistenceController.shared
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
Group {
if let notice = updateChecker.needUpdateNotice {
NeedUpdateView(
title: notice.title,
message: notice.message,
onUpdate: { updateChecker.openAppStore(link: notice.appStoreURL) }
)
} else {
ZStack(alignment: .top) { ZStack(alignment: .top) {
Group { Group {
if viewModel.isInitialLoading { if viewModel.isLoading {
SplashScreenView() SplashScreenView()
} else if viewModel.isLoggedIn { } else if viewModel.isLoggedIn {
MainView(viewModel: viewModel) MainView(viewModel: viewModel)
@ -67,34 +55,29 @@ struct yobbleApp: App {
} }
} }
} }
.environmentObject(messageCenter)
} }
.alert(item: Binding( .fullScreenCover(item: AppConfig.PRESENT_CHAT_AS_SHEET ? .constant(nil) : $messageCenter.presentedChat) { chatItem in
get: { updateChecker.softUpdateNotice }, NavigationView {
set: { newValue in PrivateChatView(
if newValue == nil { chat: chatItem,
updateChecker.dismissSoftUpdateIfNeeded() currentUserId: messageCenter.currentUserId
}
}
)) { 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)
}
) )
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Закрыть", comment: "")) {
messageCenter.presentedChat = nil
} }
} }
} }
}
.environmentObject(messageCenter)
}
.environmentObject(messageCenter) .environmentObject(messageCenter)
.environmentObject(themeManager) .environmentObject(themeManager)
.preferredColorScheme(themeManager.theme.colorScheme) .preferredColorScheme(themeManager.theme.colorScheme)
.environment(\.managedObjectContext, persistenceController.viewContext) .environment(\.managedObjectContext, persistenceController.viewContext)
.onAppear { .onAppear {
updateChecker.checkForUpdatesIfNeeded()
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
} }
.onChange(of: viewModel.userId) { newValue in .onChange(of: viewModel.userId) { newValue in