Compare commits
1 Commits
main
...
test-view-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c0c24cff2 |
@ -7,8 +7,6 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1A38EB882EDE871B00DD8FC4 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */; };
|
||||
1A38EB8C2EDE8CC700DD8FC4 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */; };
|
||||
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = 1A85C6CB2EA6FD73009FA847 /* SocketIO */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -35,22 +33,9 @@
|
||||
1A6D61E42E7CD04100B9F736 /* yobbleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = yobbleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
1A38EB832EDE80E700DD8FC4 /* Exceptions for "yobble" folder in "yobble" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 1A6D61CB2E7CD03E00B9F736 /* yobble */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
1A6D61CE2E7CD03E00B9F736 /* yobble */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
1A38EB832EDE80E700DD8FC4 /* Exceptions for "yobble" folder in "yobble" target */,
|
||||
);
|
||||
path = yobble;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -71,8 +56,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A38EB882EDE871B00DD8FC4 /* FirebaseMessaging in Frameworks */,
|
||||
1A38EB8C2EDE8CC700DD8FC4 /* FirebaseCore in Frameworks */,
|
||||
1A85C6CC2EA6FD73009FA847 /* SocketIO in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@ -128,8 +111,6 @@
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */,
|
||||
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1A6D61CE2E7CD03E00B9F736 /* yobble */,
|
||||
@ -137,8 +118,6 @@
|
||||
name = yobble;
|
||||
packageProductDependencies = (
|
||||
1A85C6CB2EA6FD73009FA847 /* SocketIO */,
|
||||
1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */,
|
||||
1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */,
|
||||
);
|
||||
productName = yobble;
|
||||
productReference = 1A6D61CC2E7CD03E00B9F736 /* yobble.app */;
|
||||
@ -225,7 +204,6 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */,
|
||||
1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 1A6D61CD2E7CD03E00B9F736 /* Products */;
|
||||
@ -288,14 +266,6 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
1A38EB902EDE8ECA00DD8FC4 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = 1A38EB8F2EDE8ECA00DD8FC4 /* FirebaseMessaging */;
|
||||
};
|
||||
1A38EB922EDE8ED500DD8FC4 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = 1A38EB912EDE8ED500DD8FC4 /* FirebaseCore */;
|
||||
};
|
||||
1A6D61DC2E7CD04000B9F736 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1A6D61CB2E7CD03E00B9F736 /* yobble */;
|
||||
@ -434,12 +404,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = V22H44W47J;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = yobble/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
@ -451,7 +420,7 @@
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 11;
|
||||
@ -475,12 +444,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = V22H44W47J;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = yobble/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
@ -492,7 +460,7 @@
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 11;
|
||||
@ -641,14 +609,6 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
|
||||
requirement = {
|
||||
kind = upToNextMinorVersion;
|
||||
minimumVersion = 12.6.0;
|
||||
};
|
||||
};
|
||||
1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/socketio/socket.io-client-swift";
|
||||
@ -660,26 +620,6 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
1A38EB872EDE871B00DD8FC4 /* FirebaseMessaging */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseMessaging;
|
||||
};
|
||||
1A38EB8B2EDE8CC700DD8FC4 /* FirebaseCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseCore;
|
||||
};
|
||||
1A38EB8F2EDE8ECA00DD8FC4 /* FirebaseMessaging */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseMessaging;
|
||||
};
|
||||
1A38EB912EDE8ED500DD8FC4 /* FirebaseCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 1A38EB862EDE871B00DD8FC4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseCore;
|
||||
};
|
||||
1A85C6CB2EA6FD73009FA847 /* SocketIO */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 1A85C6CA2EA6FC08009FA847 /* XCRemoteSwiftPackageReference "socket" */;
|
||||
|
||||
@ -1,123 +1,6 @@
|
||||
{
|
||||
"originHash" : "600d4311db652d123053aed731b25dc6c4fe63e106bb9043d105bb4fedf8b79b",
|
||||
"originHash" : "c9fb241c5f575df8f20b39649006995779013948e60c51c3f85b729f83b054e7",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "abseil-cpp-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
||||
"state" : {
|
||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
||||
"version" : "1.2024072200.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
||||
"version" : "11.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "firebase-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "087bb95235f676c1a37e928769a5b6645dcbd325",
|
||||
"version" : "12.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c",
|
||||
"version" : "3.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleappmeasurement",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1",
|
||||
"version" : "12.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googledatatransport",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||
"state" : {
|
||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
||||
"version" : "10.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
||||
"version" : "8.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "grpc-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/grpc-binary.git",
|
||||
"state" : {
|
||||
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
||||
"version" : "1.69.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7",
|
||||
"version" : "5.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "leveldb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/leveldb.git",
|
||||
"state" : {
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||
"version" : "1.22.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nanopb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/nanopb.git",
|
||||
"state" : {
|
||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
||||
"version" : "2.30910.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "promises",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||
"version" : "2.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "socket.io-client-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
@ -135,15 +18,6 @@
|
||||
"revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a",
|
||||
"version" : "4.0.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@ -3,22 +3,4 @@
|
||||
uuid = "AEE1609A-17B4-4FCC-80A6-0D556940F4D7"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
<Breakpoints>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "09699199-8124-4F89-892D-6880A0EB7C04"
|
||||
shouldBeEnabled = "No"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "yobble/Views/Contacts/ContactEditView.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "74"
|
||||
endingLineNumber = "74"
|
||||
landmarkName = "ContactEditView"
|
||||
landmarkType = "14">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
</Breakpoints>
|
||||
</Bucket>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@ import SwiftUI
|
||||
struct TopBarView: View {
|
||||
var title: String
|
||||
|
||||
let isMessengerModeEnabled: Bool
|
||||
// Состояния для ProfileTab
|
||||
@Binding var selectedAccount: String
|
||||
// @Binding var sheetType: ProfileTab.SheetType?
|
||||
@ -11,7 +10,6 @@ struct TopBarView: View {
|
||||
// var viewModel: LoginViewModel
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@Binding var isSettingsPresented: Bool
|
||||
@Binding var isQrPresented: Bool
|
||||
|
||||
// Привязка для управления боковым меню
|
||||
@Binding var isSideMenuPresented: Bool
|
||||
@ -19,23 +17,15 @@ struct TopBarView: View {
|
||||
@Binding var chatSearchText: String
|
||||
|
||||
var isHomeTab: Bool {
|
||||
return title == NSLocalizedString("Home", comment: "")
|
||||
return title == "Home"
|
||||
}
|
||||
|
||||
var isChatsTab: Bool {
|
||||
return title == NSLocalizedString("Чаты", comment: "")
|
||||
return title == "Chats"
|
||||
}
|
||||
|
||||
var isProfileTab: Bool {
|
||||
return title == NSLocalizedString("Profile", comment: "")
|
||||
}
|
||||
|
||||
var isContactsTab: Bool {
|
||||
return title == NSLocalizedString("Контакты", comment: "")
|
||||
}
|
||||
|
||||
var isSettingsTab: Bool {
|
||||
return title == NSLocalizedString("Настройки", comment: "")
|
||||
return title == "Profile"
|
||||
}
|
||||
|
||||
private var statusMessage: String? {
|
||||
@ -51,22 +41,20 @@ struct TopBarView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
|
||||
if !isMessengerModeEnabled{
|
||||
// Кнопка "Гамбургер" для открытия меню
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
isSideMenuPresented.toggle()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "line.horizontal.3")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.primary)
|
||||
// Кнопка "Гамбургер" для открытия меню
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
isSideMenuPresented.toggle()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "line.horizontal.3")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
// Spacer()
|
||||
|
||||
if let statusMessage, !isContactsTab, !isSettingsTab {
|
||||
if let statusMessage {
|
||||
connectionStatusView(message: statusMessage)
|
||||
Spacer()
|
||||
} else if isHomeTab{
|
||||
@ -121,14 +109,6 @@ struct TopBarView: View {
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
} else if isContactsTab {
|
||||
NavigationLink(isActive: $isQrPresented) {
|
||||
QrView()
|
||||
} label: {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
// else if isChatsTab {
|
||||
@ -237,20 +217,17 @@ struct TopBarView_Previews: PreviewProvider {
|
||||
@StateObject private var viewModel = LoginViewModel()
|
||||
@State private var searchText: String = ""
|
||||
@State private var isSettingsPresented = false
|
||||
@State private var isQrPresented = false
|
||||
|
||||
var body: some View {
|
||||
TopBarView(
|
||||
title: "Chats",
|
||||
isMessengerModeEnabled: false,
|
||||
selectedAccount: $selectedAccount,
|
||||
accounts: [selectedAccount],
|
||||
viewModel: viewModel,
|
||||
isSettingsPresented: $isSettingsPresented,
|
||||
isQrPresented: $isSettingsPresented,
|
||||
isSideMenuPresented: $isSideMenuPresented,
|
||||
chatSearchRevealProgress: $revealProgress,
|
||||
chatSearchText: $searchText,
|
||||
chatSearchText: $searchText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -29,16 +29,3 @@ struct ErrorResponse: Decodable {
|
||||
struct MessagePayload: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct BlockedUserInfo: Decodable {
|
||||
let userId: UUID
|
||||
let login: String?
|
||||
let fullName: String?
|
||||
let customName: String?
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
struct BlockedUsersPayload: Decodable {
|
||||
let hasMore: Bool
|
||||
let items: [BlockedUserInfo]
|
||||
}
|
||||
|
||||
@ -44,9 +44,8 @@ final class AuthService {
|
||||
}
|
||||
|
||||
NetworkClient.shared.request(
|
||||
path: "/v1/auth/login/password",
|
||||
path: "/v1/auth/login",
|
||||
method: .post,
|
||||
headers: ["X-Client-Type": "ios"],
|
||||
body: body,
|
||||
requiresAuth: false
|
||||
) { result in
|
||||
@ -84,91 +83,6 @@ final class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
func requestLoginCode(identifier: String, completion: @escaping (Bool, String?) -> Void) {
|
||||
let payload = LoginCodeRequestPayload(login: identifier)
|
||||
|
||||
guard let body = try? JSONEncoder().encode(payload) else {
|
||||
completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
NetworkClient.shared.request(
|
||||
path: "/v1/auth/login/code",
|
||||
method: .post,
|
||||
headers: ["X-Client-Type": "ios"],
|
||||
body: body,
|
||||
requiresAuth: false
|
||||
) { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let response):
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
||||
guard apiResponse.status == "fine" else {
|
||||
let message = apiResponse.detail ?? apiResponse.data.message
|
||||
completion(false, message)
|
||||
return
|
||||
}
|
||||
completion(true, nil)
|
||||
} catch {
|
||||
completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(false, self.passwordlessRequestErrorMessage(for: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loginWithCode(identifier: String, code: String, completion: @escaping (Bool, String?) -> Void) {
|
||||
let payload = VerifyCodeRequestPayload(login: identifier, otp: code)
|
||||
|
||||
guard let body = try? JSONEncoder().encode(payload) else {
|
||||
completion(false, NSLocalizedString("Не удалось сериализовать данные запроса.", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
NetworkClient.shared.request(
|
||||
path: "/v1/auth/login/verify_code",
|
||||
method: .post,
|
||||
headers: ["X-Client-Type": "ios"],
|
||||
body: body,
|
||||
requiresAuth: false
|
||||
) { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let response):
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let apiResponse = try decoder.decode(APIResponse<TokenPairPayload>.self, from: response.data)
|
||||
guard apiResponse.status == "fine" else {
|
||||
let message = apiResponse.detail ?? NSLocalizedString("Проверьте код и попробуйте снова.", comment: "")
|
||||
completion(false, message)
|
||||
return
|
||||
}
|
||||
|
||||
let tokens = apiResponse.data
|
||||
KeychainService.shared.save(tokens.access_token, forKey: "access_token", service: identifier)
|
||||
KeychainService.shared.save(tokens.refresh_token, forKey: "refresh_token", service: identifier)
|
||||
if let userId = tokens.user_id {
|
||||
KeychainService.shared.save(userId, forKey: "userId", service: identifier)
|
||||
}
|
||||
UserDefaults.standard.set(identifier, forKey: "currentUser")
|
||||
|
||||
NotificationCenter.default.post(name: .accessTokenDidChange, object: nil)
|
||||
|
||||
completion(true, nil)
|
||||
} catch {
|
||||
completion(false, NSLocalizedString("Не удалось обработать ответ сервера.", comment: ""))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(false, self.passwordlessVerifyErrorMessage(for: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
||||
let payload = RegisterRequest(login: username, password: password, invite: invite)
|
||||
guard let body = try? JSONEncoder().encode(payload) else {
|
||||
@ -315,24 +229,11 @@ final class AuthService {
|
||||
return mappedRegistrationMessage(for: message, statusCode: statusCode)
|
||||
}
|
||||
|
||||
let message = extractMessage(from: data)
|
||||
|
||||
switch statusCode {
|
||||
case 400:
|
||||
return NSLocalizedString("Неверный запрос (400).", comment: "")
|
||||
case 403:
|
||||
return NSLocalizedString("Регистрация запрещена.", comment: "")
|
||||
case 409:
|
||||
return NSLocalizedString("Логин уже занят.", comment: "")
|
||||
case 422:
|
||||
if let message {
|
||||
if message == "Value error, Login must not end with 'bot' for non-bot accounts"{
|
||||
return NSLocalizedString("Login must not end with 'bot' for non-bot accounts", comment: "")
|
||||
}
|
||||
return message
|
||||
} else {
|
||||
return NSLocalizedString("Ошибка в данных. Проверьте введённую информацию.", comment: "")
|
||||
}
|
||||
case 429:
|
||||
return NSLocalizedString("Слишком много запросов.", comment: "")
|
||||
case 502:
|
||||
@ -347,70 +248,6 @@ final class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private func passwordlessRequestErrorMessage(for error: NetworkError) -> String {
|
||||
switch error {
|
||||
case .network(let err):
|
||||
return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription)
|
||||
case .server(let statusCode, let data):
|
||||
let message = extractMessage(from: data)
|
||||
|
||||
switch statusCode {
|
||||
case 401, 404:
|
||||
return message ?? NSLocalizedString("Аккаунт не найден.", comment: "")
|
||||
case 403:
|
||||
return message ?? NSLocalizedString("Этому аккаунту недоступен вход по коду.", comment: "")
|
||||
case 422:
|
||||
return message ?? NSLocalizedString("Неверный логин. Проверьте и попробуйте снова.", comment: "")
|
||||
case 429:
|
||||
return NSLocalizedString("Слишком много попыток. Попробуйте позже.", comment: "")
|
||||
case 502:
|
||||
return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
|
||||
default:
|
||||
if let message {
|
||||
return message
|
||||
}
|
||||
return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)")
|
||||
}
|
||||
case .unauthorized:
|
||||
return NSLocalizedString("Необходимо авторизоваться заново.", comment: "")
|
||||
case .invalidURL, .noResponse:
|
||||
return NSLocalizedString("Некорректный ответ от сервера.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
private func passwordlessVerifyErrorMessage(for error: NetworkError) -> String {
|
||||
switch error {
|
||||
case .network(let err):
|
||||
return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), err.localizedDescription)
|
||||
case .server(let statusCode, let data):
|
||||
let message = extractMessage(from: data)
|
||||
|
||||
switch statusCode {
|
||||
case 401:
|
||||
return message ?? NSLocalizedString("Неверный или просроченный код.", comment: "")
|
||||
case 403:
|
||||
return message ?? NSLocalizedString("Этот аккаунт недоступен.", comment: "")
|
||||
case 404:
|
||||
return message ?? NSLocalizedString("Аккаунт не найден.", comment: "")
|
||||
case 422:
|
||||
return message ?? NSLocalizedString("Некорректные данные. Проверьте код и логин.", comment: "")
|
||||
case 429:
|
||||
return NSLocalizedString("Слишком много попыток. Попробуйте позже.", comment: "")
|
||||
case 502:
|
||||
return NSLocalizedString("Сервер не отвечает. Попробуйте позже.", comment: "")
|
||||
default:
|
||||
if let message {
|
||||
return message
|
||||
}
|
||||
return String(format: NSLocalizedString("Ошибка сервера: %@", comment: ""), "\(statusCode)")
|
||||
}
|
||||
case .unauthorized:
|
||||
return NSLocalizedString("Сессия недействительна. Авторизуйтесь заново.", comment: "")
|
||||
case .invalidURL, .noResponse:
|
||||
return NSLocalizedString("Некорректный ответ от сервера.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
private func mappedRegistrationMessage(for message: String, statusCode: Int) -> String {
|
||||
if statusCode == 400 {
|
||||
if message.contains("Invalid invitation code") {
|
||||
@ -431,7 +268,7 @@ final class AuthService {
|
||||
return NSLocalizedString("Регистрация временно недоступна.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if statusCode == 429 {
|
||||
return NSLocalizedString("Слишком много запросов.", comment: "")
|
||||
}
|
||||
@ -550,15 +387,6 @@ private struct LoginRequest: Encodable {
|
||||
let password: String
|
||||
}
|
||||
|
||||
private struct LoginCodeRequestPayload: Encodable {
|
||||
let login: String
|
||||
}
|
||||
|
||||
private struct VerifyCodeRequestPayload: Encodable {
|
||||
let login: String
|
||||
let otp: String
|
||||
}
|
||||
|
||||
private struct RegisterRequest: Encodable {
|
||||
let login: String
|
||||
let password: String
|
||||
|
||||
@ -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?
|
||||
}
|
||||
@ -83,7 +83,6 @@ struct MessageItem: Decodable, Identifiable {
|
||||
let content: String?
|
||||
let mediaLink: String?
|
||||
let isViewed: Bool?
|
||||
let viewedAt: Date?
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
let forwardMetadata: ForwardMetadata?
|
||||
@ -99,7 +98,6 @@ struct MessageItem: Decodable, Identifiable {
|
||||
case content
|
||||
case mediaLink
|
||||
case isViewed
|
||||
case viewedAt
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case forwardMetadata
|
||||
@ -115,7 +113,6 @@ struct MessageItem: Decodable, Identifiable {
|
||||
self.content = try container.decodeIfPresent(String.self, forKey: .content)
|
||||
self.mediaLink = try container.decodeIfPresent(String.self, forKey: .mediaLink)
|
||||
self.isViewed = try container.decodeIfPresent(Bool.self, forKey: .isViewed)
|
||||
self.viewedAt = try container.decodeIfPresent(Date.self, forKey: .viewedAt)
|
||||
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||
self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt)
|
||||
self.forwardMetadata = try container.decodeIfPresent(ForwardMetadata.self, forKey: .forwardMetadata)
|
||||
@ -130,7 +127,6 @@ struct MessageItem: Decodable, Identifiable {
|
||||
content: String?,
|
||||
mediaLink: String?,
|
||||
isViewed: Bool?,
|
||||
viewedAt: Date?,
|
||||
createdAt: Date?,
|
||||
updatedAt: Date?,
|
||||
forwardMetadata: ForwardMetadata?
|
||||
@ -143,7 +139,6 @@ struct MessageItem: Decodable, Identifiable {
|
||||
self.content = content
|
||||
self.mediaLink = mediaLink
|
||||
self.isViewed = isViewed
|
||||
self.viewedAt = viewedAt
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.forwardMetadata = forwardMetadata
|
||||
@ -172,12 +167,10 @@ struct ChatProfile: Decodable {
|
||||
let bio: String?
|
||||
let lastSeen: Int?
|
||||
let createdAt: Date?
|
||||
let avatars: Avatars?
|
||||
let stories: [JSONValue]
|
||||
let permissions: ChatPermissions?
|
||||
let profilePermissions: ChatProfilePermissions?
|
||||
let relationship: RelationshipStatus?
|
||||
let rating: Double?
|
||||
let isOfficial: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@ -188,12 +181,10 @@ struct ChatProfile: Decodable {
|
||||
case bio
|
||||
case lastSeen
|
||||
case createdAt
|
||||
case avatars
|
||||
case stories
|
||||
case permissions
|
||||
case profilePermissions
|
||||
case relationship
|
||||
case rating
|
||||
case isOfficial
|
||||
case isVerified
|
||||
}
|
||||
@ -207,50 +198,16 @@ struct ChatProfile: Decodable {
|
||||
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
|
||||
self.lastSeen = try container.decodeIfPresent(Int.self, forKey: .lastSeen)
|
||||
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||
self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars)
|
||||
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
|
||||
self.permissions = try container.decodeIfPresent(ChatPermissions.self, forKey: .permissions)
|
||||
self.profilePermissions = try container.decodeIfPresent(ChatProfilePermissions.self, forKey: .profilePermissions)
|
||||
self.relationship = try container.decodeIfPresent(RelationshipStatus.self, forKey: .relationship)
|
||||
let ratingPayload = try container.decodeIfPresent(ChatProfileRatingPayload.self, forKey: .rating)
|
||||
self.rating = ratingPayload?.resolvedRating
|
||||
let explicitOfficial = try container.decodeIfPresent(Bool.self, forKey: .isOfficial)
|
||||
let verifiedFlag = try container.decodeIfPresent(Bool.self, forKey: .isVerified)
|
||||
self.isOfficial = explicitOfficial ?? verifiedFlag ?? false
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatProfileRatingPayload: Decodable {
|
||||
let status: String?
|
||||
let rating: Double?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rating
|
||||
case status
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
status = try container.decodeIfPresent(String.self, forKey: .status)
|
||||
|
||||
if let doubleValue = try? container.decode(Double.self, forKey: .rating) {
|
||||
rating = doubleValue
|
||||
} else if let stringValue = try? container.decode(String.self, forKey: .rating),
|
||||
let doubleValue = Double(stringValue) {
|
||||
rating = doubleValue
|
||||
} else if let intValue = try? container.decode(Int.self, forKey: .rating) {
|
||||
rating = Double(intValue)
|
||||
} else {
|
||||
rating = nil
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedRating: Double? {
|
||||
guard status?.lowercased() == "fine" else { return nil }
|
||||
return rating
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatProfile {
|
||||
init(
|
||||
userId: String,
|
||||
@ -260,12 +217,10 @@ extension ChatProfile {
|
||||
bio: String? = nil,
|
||||
lastSeen: Int? = nil,
|
||||
createdAt: Date? = nil,
|
||||
avatars: Avatars? = nil,
|
||||
stories: [JSONValue] = [],
|
||||
permissions: ChatPermissions? = nil,
|
||||
profilePermissions: ChatProfilePermissions? = nil,
|
||||
relationship: RelationshipStatus? = nil,
|
||||
rating: Double? = nil,
|
||||
isOfficial: Bool = false
|
||||
) {
|
||||
self.userId = userId
|
||||
@ -275,29 +230,14 @@ extension ChatProfile {
|
||||
self.bio = bio
|
||||
self.lastSeen = lastSeen
|
||||
self.createdAt = createdAt
|
||||
self.avatars = avatars
|
||||
self.stories = stories
|
||||
self.permissions = permissions
|
||||
self.profilePermissions = profilePermissions
|
||||
self.relationship = relationship
|
||||
self.rating = rating
|
||||
self.isOfficial = isOfficial
|
||||
}
|
||||
}
|
||||
|
||||
struct AvatarInfo: Decodable {
|
||||
let fileId: String
|
||||
let mime: String?
|
||||
let size: Int?
|
||||
let width: Int?
|
||||
let height: Int?
|
||||
let createdAt: Date?
|
||||
}
|
||||
|
||||
struct Avatars: Decodable {
|
||||
let current: AvatarInfo?
|
||||
}
|
||||
|
||||
struct ChatPermissions: Decodable {
|
||||
let youCanSendMessage: Bool
|
||||
let youCanPublicInvitePermission: Bool
|
||||
@ -315,39 +255,9 @@ struct ChatProfilePermissions: Decodable {
|
||||
}
|
||||
|
||||
struct RelationshipStatus: Decodable {
|
||||
let isTargetInContactsOfCurrentUser: Bool
|
||||
let isCurrentUserInContactsOfTarget: Bool
|
||||
let isTargetUserBlockedByCurrentUser: Bool
|
||||
let isCurrentUserInBlacklistOfTarget: Bool
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case isTargetInContactsOfCurrentUser
|
||||
case isCurrentUserInContactsOfTarget
|
||||
case isTargetUserBlockedByCurrentUser
|
||||
case isCurrentUserInBlacklistOfTarget
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.isTargetInContactsOfCurrentUser = try container.decodeIfPresent(Bool.self, forKey: .isTargetInContactsOfCurrentUser) ?? false
|
||||
self.isCurrentUserInContactsOfTarget = try container.decodeIfPresent(Bool.self, forKey: .isCurrentUserInContactsOfTarget) ?? false
|
||||
self.isTargetUserBlockedByCurrentUser = try container.decodeIfPresent(Bool.self, forKey: .isTargetUserBlockedByCurrentUser) ?? false
|
||||
self.isCurrentUserInBlacklistOfTarget = try container.decodeIfPresent(Bool.self, forKey: .isCurrentUserInBlacklistOfTarget) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
extension RelationshipStatus {
|
||||
init(
|
||||
isTargetInContactsOfCurrentUser: Bool,
|
||||
isCurrentUserInContactsOfTarget: Bool,
|
||||
isTargetUserBlockedByCurrentUser: Bool,
|
||||
isCurrentUserInBlacklistOfTarget: Bool
|
||||
) {
|
||||
self.isTargetInContactsOfCurrentUser = isTargetInContactsOfCurrentUser
|
||||
self.isCurrentUserInContactsOfTarget = isCurrentUserInContactsOfTarget
|
||||
self.isTargetUserBlockedByCurrentUser = isTargetUserBlockedByCurrentUser
|
||||
self.isCurrentUserInBlacklistOfTarget = isCurrentUserInBlacklistOfTarget
|
||||
}
|
||||
}
|
||||
|
||||
enum JSONValue: Decodable {
|
||||
|
||||
@ -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
|
||||
}()
|
||||
}
|
||||
@ -5,26 +5,20 @@ struct ProfileDataPayload: Decodable {
|
||||
let login: String
|
||||
let fullName: String?
|
||||
let bio: String?
|
||||
let avatars: Avatars?
|
||||
let balances: [WalletBalancePayload]
|
||||
let createdAt: Date?
|
||||
let isVerified: Bool
|
||||
let stories: [JSONValue]
|
||||
let profilePermissions: ProfilePermissionsPayload
|
||||
let rating: Double?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case userId
|
||||
case login
|
||||
case fullName
|
||||
case bio
|
||||
case avatars
|
||||
case balances
|
||||
case createdAt
|
||||
case isVerified
|
||||
case stories
|
||||
case profilePermissions
|
||||
case rating
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@ -33,61 +27,13 @@ struct ProfileDataPayload: Decodable {
|
||||
self.login = try container.decode(String.self, forKey: .login)
|
||||
self.fullName = try container.decodeIfPresent(String.self, forKey: .fullName)
|
||||
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
|
||||
self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars)
|
||||
self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? []
|
||||
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||
self.isVerified = try container.decode(Bool.self, forKey: .isVerified)
|
||||
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
|
||||
self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions)
|
||||
let ratingPayload = try container.decodeIfPresent(ProfileRatingPayload.self, forKey: .rating)
|
||||
self.rating = ratingPayload?.resolvedRating
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProfileRatingPayload: Decodable {
|
||||
let status: String?
|
||||
let rating: Double?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rating
|
||||
case status
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
status = try container.decodeIfPresent(String.self, forKey: .status)
|
||||
|
||||
if let doubleValue = try? container.decode(Double.self, forKey: .rating) {
|
||||
rating = doubleValue
|
||||
} else if let stringValue = try? container.decode(String.self, forKey: .rating),
|
||||
let doubleValue = Double(stringValue) {
|
||||
rating = doubleValue
|
||||
} else if let intValue = try? container.decode(Int.self, forKey: .rating) {
|
||||
rating = Double(intValue)
|
||||
} else {
|
||||
rating = nil
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedRating: Double? {
|
||||
guard status?.lowercased() == "fine" else { return nil }
|
||||
return rating
|
||||
}
|
||||
}
|
||||
|
||||
//struct AvatarInfo: Decodable {
|
||||
// let fileId: String
|
||||
// let mime: String?
|
||||
// let size: Int?
|
||||
// let width: Int?
|
||||
// let height: Int?
|
||||
// let createdAt: Date?
|
||||
//}
|
||||
//
|
||||
//struct Avatars: Decodable {
|
||||
// let current: AvatarInfo?
|
||||
//}
|
||||
|
||||
struct WalletBalancePayload: Decodable {
|
||||
let currency: String
|
||||
let balance: Decimal
|
||||
@ -210,39 +156,6 @@ struct ProfilePermissionsRequestPayload: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfilePermissionsRequestPayload {
|
||||
init(payload: ProfilePermissionsPayload) {
|
||||
self.init(
|
||||
isSearchable: payload.isSearchable,
|
||||
allowMessageForwarding: payload.allowMessageForwarding,
|
||||
allowMessagesFromNonContacts: payload.allowMessagesFromNonContacts,
|
||||
showProfilePhotoToNonContacts: payload.showProfilePhotoToNonContacts,
|
||||
lastSeenVisibility: payload.lastSeenVisibility,
|
||||
showBioToNonContacts: payload.showBioToNonContacts,
|
||||
showStoriesToNonContacts: payload.showStoriesToNonContacts,
|
||||
allowServerChats: payload.allowServerChats,
|
||||
publicInvitePermission: payload.publicInvitePermission,
|
||||
groupInvitePermission: payload.groupInvitePermission,
|
||||
callPermission: payload.callPermission,
|
||||
forceAutoDeleteMessagesInPrivate: payload.forceAutoDeleteMessagesInPrivate,
|
||||
maxMessageAutoDeleteSeconds: payload.maxMessageAutoDeleteSeconds,
|
||||
autoDeleteAfterDays: payload.autoDeleteAfterDays
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileUpdateRequestPayload: Encodable {
|
||||
let fullName: String?
|
||||
let bio: String?
|
||||
let profilePermissions: ProfilePermissionsRequestPayload
|
||||
|
||||
init(fullName: String? = nil, bio: String? = nil, profilePermissions: ProfilePermissionsRequestPayload) {
|
||||
self.fullName = fullName
|
||||
self.bio = bio
|
||||
self.profilePermissions = profilePermissions
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadAvatarPayload: Decodable {
|
||||
let fileId: String
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum ProfileServiceError: LocalizedError {
|
||||
case unexpectedStatus(String)
|
||||
@ -74,57 +73,6 @@ final class ProfileService {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchProfile(userId: UUID, completion: @escaping (Result<ChatProfile, Error>) -> Void) {
|
||||
fetchProfile(userId: userId.uuidString, completion: completion)
|
||||
}
|
||||
|
||||
func fetchProfile(userId: String, completion: @escaping (Result<ChatProfile, Error>) -> Void) {
|
||||
let sanitizedId = userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
client.request(
|
||||
path: "/v1/profile/\(sanitizedId)",
|
||||
method: .get,
|
||||
requiresAuth: true
|
||||
) { [decoder] result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
do {
|
||||
let profile = try Self.decodeProfileResponse(
|
||||
data: response.data,
|
||||
decoder: decoder,
|
||||
requestedId: sanitizedId
|
||||
)
|
||||
completion(.success(profile))
|
||||
} catch {
|
||||
if AppConfig.DEBUG {
|
||||
print("[ProfileService] decode profile by id failed: \(error)")
|
||||
}
|
||||
completion(.failure(error))
|
||||
}
|
||||
case .failure(let error):
|
||||
if case let NetworkError.server(_, data) = error,
|
||||
let data,
|
||||
let message = Self.errorMessage(from: data) {
|
||||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||||
return
|
||||
}
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchProfile(userId: UUID) async throws -> ChatProfile {
|
||||
try await fetchProfile(userId: userId.uuidString)
|
||||
}
|
||||
|
||||
func fetchProfile(userId: String) async throws -> ChatProfile {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
fetchProfile(userId: userId, completion: { result in
|
||||
continuation.resume(with: result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func updateProfile(_ payload: ProfileUpdateRequestPayload, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
@ -185,126 +133,6 @@ final class ProfileService {
|
||||
}
|
||||
}
|
||||
|
||||
func uploadAvatar(image: UIImage, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
guard let imageData = image.jpegData(compressionQuality: 0.9) else {
|
||||
let message = NSLocalizedString("Не удалось подготовить изображение для загрузки.", comment: "Avatar encoding error")
|
||||
completion(.failure(ProfileServiceError.encoding(message)))
|
||||
return
|
||||
}
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
let body = Self.makeMultipartBody(
|
||||
data: imageData,
|
||||
boundary: boundary,
|
||||
fieldName: "file",
|
||||
filename: "avatar.jpg",
|
||||
mimeType: "image/jpeg"
|
||||
)
|
||||
|
||||
client.request(
|
||||
path: "/v1/storage/upload/avatar",
|
||||
method: .post,
|
||||
body: body,
|
||||
contentType: "multipart/form-data; boundary=\(boundary)",
|
||||
requiresAuth: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let apiResponse = try decoder.decode(APIResponse<UploadAvatarPayload>.self, from: response.data)
|
||||
guard apiResponse.status == "fine" else {
|
||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить аватар.", comment: "Avatar upload unexpected status")
|
||||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||||
return
|
||||
}
|
||||
completion(.success(apiResponse.data.fileId))
|
||||
} catch {
|
||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
||||
if AppConfig.DEBUG {
|
||||
print("[ProfileService] decode upload avatar failed: \(debugMessage)")
|
||||
}
|
||||
if AppConfig.DEBUG {
|
||||
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
|
||||
} else {
|
||||
let message = NSLocalizedString("Не удалось обработать ответ сервера.", comment: "Avatar upload decode error")
|
||||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
if case let NetworkError.server(_, data) = error,
|
||||
let data,
|
||||
let message = Self.errorMessage(from: data) {
|
||||
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||||
return
|
||||
}
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadAvatar(image: UIImage) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
uploadAvatar(image: image) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeProfileResponse(
|
||||
data: Data,
|
||||
decoder: JSONDecoder,
|
||||
requestedId: String
|
||||
) throws -> ChatProfile {
|
||||
let defaultErrorMessage = NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile unexpected status")
|
||||
var dictionaryDecodeError: String?
|
||||
|
||||
do {
|
||||
let apiResponse = try decoder.decode(APIResponse<[String: ChatProfile]>.self, from: data)
|
||||
guard apiResponse.status == "fine" else {
|
||||
let message = apiResponse.detail ?? defaultErrorMessage
|
||||
throw ProfileServiceError.unexpectedStatus(message)
|
||||
}
|
||||
|
||||
let normalizedKey = requestedId.lowercased()
|
||||
if let profile = apiResponse.data[requestedId]
|
||||
?? apiResponse.data[normalizedKey]
|
||||
?? apiResponse.data[requestedId.uppercased()]
|
||||
?? apiResponse.data.first?.value {
|
||||
return profile
|
||||
}
|
||||
|
||||
throw ProfileServiceError.unexpectedStatus(
|
||||
NSLocalizedString("Профиль не найден.", comment: "Profile by id missing")
|
||||
)
|
||||
} catch let error as ProfileServiceError {
|
||||
throw error
|
||||
} catch {
|
||||
dictionaryDecodeError = Self.describeDecodingError(error: error, data: data)
|
||||
}
|
||||
|
||||
do {
|
||||
let apiResponse = try decoder.decode(APIResponse<ChatProfile>.self, from: data)
|
||||
guard apiResponse.status == "fine" else {
|
||||
let message = apiResponse.detail ?? defaultErrorMessage
|
||||
throw ProfileServiceError.unexpectedStatus(message)
|
||||
}
|
||||
return apiResponse.data
|
||||
} catch let error as ProfileServiceError {
|
||||
throw error
|
||||
} catch {
|
||||
let singleError = Self.describeDecodingError(error: error, data: data)
|
||||
let combined: String
|
||||
if let dictionaryDecodeError {
|
||||
combined = dictionaryDecodeError + "\nOR\n" + singleError
|
||||
} else {
|
||||
combined = singleError
|
||||
}
|
||||
throw ProfileServiceError.decoding(debugDescription: combined)
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeDate(from decoder: Decoder) throws -> Date {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let string = try container.decode(String.self)
|
||||
@ -370,31 +198,6 @@ final class ProfileService {
|
||||
return String(string[string.startIndex..<index]) + "…"
|
||||
}
|
||||
|
||||
private static func makeMultipartBody(
|
||||
data: Data,
|
||||
boundary: String,
|
||||
fieldName: String,
|
||||
filename: String,
|
||||
mimeType: String
|
||||
) -> Data {
|
||||
var body = Data()
|
||||
let lineBreak = "\r\n"
|
||||
if let boundaryData = "--\(boundary)\(lineBreak)".data(using: .utf8) {
|
||||
body.append(boundaryData)
|
||||
}
|
||||
if let dispositionData = "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(filename)\"\(lineBreak)".data(using: .utf8) {
|
||||
body.append(dispositionData)
|
||||
}
|
||||
if let typeData = "Content-Type: \(mimeType)\(lineBreak)\(lineBreak)".data(using: .utf8) {
|
||||
body.append(typeData)
|
||||
}
|
||||
body.append(data)
|
||||
if let closingData = "\(lineBreak)--\(boundary)--\(lineBreak)".data(using: .utf8) {
|
||||
body.append(closingData)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
private static func errorMessage(from data: Data) -> String? {
|
||||
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
||||
if let detail = apiError.detail, !detail.isEmpty {
|
||||
|
||||
@ -63,27 +63,14 @@ extension UserSearchResult {
|
||||
}
|
||||
|
||||
var avatarInitial: String {
|
||||
let nameSource: String?
|
||||
if let customName = preferredCustomName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
nameSource = customName
|
||||
} else if let fullName = officialFullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
nameSource = fullName
|
||||
} else {
|
||||
nameSource = nil
|
||||
}
|
||||
let source = preferredCustomName
|
||||
?? officialFullName
|
||||
?? login
|
||||
?? userId.uuidString
|
||||
|
||||
if let name = nameSource {
|
||||
let components = name.split(separator: " ")
|
||||
let nameInitials = components.prefix(2).compactMap { $0.first }
|
||||
if !nameInitials.isEmpty {
|
||||
return nameInitials.map { String($0) }.joined().uppercased()
|
||||
}
|
||||
if let character = source.first(where: { !$0.isWhitespace && $0 != "@" }) {
|
||||
return String(character).uppercased()
|
||||
}
|
||||
|
||||
if let login = login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return String(login.prefix(1)).uppercased()
|
||||
}
|
||||
|
||||
return "?"
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}()
|
||||
}
|
||||
@ -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])
|
||||
// }
|
||||
//}
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 843 B After Width: | Height: | Size: 720 B |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1018 B |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.6 KiB |
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,9 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
struct ChatNavigationTarget: Identifiable {
|
||||
let id = UUID()
|
||||
let chat: PrivateChatListItem
|
||||
}
|
||||
|
||||
final class IncomingMessageCenter: ObservableObject {
|
||||
@Published private(set) var banner: IncomingMessageBanner?
|
||||
@Published var presentedChat: PrivateChatListItem?
|
||||
@Published var pendingNavigation: ChatNavigationTarget?
|
||||
var currentUserId: String?
|
||||
var activeChatId: String?
|
||||
|
||||
@ -38,13 +32,7 @@ final class IncomingMessageCenter: ObservableObject {
|
||||
guard let banner else { return }
|
||||
activeChatId = banner.message.chatId
|
||||
let chatItem = makeChatItem(from: banner.message)
|
||||
if AppConfig.PRESENT_CHAT_AS_SHEET {
|
||||
presentedChat = chatItem
|
||||
pendingNavigation = nil
|
||||
} else {
|
||||
pendingNavigation = ChatNavigationTarget(chat: chatItem)
|
||||
presentedChat = nil
|
||||
}
|
||||
presentedChat = chatItem
|
||||
dismissBanner()
|
||||
}
|
||||
|
||||
@ -57,8 +45,7 @@ final class IncomingMessageCenter: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if AppConfig.PRESENT_CHAT_AS_SHEET,
|
||||
let presentedChat,
|
||||
if let presentedChat,
|
||||
presentedChat.chatId == message.chatId {
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import Foundation
|
||||
import SwiftUICore
|
||||
import Security
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
|
||||
//let username = "user1"
|
||||
@ -114,163 +111,3 @@ class KeychainService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AvatarCacheService {
|
||||
static let shared = AvatarCacheService()
|
||||
private let fileManager = FileManager.default
|
||||
private var baseCacheDirectory: URL? {
|
||||
fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("avatar_cache")
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
private func cacheDirectory(for userId: String) -> URL? {
|
||||
baseCacheDirectory?.appendingPathComponent(userId)
|
||||
}
|
||||
|
||||
private func filePath(forKey key: String, userId: String) -> URL? {
|
||||
cacheDirectory(for: userId)?.appendingPathComponent(key)
|
||||
}
|
||||
|
||||
func getImage(forKey key: String, userId: String) -> UIImage? {
|
||||
guard let url = filePath(forKey: key, userId: userId),
|
||||
fileManager.fileExists(atPath: url.path),
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
return nil
|
||||
}
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
func saveImage(_ image: UIImage, forKey key: String, userId: String) {
|
||||
guard let directory = cacheDirectory(for: userId),
|
||||
let url = filePath(forKey: key, userId: userId),
|
||||
let data = image.jpegData(compressionQuality: 0.8) else {
|
||||
return
|
||||
}
|
||||
|
||||
if !fileManager.fileExists(atPath: directory.path) {
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
|
||||
try? data.write(to: url)
|
||||
}
|
||||
|
||||
func clearCache(forUserId userId: String) {
|
||||
guard let directory = cacheDirectory(for: userId) else { return }
|
||||
|
||||
// Try to delete files inside first, ignoring errors
|
||||
if let fileUrls = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: []) {
|
||||
for fileUrl in fileUrls {
|
||||
try? fileManager.removeItem(at: fileUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Then try to delete the directory itself
|
||||
try? fileManager.removeItem(at: directory)
|
||||
}
|
||||
|
||||
func clearAllCache() {
|
||||
guard let directory = baseCacheDirectory else { return }
|
||||
try? fileManager.removeItem(at: directory)
|
||||
}
|
||||
|
||||
func getAllCachedUserIds() -> [String] {
|
||||
guard let baseDir = baseCacheDirectory else { return [] }
|
||||
do {
|
||||
let directoryContents = try fileManager.contentsOfDirectory(at: baseDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
return directoryContents.map { $0.lastPathComponent }
|
||||
} catch {
|
||||
// This can happen if the directory doesn't exist yet, which is not an error.
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func sizeOfCache(forUserId userId: String) -> Int64 {
|
||||
guard let directory = cacheDirectory(for: userId) else { return 0 }
|
||||
return directorySize(url: directory)
|
||||
}
|
||||
|
||||
func sizeOfAllCache() -> Int64 {
|
||||
guard let directory = baseCacheDirectory else { return 0 }
|
||||
return directorySize(url: directory)
|
||||
}
|
||||
|
||||
private func directorySize(url: URL) -> Int64 {
|
||||
let contents: [URL]
|
||||
do {
|
||||
contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey], options: .skipsHiddenFiles)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
|
||||
var totalSize: Int64 = 0
|
||||
for url in contents {
|
||||
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
|
||||
totalSize += Int64(fileSize)
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
}
|
||||
|
||||
class ImageLoader: ObservableObject {
|
||||
@Published var image: UIImage?
|
||||
|
||||
private let url: URL
|
||||
private let fileId: String
|
||||
private let userId: String
|
||||
private var cancellable: AnyCancellable?
|
||||
private let cache = AvatarCacheService.shared
|
||||
|
||||
init(url: URL, fileId: String, userId: String) {
|
||||
self.url = url
|
||||
self.fileId = fileId
|
||||
self.userId = userId
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellable?.cancel()
|
||||
}
|
||||
|
||||
func load() {
|
||||
if let cachedImage = cache.getImage(forKey: fileId, userId: userId) {
|
||||
self.image = cachedImage
|
||||
return
|
||||
}
|
||||
|
||||
cancellable = URLSession.shared.dataTaskPublisher(for: url)
|
||||
.map { UIImage(data: $0.data) }
|
||||
.replaceError(with: nil)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] loadedImage in
|
||||
guard let self = self, let image = loadedImage else { return }
|
||||
self.image = image
|
||||
self.cache.saveImage(image, forKey: self.fileId, userId: self.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CachedAvatarView<Placeholder: View>: View {
|
||||
@StateObject private var loader: ImageLoader
|
||||
private let placeholder: Placeholder
|
||||
|
||||
init(url: URL, fileId: String, userId: String, @ViewBuilder placeholder: () -> Placeholder) {
|
||||
self.placeholder = placeholder()
|
||||
_loader = StateObject(wrappedValue: ImageLoader(url: url, fileId: fileId, userId: userId))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onAppear(perform: loader.load)
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
Group {
|
||||
if let image = loader.image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -340,7 +340,17 @@ final class SocketService {
|
||||
private func handleNewPrivateMessage(_ data: [Any]) {
|
||||
guard let payload = data.first else { return }
|
||||
|
||||
guard let messageData = normalizeMessagePayload(payload) else { return }
|
||||
let messageData: Data
|
||||
if let dictionary = payload as? [String: Any],
|
||||
JSONSerialization.isValidJSONObject(dictionary),
|
||||
let json = try? JSONSerialization.data(withJSONObject: dictionary, options: []) {
|
||||
messageData = json
|
||||
} else if let string = payload as? String,
|
||||
let data = string.data(using: .utf8) {
|
||||
messageData = data
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
@ -363,31 +373,6 @@ final class SocketService {
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizeMessagePayload(_ payload: Any) -> Data? {
|
||||
// Server can wrap the actual message in an { event, payload } envelope.
|
||||
if let dictionary = payload as? [String: Any] {
|
||||
let messageBody = dictionary["payload"] ?? dictionary
|
||||
if let messageDict = messageBody as? [String: Any],
|
||||
JSONSerialization.isValidJSONObject(messageDict) {
|
||||
return try? JSONSerialization.data(withJSONObject: messageDict, options: [])
|
||||
}
|
||||
}
|
||||
|
||||
if let string = payload as? String,
|
||||
let data = string.data(using: .utf8) {
|
||||
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let nested = jsonObject["payload"] {
|
||||
return normalizeMessagePayload(nested)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
if let data = payload as? Data {
|
||||
return data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func handleHeartbeatSuccess() {
|
||||
consecutiveHeartbeatMisses = 0
|
||||
heartbeatAckInFlight = false
|
||||
|
||||
@ -7,69 +7,27 @@
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
class LoginViewModel: ObservableObject {
|
||||
// @AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
|
||||
|
||||
@Published var username: String = ""
|
||||
@Published var userId: String = ""
|
||||
@Published var password: String = ""
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var isInitialLoading: Bool = true // отдельный флаг для сплэша до завершения автологина
|
||||
@Published var isLoading: Bool = true // сразу true, чтобы показать спиннер при автологине
|
||||
@Published var showError: Bool = false
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var isLoggedIn: Bool = false
|
||||
@Published var socketState: SocketService.ConnectionState
|
||||
@Published var chatLoadingState: ChatLoadingState = .idle
|
||||
@Published var hasAcceptedTerms: Bool = false
|
||||
@Published var isLoadingTerms: Bool = false
|
||||
@Published var termsContent: String = ""
|
||||
@Published var termsErrorMessage: String?
|
||||
@Published var onboardingDestination: OnboardingDestination?
|
||||
@Published var loginFlowStep: LoginFlowStep = .passwordlessRequest
|
||||
@Published var passwordlessLogin: String = "" {
|
||||
didSet {
|
||||
if passwordlessLogin.count > 32 {
|
||||
passwordlessLogin = String(passwordlessLogin.prefix(32))
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var verificationCode: String = "" {
|
||||
didSet {
|
||||
let filtered = verificationCode
|
||||
.filter { $0.isNumber }
|
||||
.prefix(Constants.verificationCodeLength)
|
||||
if filtered != verificationCode {
|
||||
verificationCode = String(filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var isSendingCode: Bool = false
|
||||
@Published var isVerifyingCode: Bool = false
|
||||
@Published var resendSecondsRemaining: Int = 0
|
||||
|
||||
private let authService = AuthService()
|
||||
private let socketService = SocketService.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var resendTimer: Timer?
|
||||
|
||||
enum LoginFlowStep: Equatable {
|
||||
case passwordlessRequest
|
||||
case passwordlessVerify
|
||||
case password
|
||||
case registration
|
||||
}
|
||||
|
||||
enum ChatLoadingState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
}
|
||||
|
||||
enum OnboardingDestination: Equatable {
|
||||
case afterRegister
|
||||
}
|
||||
|
||||
private enum DefaultsKeys {
|
||||
static let currentUser = "currentUser"
|
||||
static let userId = "userId"
|
||||
@ -85,10 +43,6 @@ class LoginViewModel: ObservableObject {
|
||||
autoLogin()
|
||||
}
|
||||
|
||||
deinit {
|
||||
resendTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func observeSocketState() {
|
||||
socketService.connectionStatePublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
@ -137,7 +91,6 @@ class LoginViewModel: ObservableObject {
|
||||
self?.socketService.disconnect()
|
||||
}
|
||||
self?.isLoading = false
|
||||
self?.isInitialLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -146,13 +99,8 @@ class LoginViewModel: ObservableObject {
|
||||
func login() {
|
||||
isLoading = true
|
||||
showError = false
|
||||
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedLogin != passwordlessLogin {
|
||||
passwordlessLogin = trimmedLogin
|
||||
}
|
||||
username = trimmedLogin
|
||||
|
||||
authService.login(username: trimmedLogin, password: password) { [weak self] success, error in
|
||||
|
||||
authService.login(username: username, password: password) { [weak self] success, error in
|
||||
DispatchQueue.main.async {
|
||||
self?.isLoading = false
|
||||
if success {
|
||||
@ -167,94 +115,6 @@ class LoginViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestPasswordlessCode() {
|
||||
guard LoginViewModel.isLoginValid(passwordlessLogin) else {
|
||||
errorMessage = NSLocalizedString("Неверный логин", comment: "")
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
|
||||
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
isSendingCode = true
|
||||
showError = false
|
||||
|
||||
authService.requestLoginCode(identifier: trimmedLogin) { [weak self] success, message in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
|
||||
self.isSendingCode = false
|
||||
|
||||
if success {
|
||||
self.passwordlessLogin = trimmedLogin
|
||||
self.verificationCode = ""
|
||||
self.loginFlowStep = .passwordlessVerify
|
||||
self.startResendTimer()
|
||||
} else {
|
||||
if self.handlePasswordlessRedirect(message: message, login: trimmedLogin) {
|
||||
return
|
||||
}
|
||||
self.errorMessage = message ?? NSLocalizedString("Не удалось отправить код.", comment: "")
|
||||
self.showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verifyPasswordlessCode() {
|
||||
guard verificationCode.count == Constants.verificationCodeLength,
|
||||
!passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
isVerifyingCode = true
|
||||
showError = false
|
||||
|
||||
authService.loginWithCode(identifier: passwordlessLogin, code: verificationCode) { [weak self] success, message in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
|
||||
self.isVerifyingCode = false
|
||||
|
||||
if success {
|
||||
self.resendTimer?.invalidate()
|
||||
self.loadStoredUser()
|
||||
self.isLoggedIn = true
|
||||
self.socketService.connectForCurrentUser()
|
||||
self.verificationCode = ""
|
||||
} else {
|
||||
self.errorMessage = message ?? NSLocalizedString("Проверьте введённый код и попробуйте снова.", comment: "")
|
||||
self.showError = true
|
||||
// self.verificationCode = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resendPasswordlessCode() {
|
||||
guard resendSecondsRemaining == 0 else { return }
|
||||
requestPasswordlessCode()
|
||||
}
|
||||
|
||||
func showPasswordLogin() {
|
||||
resendTimer?.invalidate()
|
||||
loginFlowStep = .password
|
||||
}
|
||||
|
||||
func showPasswordlessRequest() {
|
||||
loginFlowStep = .passwordlessRequest
|
||||
}
|
||||
|
||||
func backToPasswordlessRequest() {
|
||||
verificationCode = ""
|
||||
loginFlowStep = .passwordlessRequest
|
||||
}
|
||||
|
||||
func showRegistration() {
|
||||
loginFlowStep = .registration
|
||||
}
|
||||
|
||||
|
||||
func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
||||
authService.register(username: username, password: password, invite: invite) { [weak self] success, message in
|
||||
@ -263,7 +123,6 @@ class LoginViewModel: ObservableObject {
|
||||
self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
|
||||
self?.loadStoredUser()
|
||||
self?.socketService.connectForCurrentUser()
|
||||
self?.onboardingDestination = .afterRegister
|
||||
} else {
|
||||
self?.socketService.disconnect()
|
||||
}
|
||||
@ -310,121 +169,4 @@ class LoginViewModel: ObservableObject {
|
||||
|
||||
if AppConfig.DEBUG{ print("username: \(username) | userId: \(userId)")}
|
||||
}
|
||||
|
||||
func loadTermsIfNeeded() {
|
||||
guard !isLoadingTerms else { return }
|
||||
|
||||
if !termsContent.isEmpty {
|
||||
termsErrorMessage = nil
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingTerms = true
|
||||
termsErrorMessage = nil
|
||||
|
||||
NetworkClient.shared.request(
|
||||
path: "/legal/terms",
|
||||
headers: ["Accept": "text/plain"],
|
||||
requiresAuth: false,
|
||||
callbackQueue: .main
|
||||
) { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
self.isLoadingTerms = false
|
||||
|
||||
switch result {
|
||||
case .success(let response):
|
||||
if let content = String(data: response.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!content.isEmpty {
|
||||
self.termsContent = content
|
||||
return
|
||||
}
|
||||
|
||||
if let jsonObject = try? JSONSerialization.jsonObject(with: response.data, options: []),
|
||||
let json = jsonObject as? [String: Any],
|
||||
let content = (json["content"] as? String) ?? (json["text"] as? String),
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.termsContent = content
|
||||
} else {
|
||||
self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "")
|
||||
}
|
||||
case .failure:
|
||||
self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadTerms() {
|
||||
termsContent = ""
|
||||
termsErrorMessage = nil
|
||||
loadTermsIfNeeded()
|
||||
}
|
||||
|
||||
private func startResendTimer(duration: Int = Constants.defaultResendDelay) {
|
||||
resendTimer?.invalidate()
|
||||
resendSecondsRemaining = duration
|
||||
|
||||
guard duration > 0 else { return }
|
||||
|
||||
resendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
|
||||
guard let self else {
|
||||
timer.invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
if self.resendSecondsRemaining > 0 {
|
||||
self.resendSecondsRemaining -= 1
|
||||
} else {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginViewModel {
|
||||
var isVerificationCodeComplete: Bool {
|
||||
verificationCode.count == Constants.verificationCodeLength
|
||||
}
|
||||
|
||||
var canRequestPasswordlessCode: Bool {
|
||||
LoginViewModel.isLoginValid(passwordlessLogin) && !isSendingCode
|
||||
}
|
||||
|
||||
var canVerifyPasswordlessCode: Bool {
|
||||
isVerificationCodeComplete && !isVerifyingCode
|
||||
}
|
||||
|
||||
static func isLoginValid(_ login: String) -> Bool {
|
||||
let trimmed = login.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed == login else { return false }
|
||||
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
||||
return trimmed.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension LoginViewModel {
|
||||
func handlePasswordlessRedirect(message: String?, login: String) -> Bool {
|
||||
guard let message else { return false }
|
||||
|
||||
switch message {
|
||||
case "otp_not_found":
|
||||
username = login
|
||||
passwordlessLogin = login
|
||||
loginFlowStep = .password
|
||||
return true
|
||||
case "account_not_found":
|
||||
username = login
|
||||
passwordlessLogin = login
|
||||
hasAcceptedTerms = false
|
||||
loginFlowStep = .registration
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum Constants {
|
||||
static let verificationCodeLength = 6
|
||||
static let defaultResendDelay = 60
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,15 +6,14 @@ final class PrivateChatViewModel: ObservableObject {
|
||||
@Published private(set) var isInitialLoading: Bool = false
|
||||
@Published private(set) var isLoadingMore: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var sendingErrorMessage: String?
|
||||
@Published private(set) var isSending: Bool = false
|
||||
@Published private(set) var hasMore: Bool = true
|
||||
|
||||
private let chatService: ChatService
|
||||
private let chatId: String
|
||||
private let currentUserId: String?
|
||||
private let pageSize: Int
|
||||
let maxMessageLength: Int = 4096
|
||||
private let maxMessageLength: Int = 4096
|
||||
private var hasMore: Bool = true
|
||||
private var didLoadInitially: Bool = false
|
||||
private var messageObserver: NSObjectProtocol?
|
||||
|
||||
@ -74,9 +73,12 @@ final class PrivateChatViewModel: ObservableObject {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard trimmed.count <= maxMessageLength else {
|
||||
errorMessage = NSLocalizedString("Сообщение слишком длинное.", comment: "")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard !isSending else {
|
||||
sendingErrorMessage = NSLocalizedString("Дождитесь отправки предыдущего сообщения.", comment: "")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
@ -86,63 +88,36 @@ final class PrivateChatViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
isSending = true
|
||||
sendingErrorMessage = nil
|
||||
|
||||
let chunks = splitMessage(trimmed, maxLength: maxMessageLength)
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var overallSuccess = true
|
||||
chatService.sendPrivateMessage(chatId: chatId, content: trimmed) { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
for chunk in chunks {
|
||||
dispatchGroup.enter()
|
||||
chatService.sendPrivateMessage(chatId: chatId, content: chunk) { [weak self] result in
|
||||
guard let self else {
|
||||
dispatchGroup.leave()
|
||||
return
|
||||
}
|
||||
switch result {
|
||||
case .success(let data):
|
||||
let newMessage = MessageItem(
|
||||
messageId: data.messageId,
|
||||
messageType: "text",
|
||||
chatId: data.chatId,
|
||||
senderId: currentUserId,
|
||||
senderData: nil,
|
||||
content: trimmed,
|
||||
mediaLink: nil,
|
||||
isViewed: true,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.createdAt,
|
||||
forwardMetadata: nil
|
||||
)
|
||||
|
||||
switch result {
|
||||
case .success(let data):
|
||||
let newMessage = MessageItem(
|
||||
messageId: data.messageId,
|
||||
messageType: "text",
|
||||
chatId: data.chatId,
|
||||
senderId: currentUserId,
|
||||
senderData: nil,
|
||||
content: chunk,
|
||||
mediaLink: nil,
|
||||
isViewed: true,
|
||||
viewedAt: nil,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.createdAt,
|
||||
forwardMetadata: nil
|
||||
)
|
||||
self.messages = Self.merge(existing: self.messages, newMessages: [newMessage])
|
||||
case .failure(let error):
|
||||
self.sendingErrorMessage = self.message(for: error)
|
||||
overallSuccess = false
|
||||
}
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
self.isSending = false
|
||||
if overallSuccess {
|
||||
self.messages = Self.merge(existing: self.messages, newMessages: [newMessage])
|
||||
self.errorMessage = nil
|
||||
completion(true)
|
||||
case .failure(let error):
|
||||
self.errorMessage = self.message(for: error)
|
||||
completion(false)
|
||||
}
|
||||
completion(overallSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
private func splitMessage(_ message: String, maxLength: Int) -> [String] {
|
||||
var chunks: [String] = []
|
||||
var remaining = message
|
||||
while !remaining.isEmpty {
|
||||
let chunk = String(remaining.prefix(maxLength))
|
||||
chunks.append(chunk)
|
||||
remaining = String(remaining.dropFirst(maxLength))
|
||||
self.isSending = false
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
@ -152,21 +127,11 @@ final class PrivateChatViewModel: ObservableObject {
|
||||
|
||||
func loadMoreIfNeeded(for message: MessageItem) {
|
||||
guard didLoadInitially, !isInitialLoading, hasMore, !isLoadingMore else { return }
|
||||
|
||||
guard let messageIndex = messages.firstIndex(where: { $0.id == message.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let threshold = 10
|
||||
guard messageIndex < threshold else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let oldestMessage = messages.first else { return }
|
||||
guard let first = messages.first, first.id == message.id else { return }
|
||||
|
||||
isLoadingMore = true
|
||||
|
||||
chatService.fetchPrivateChatHistory(chatId: chatId, beforeMessageId: oldestMessage.id, limit: pageSize) { [weak self] result in
|
||||
chatService.fetchPrivateChatHistory(chatId: chatId, beforeMessageId: message.id, limit: pageSize) { [weak self] result in
|
||||
guard let self else { return }
|
||||
|
||||
switch result {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -9,124 +9,11 @@ import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
||||
@State private var isShowingMessengerPrompt: Bool = true
|
||||
@State private var pendingMessengerMode: Bool = UserDefaults.standard.bool(forKey: "messengerModeEnabled")
|
||||
@State private var showLegacySupportNotice = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
content
|
||||
.animation(.easeInOut(duration: 0.25), value: viewModel.loginFlowStep)
|
||||
.allowsHitTesting(!isAnyBlockingOverlayPresented)
|
||||
.blur(radius: isAnyBlockingOverlayPresented ? 3 : 0)
|
||||
|
||||
if showLegacySupportNotice {
|
||||
LegacySupportNoticeView(isPresented: $showLegacySupportNotice)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if isShowingMessengerPrompt && !showLegacySupportNotice {
|
||||
Color.black.opacity(0.35)
|
||||
.ignoresSafeArea()
|
||||
.transition(.opacity)
|
||||
|
||||
MessengerModePrompt(
|
||||
selection: $pendingMessengerMode,
|
||||
onAccept: applyMessengerModeSelection,
|
||||
onSkip: dismissMessengerPrompt
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
showModePrompt()
|
||||
showLegacySupportNoticeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
ZStack {
|
||||
switch viewModel.loginFlowStep {
|
||||
case .passwordlessRequest:
|
||||
PasswordlessRequestView(
|
||||
viewModel: viewModel,
|
||||
shouldAutofocus: !isShowingMessengerPrompt,
|
||||
onShowModePrompt: showModePrompt
|
||||
)
|
||||
.transition(unifiedTransition)
|
||||
case .passwordlessVerify:
|
||||
PasswordlessVerifyView(
|
||||
viewModel: viewModel,
|
||||
shouldAutofocus: !isShowingMessengerPrompt,
|
||||
onShowModePrompt: showModePrompt
|
||||
)
|
||||
.transition(unifiedTransition)
|
||||
case .password:
|
||||
PasswordLoginView(viewModel: viewModel, onShowModePrompt: showModePrompt)
|
||||
.transition(unifiedTransition)
|
||||
case .registration:
|
||||
RegistrationView(viewModel: viewModel, onShowModePrompt: showModePrompt)
|
||||
.transition(unifiedTransition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showModePrompt() {
|
||||
pendingMessengerMode = isMessengerModeEnabled
|
||||
withAnimation {
|
||||
isShowingMessengerPrompt = true
|
||||
}
|
||||
}
|
||||
|
||||
private var isAnyBlockingOverlayPresented: Bool {
|
||||
isShowingMessengerPrompt || showLegacySupportNotice
|
||||
}
|
||||
|
||||
private var unifiedTransition: AnyTransition {
|
||||
.opacity.combined(with: .scale(scale: 0.98, anchor: .center))
|
||||
}
|
||||
|
||||
private func showLegacySupportNoticeIfNeeded() {
|
||||
guard shouldShowLegacySupportNotice else { return }
|
||||
withAnimation {
|
||||
showLegacySupportNotice = true
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowLegacySupportNotice: Bool {
|
||||
#if os(iOS)
|
||||
let requiredVersion = OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)
|
||||
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private func applyMessengerModeSelection() {
|
||||
isMessengerModeEnabled = pendingMessengerMode
|
||||
dismissMessengerPrompt()
|
||||
}
|
||||
|
||||
private func dismissMessengerPrompt() {
|
||||
withAnimation {
|
||||
isShowingMessengerPrompt = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PasswordLoginView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
let onShowModePrompt: () -> Void
|
||||
@EnvironmentObject private var themeManager: ThemeManager
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
private let themeOptions = ThemeOption.ordered
|
||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
||||
|
||||
@State private var isShowingTerms = false
|
||||
@State private var hasResetTermsOnAppear = false
|
||||
@State private var isShowingForgotPassword = false
|
||||
@State private var isShowingRegistration = false
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
private enum Field: Hashable {
|
||||
@ -135,162 +22,142 @@ struct PasswordLoginView: View {
|
||||
}
|
||||
|
||||
private var isUsernameValid: Bool {
|
||||
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
|
||||
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
||||
return viewModel.username.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private var isPasswordValid: Bool {
|
||||
return viewModel.password.count >= 8 && viewModel.password.count <= 128
|
||||
}
|
||||
|
||||
private var isLoginButtonEnabled: Bool {
|
||||
// !viewModel.isLoading && isUsernameValid && isPasswordValid && viewModel.hasAcceptedTerms
|
||||
!viewModel.isLoading && isUsernameValid && isPasswordValid
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
||||
|
||||
Button {
|
||||
ZStack {
|
||||
Color.clear // чтобы поймать тап
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
focusedField = nil
|
||||
withAnimation {
|
||||
viewModel.showPasswordlessRequest()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.left")
|
||||
Text(NSLocalizedString("Назад", comment: ""))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(NSLocalizedString("Вход по паролю", comment: ""))
|
||||
.font(.largeTitle).bold()
|
||||
// Text(NSLocalizedString("Если предпочитаете классический вход, используйте логин и пароль.", comment: ""))
|
||||
// .foregroundColor(.secondary)
|
||||
VStack {
|
||||
HStack {
|
||||
|
||||
Button(action: openLanguageSettings) {
|
||||
Text("🌍")
|
||||
.padding()
|
||||
}
|
||||
Spacer()
|
||||
Menu {
|
||||
ForEach(themeOptions) { option in
|
||||
Button(action: { selectTheme(option) }) {
|
||||
themeMenuContent(for: option)
|
||||
.opacity(option.isEnabled ? 1.0 : 0.5)
|
||||
}
|
||||
.disabled(!option.isEnabled)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: themeIconName)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
focusedField = nil
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Text("@")
|
||||
.foregroundColor(.secondary)
|
||||
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .username)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
|
||||
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
|
||||
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
SecureField(NSLocalizedString("Введите пароль", comment: ""), text: $viewModel.password)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .password)
|
||||
.onChange(of: viewModel.password) { newValue in
|
||||
if newValue.count > 32 {
|
||||
viewModel.password = String(newValue.prefix(32))
|
||||
}
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .username)
|
||||
.onChange(of: viewModel.username) { newValue in
|
||||
if newValue.count > 32 {
|
||||
viewModel.username = String(newValue.prefix(32))
|
||||
}
|
||||
|
||||
if !isPasswordValid && !viewModel.password.isEmpty {
|
||||
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
// Показываем ошибку для логина
|
||||
if !isUsernameValid && !viewModel.username.isEmpty {
|
||||
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
// VStack(alignment: .leading, spacing: 4) {
|
||||
// Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
|
||||
// .toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
// Text(isMessengerModeEnabled
|
||||
// ? "Мессенджер-режим сейчас проработан примерно на 60%."
|
||||
// : "Основной режим находится в ранней разработке (около 10%).")
|
||||
// .font(.footnote)
|
||||
// .foregroundColor(.secondary)
|
||||
// }
|
||||
// Показываем поле пароля
|
||||
SecureField(NSLocalizedString("Пароль", comment: ""), text: $viewModel.password)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .password)
|
||||
.onChange(of: viewModel.password) { newValue in
|
||||
if newValue.count > 32 {
|
||||
viewModel.password = String(newValue.prefix(32))
|
||||
}
|
||||
}
|
||||
|
||||
// Показываем ошибку для пароля
|
||||
if !isPasswordValid && !viewModel.password.isEmpty {
|
||||
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
var isButtonEnabled: Bool {
|
||||
!viewModel.isLoading && isUsernameValid && isPasswordValid
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
viewModel.login()
|
||||
}) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.6))
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
Text(NSLocalizedString("Войти", comment: ""))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isButtonEnabled ? Color.blue : Color.gray)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
|
||||
.cornerRadius(12)
|
||||
.disabled(!isLoginButtonEnabled)
|
||||
.disabled(!isButtonEnabled)
|
||||
|
||||
// Spacer()
|
||||
|
||||
// Кнопка регистрации
|
||||
Button(action: {
|
||||
isShowingForgotPassword = true
|
||||
isShowingRegistration = true
|
||||
}) {
|
||||
Text(NSLocalizedString("Забыли пароль? Сбросить", comment: ""))
|
||||
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
.padding(.top, 10)
|
||||
.sheet(isPresented: $isShowingRegistration) {
|
||||
RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.background(Color(.systemBackground).ignoresSafeArea())
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
focusedField = nil
|
||||
}
|
||||
.loginErrorAlert(viewModel: viewModel)
|
||||
.onAppear {
|
||||
if !hasResetTermsOnAppear {
|
||||
viewModel.hasAcceptedTerms = false
|
||||
hasResetTermsOnAppear = true
|
||||
.padding()
|
||||
.alert(isPresented: $viewModel.showError) {
|
||||
Alert(
|
||||
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
|
||||
message: Text(viewModel.errorMessage),
|
||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||
)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $isShowingTerms) {
|
||||
TermsFullScreenView(
|
||||
isPresented: $isShowingTerms,
|
||||
title: NSLocalizedString("Правила сервиса", comment: ""),
|
||||
content: viewModel.termsContent,
|
||||
isLoading: viewModel.isLoadingTerms,
|
||||
errorMessage: viewModel.termsErrorMessage,
|
||||
onRetry: {
|
||||
viewModel.reloadTerms()
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
if viewModel.termsContent.isEmpty {
|
||||
viewModel.loadTermsIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingForgotPassword) {
|
||||
ForgotPasswordInfoView {
|
||||
isShowingForgotPassword = false
|
||||
withAnimation {
|
||||
viewModel.showPasswordlessRequest()
|
||||
}
|
||||
} onDismiss: {
|
||||
isShowingForgotPassword = false
|
||||
.onTapGesture {
|
||||
focusedField = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -305,11 +172,6 @@ struct PasswordLoginView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func hideKeyboardAndShowModePrompt() {
|
||||
focusedField = nil
|
||||
onShowModePrompt()
|
||||
}
|
||||
|
||||
private func openLanguageSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
@ -343,542 +205,10 @@ struct PasswordLoginView: View {
|
||||
|
||||
}
|
||||
|
||||
private struct PasswordlessRequestView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
let shouldAutofocus: Bool
|
||||
let onShowModePrompt: () -> Void
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
|
||||
private var isLoginValid: Bool {
|
||||
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
|
||||
Text(NSLocalizedString("Yobble Passport", comment: ""))
|
||||
.font(.largeTitle).bold()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Text("@")
|
||||
.foregroundColor(.secondary)
|
||||
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
|
||||
.textContentType(.username)
|
||||
.keyboardType(.default)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($isFieldFocused)
|
||||
.onChange(of: viewModel.passwordlessLogin) { newValue in
|
||||
if newValue.count > 32 {
|
||||
viewModel.passwordlessLogin = String(newValue.prefix(32))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
viewModel.requestPasswordlessCode()
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isSendingCode {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
Text(NSLocalizedString("Войти", comment: ""))
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.background(viewModel.canRequestPasswordlessCode ? Color.blue : Color.gray)
|
||||
.cornerRadius(12)
|
||||
.disabled(!viewModel.canRequestPasswordlessCode)
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
viewModel.hasAcceptedTerms = false
|
||||
withAnimation {
|
||||
viewModel.showRegistration()
|
||||
}
|
||||
} label: {
|
||||
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: ""))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.background(Color(.systemBackground).ignoresSafeArea())
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
isFieldFocused = false
|
||||
}
|
||||
.onAppear(perform: scheduleFocusIfNeeded)
|
||||
.onChange(of: shouldAutofocus) { newValue in
|
||||
if newValue {
|
||||
scheduleFocusIfNeeded()
|
||||
} else {
|
||||
isFieldFocused = false
|
||||
}
|
||||
}
|
||||
.loginErrorAlert(viewModel: viewModel)
|
||||
}
|
||||
|
||||
private func hideKeyboardAndShowModePrompt() {
|
||||
isFieldFocused = false
|
||||
onShowModePrompt()
|
||||
}
|
||||
|
||||
private func openLanguageSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func scheduleFocusIfNeeded() {
|
||||
guard shouldAutofocus else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
if shouldAutofocus {
|
||||
isFieldFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LegacySupportNoticeView: View {
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.5)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
isPresented = false
|
||||
}
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 40, weight: .bold))
|
||||
.foregroundColor(.yellow)
|
||||
|
||||
Text("Экспериментальная поддержка iOS 15")
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
isPresented = false
|
||||
} label: {
|
||||
Text("Понятно")
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
.frame(maxWidth: 320)
|
||||
.shadow(radius: 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PasswordlessVerifyView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
let shouldAutofocus: Bool
|
||||
let onShowModePrompt: () -> Void
|
||||
@FocusState private var isCodeFieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
||||
|
||||
Button {
|
||||
// focusedField = nil
|
||||
withAnimation {
|
||||
viewModel.showPasswordlessRequest()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.left")
|
||||
Text(NSLocalizedString("Назад", comment: ""))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(NSLocalizedString("Вход в аккаунт", comment: ""))
|
||||
.font(.largeTitle).bold()
|
||||
Text(String(format: NSLocalizedString("@%@", comment: ""), viewModel.passwordlessLogin))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Text(NSLocalizedString("Введите код", comment: ""))
|
||||
// .font(.largeTitle).bold()
|
||||
//
|
||||
// Text(String(format: NSLocalizedString("Код отправлен. Аккаунт: @%@", comment: ""), viewModel.passwordlessLogin))
|
||||
// .foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
OTPInputView(code: $viewModel.verificationCode, isFocused: $isCodeFieldFocused)
|
||||
|
||||
if viewModel.isVerifyingCode {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text(NSLocalizedString("Проверяем код…", comment: ""))
|
||||
.font(.subheadline)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(NSLocalizedString("Не получили код?", comment: ""))
|
||||
.font(.subheadline)
|
||||
if viewModel.resendSecondsRemaining > 0 {
|
||||
Text(String(format: NSLocalizedString("Попробовать снова можно через %d сек", comment: ""), viewModel.resendSecondsRemaining))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
viewModel.resendPasswordlessCode()
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isSendingCode {
|
||||
ProgressView()
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
Text(NSLocalizedString("Отправить код ещё раз", comment: ""))
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.resendSecondsRemaining > 0 || viewModel.isSendingCode)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Button {
|
||||
// withAnimation {
|
||||
// viewModel.backToPasswordlessRequest()
|
||||
// }
|
||||
// } label: {
|
||||
// Text(NSLocalizedString("Изменить способ входа", comment: ""))
|
||||
// .frame(maxWidth: .infinity)
|
||||
// }
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
viewModel.showPasswordLogin()
|
||||
}
|
||||
} label: {
|
||||
Text(NSLocalizedString("Войти по паролю", comment: ""))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.background(Color(.systemBackground).ignoresSafeArea())
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
isCodeFieldFocused = true
|
||||
}
|
||||
.onAppear(perform: scheduleFocusIfNeeded)
|
||||
.onChange(of: shouldAutofocus) { newValue in
|
||||
if newValue {
|
||||
scheduleFocusIfNeeded()
|
||||
} else {
|
||||
isCodeFieldFocused = false
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
triggerAutoVerificationIfNeeded()
|
||||
}
|
||||
.onChange(of: viewModel.verificationCode) { _ in
|
||||
triggerAutoVerificationIfNeeded()
|
||||
}
|
||||
.loginErrorAlert(viewModel: viewModel)
|
||||
}
|
||||
|
||||
private func hideKeyboardAndShowModePrompt() {
|
||||
isCodeFieldFocused = false
|
||||
onShowModePrompt()
|
||||
}
|
||||
|
||||
private func openLanguageSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func scheduleFocusIfNeeded() {
|
||||
guard shouldAutofocus else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
if shouldAutofocus {
|
||||
isCodeFieldFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerAutoVerificationIfNeeded() {
|
||||
guard viewModel.canVerifyPasswordlessCode else { return }
|
||||
viewModel.verifyPasswordlessCode()
|
||||
}
|
||||
}
|
||||
|
||||
private struct OTPInputView: View {
|
||||
@Binding var code: String
|
||||
var length: Int = 6
|
||||
let isFocused: FocusState<Bool>.Binding
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(0..<length, id: \.self) { index in
|
||||
Text(symbol(at: index))
|
||||
.font(.title2.monospacedDigit())
|
||||
.frame(width: 48, height: 56)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(borderColor(for: index), lineWidth: 1.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextField("", text: textBinding)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.focused(isFocused)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0.01)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
isFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
|
||||
private var textBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { code },
|
||||
set: { newValue in
|
||||
let filtered = newValue.filter { $0.isNumber }
|
||||
let trimmed = String(filtered.prefix(length))
|
||||
|
||||
// избегаем nested updates
|
||||
if code != trimmed {
|
||||
// отключаем анимации и делаем обновление вне view update фазы
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
code = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func symbol(at index: Int) -> String {
|
||||
guard index < code.count else { return "" }
|
||||
let idx = code.index(code.startIndex, offsetBy: index)
|
||||
return String(code[idx])
|
||||
}
|
||||
|
||||
private func borderColor(for index: Int) -> Color {
|
||||
if index == code.count && code.count < length {
|
||||
return .blue
|
||||
}
|
||||
return .gray.opacity(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessengerModePrompt: View {
|
||||
@Binding var selection: Bool
|
||||
let onAccept: () -> Void
|
||||
let onSkip: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text(NSLocalizedString("Какой режим попробовать?", comment: ""))
|
||||
.font(.title3.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(NSLocalizedString("По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент.", comment: ""))
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
optionButton(
|
||||
title: NSLocalizedString("Соцсеть (готово 10%)", comment: ""),
|
||||
subtitle: NSLocalizedString("Лента, истории, подписки", comment: ""),
|
||||
isMessenger: false
|
||||
)
|
||||
|
||||
optionButton(
|
||||
title: NSLocalizedString("Только чаты (готово 60%)", comment: ""),
|
||||
subtitle: NSLocalizedString("Минимум отвлечений, чистый мессенджер", comment: ""),
|
||||
isMessenger: true
|
||||
)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
// Button(action: onSkip) {
|
||||
// Text(NSLocalizedString("Позже", comment: ""))
|
||||
// .font(.callout)
|
||||
// .frame(maxWidth: .infinity)
|
||||
// .padding()
|
||||
// .background(
|
||||
// RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
// .stroke(Color.secondary.opacity(0.3))
|
||||
// )
|
||||
// }
|
||||
|
||||
Button(action: onAccept) {
|
||||
Text(NSLocalizedString("Применить", comment: ""))
|
||||
.font(.callout.bold())
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color.accentColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.2), radius: 30, x: 0, y: 12)
|
||||
}
|
||||
|
||||
private func optionButton(title: String, subtitle: String, isMessenger: Bool) -> some View {
|
||||
Button {
|
||||
selection = isMessenger
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if selection == isMessenger {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(selection == isMessenger ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func loginErrorAlert(viewModel: LoginViewModel) -> some View {
|
||||
alert(isPresented: Binding(
|
||||
get: { viewModel.showError },
|
||||
set: { viewModel.showError = $0 }
|
||||
)) {
|
||||
Alert(
|
||||
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
|
||||
message: Text(viewModel.errorMessage.isEmpty ? NSLocalizedString("Произошла ошибка.", comment: "") : viewModel.errorMessage),
|
||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
preview(step: .passwordlessRequest)
|
||||
preview(step: .passwordlessVerify)
|
||||
preview(step: .password)
|
||||
preview(step: .registration)
|
||||
}
|
||||
.environmentObject(ThemeManager())
|
||||
}
|
||||
|
||||
private static func preview(step: LoginViewModel.LoginFlowStep) -> some View {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.isLoading = false
|
||||
viewModel.isInitialLoading = false
|
||||
viewModel.loginFlowStep = step
|
||||
viewModel.passwordlessLogin = "preview@yobble.app"
|
||||
viewModel.verificationCode = "123456"
|
||||
viewModel.isLoading = false // чтобы убрать спиннер
|
||||
return LoginView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ForgotPasswordInfoView: View {
|
||||
let onUseCode: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(NSLocalizedString("Сброс пароля", comment: ""))
|
||||
.font(.title2.bold())
|
||||
|
||||
Text(NSLocalizedString("Прямого сброса пароля нет: сменить его можно только из настроек, уже будучи в аккаунте. Если привязана почта или другое 2FA-устройство, воспользуйтесь входом по коду - он подтвердит вашу личность и пустит в аккаунт. После входа откройте настройки → безопасность и задайте новый пароль.", comment: ""))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button(action: onUseCode) {
|
||||
Text(NSLocalizedString("Войти", comment: ""))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Text(NSLocalizedString("Закрыть", comment: ""))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,10 @@ import SwiftUI
|
||||
|
||||
struct RegistrationView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
let onShowModePrompt: (() -> Void)?
|
||||
@Binding var isPresented: Bool
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
@State private var confirmPassword: String = ""
|
||||
@State private var inviteCode: String = ""
|
||||
@ -18,7 +20,6 @@ struct RegistrationView: View {
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var showError: Bool = false
|
||||
@State private var errorMessage: String = ""
|
||||
@State private var isShowingTerms: Bool = false
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
@ -31,7 +32,7 @@ struct RegistrationView: View {
|
||||
|
||||
private var isUsernameValid: Bool {
|
||||
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
||||
return viewModel.passwordlessLogin.range(of: pattern, options: .regularExpression) != nil
|
||||
return username.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private var isPasswordValid: Bool {
|
||||
@ -43,170 +44,155 @@ struct RegistrationView: View {
|
||||
}
|
||||
|
||||
private var isFormValid: Bool {
|
||||
isUsernameValid && isPasswordValid && isConfirmPasswordValid && viewModel.hasAcceptedTerms
|
||||
}
|
||||
|
||||
init(viewModel: LoginViewModel, onShowModePrompt: (() -> Void)? = nil) {
|
||||
self._viewModel = ObservedObject(initialValue: viewModel)
|
||||
self.onShowModePrompt = onShowModePrompt
|
||||
isUsernameValid && isPasswordValid && isConfirmPasswordValid
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: keyboardDismissingModePrompt)
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
ZStack(alignment: .top) {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { focusedField = nil }
|
||||
|
||||
Button(action: goBack) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.left")
|
||||
Text(NSLocalizedString("Назад", comment: ""))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(NSLocalizedString("Регистрация", comment: "Регистрация"))
|
||||
.font(.largeTitle).bold()
|
||||
// Text(NSLocalizedString("Создайте логин и пароль. При желании добавьте инвайт.", comment: ""))
|
||||
// .foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Group {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text("@")
|
||||
.foregroundColor(.secondary)
|
||||
TextField(NSLocalizedString("Введите логин", comment: "Логин"), text: $viewModel.passwordlessLogin)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .username)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
|
||||
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
|
||||
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: ""))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
SecureField(NSLocalizedString("Введите пароль", comment: "Пароль"), text: $password)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .password)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Group {
|
||||
HStack {
|
||||
TextField(NSLocalizedString("Логин", comment: "Логин"), text: $username)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .username)
|
||||
Spacer()
|
||||
if !username.isEmpty {
|
||||
Image(systemName: isUsernameValid ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundColor(isUsernameValid ? .green : .red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.onChange(of: username) { newValue in
|
||||
if newValue.count > 32 {
|
||||
username = String(newValue.prefix(32))
|
||||
}
|
||||
}
|
||||
|
||||
if !isUsernameValid && !username.isEmpty {
|
||||
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: "Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
HStack {
|
||||
SecureField(NSLocalizedString("Пароль", comment: "Пароль"), text: $password)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .password)
|
||||
Spacer()
|
||||
if !password.isEmpty {
|
||||
Image(systemName: isPasswordValid ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundColor(isPasswordValid ? .green : .red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.onChange(of: password) { newValue in
|
||||
if newValue.count > 128 {
|
||||
password = String(newValue.prefix(128))
|
||||
}
|
||||
}
|
||||
|
||||
if !isPasswordValid && !password.isEmpty {
|
||||
Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: ""))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if !isPasswordValid && !password.isEmpty {
|
||||
Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: "Пароль должен быть от 6 до 32 символов"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
SecureField(NSLocalizedString("Подтвердите пароль", comment: ""), text: $confirmPassword)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
HStack {
|
||||
SecureField(NSLocalizedString("Подтверждение пароля", comment: "Подтверждение пароля"), text: $confirmPassword)
|
||||
.autocapitalization(.none)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
Spacer()
|
||||
if !confirmPassword.isEmpty {
|
||||
Image(systemName: isConfirmPasswordValid ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundColor(isConfirmPasswordValid ? .green : .red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.onChange(of: confirmPassword) { newValue in
|
||||
if newValue.count > 32 {
|
||||
confirmPassword = String(newValue.prefix(32))
|
||||
}
|
||||
}
|
||||
|
||||
if !isConfirmPasswordValid && !confirmPassword.isEmpty {
|
||||
Text(NSLocalizedString("Пароли не совпадают", comment: ""))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
if !isConfirmPasswordValid && !confirmPassword.isEmpty {
|
||||
Text(NSLocalizedString("Пароли не совпадают", comment: "Пароли не совпадают"))
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: "Инвайт-код"), text: $inviteCode)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(8)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .invite)
|
||||
}
|
||||
}
|
||||
|
||||
TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: ""), text: $inviteCode)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .invite)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
Button(action: registerUser) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.6))
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться"))
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isFormValid ? Color.blue : Color.gray.opacity(0.6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.disabled(!isFormValid)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
TermsAgreementCard(
|
||||
isAccepted: $viewModel.hasAcceptedTerms,
|
||||
openTerms: {
|
||||
viewModel.loadTermsIfNeeded()
|
||||
isShowingTerms = true
|
||||
}
|
||||
)
|
||||
|
||||
Button(action: registerUser) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться"))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.background(isFormValid ? Color.blue : Color.gray.opacity(0.6))
|
||||
.cornerRadius(12)
|
||||
.disabled(!isFormValid)
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.background(Color(.systemBackground).ignoresSafeArea())
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { focusedField = nil }
|
||||
.alert(isPresented: $showError) {
|
||||
Alert(
|
||||
title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $isShowingTerms) {
|
||||
TermsFullScreenView(
|
||||
isPresented: $isShowingTerms,
|
||||
title: NSLocalizedString("Правила сервиса", comment: ""),
|
||||
content: viewModel.termsContent,
|
||||
isLoading: viewModel.isLoadingTerms,
|
||||
errorMessage: viewModel.termsErrorMessage,
|
||||
onRetry: {
|
||||
viewModel.reloadTerms()
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
if viewModel.termsContent.isEmpty {
|
||||
viewModel.loadTermsIfNeeded()
|
||||
.navigationTitle(Text(NSLocalizedString("Регистрация", comment: "Регистрация")))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: dismissSheet) {
|
||||
Text(NSLocalizedString("Закрыть", comment: "Закрыть"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showError) {
|
||||
Alert(
|
||||
title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func registerUser() {
|
||||
isLoading = true
|
||||
errorMessage = ""
|
||||
let trimmedLogin = viewModel.passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
viewModel.passwordlessLogin = trimmedLogin
|
||||
viewModel.registerUser(username: trimmedLogin, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
|
||||
viewModel.registerUser(username: username, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
|
||||
isLoading = false
|
||||
if success {
|
||||
viewModel.hasAcceptedTerms = false
|
||||
dismissSheet()
|
||||
} else {
|
||||
errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "")
|
||||
showError = true
|
||||
@ -214,25 +200,10 @@ struct RegistrationView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func goBack() {
|
||||
private func dismissSheet() {
|
||||
focusedField = nil
|
||||
viewModel.hasAcceptedTerms = false
|
||||
withAnimation {
|
||||
viewModel.showPasswordlessRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private var keyboardDismissingModePrompt: (() -> Void)? {
|
||||
guard let onShowModePrompt else { return nil }
|
||||
return {
|
||||
focusedField = nil
|
||||
onShowModePrompt()
|
||||
}
|
||||
}
|
||||
|
||||
private func openLanguageSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
isPresented = false
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,7 +212,6 @@ struct RegistrationView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.isLoading = false // чтобы убрать спиннер
|
||||
viewModel.isInitialLoading = false
|
||||
return RegistrationView(viewModel: viewModel, onShowModePrompt: nil)
|
||||
return RegistrationView(viewModel: viewModel, isPresented: .constant(true))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: {})
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ struct ChatsTab: View {
|
||||
private let chatService = ChatService()
|
||||
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
|
||||
@StateObject private var viewModel = PrivateChatsViewModel()
|
||||
@State private var selectedChatId: String?
|
||||
@State private var searchDragStartProgress: CGFloat = 0
|
||||
@State private var isSearchGestureActive: Bool = false
|
||||
@State private var globalSearchResults: [UserSearchResult] = []
|
||||
@ -31,7 +32,6 @@ struct ChatsTab: View {
|
||||
@State private var isPendingChatActive: Bool = false
|
||||
|
||||
private let searchRevealDistance: CGFloat = 90
|
||||
private let scrollToTopAnchorId = "ChatsListTopAnchor"
|
||||
|
||||
private var currentUserId: String? {
|
||||
let userId = loginViewModel.userId
|
||||
@ -50,7 +50,6 @@ struct ChatsTab: View {
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(NSLocalizedString("Чаты", comment: "Chats tab title"))
|
||||
.background(Color(UIColor.systemBackground))
|
||||
.onAppear {
|
||||
viewModel.loadInitialChats()
|
||||
@ -102,17 +101,16 @@ struct ChatsTab: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
// if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||||
// loadingState
|
||||
// }
|
||||
chatList
|
||||
|
||||
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||||
loadingState
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
}
|
||||
|
||||
private var chatList: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
List {
|
||||
ZStack {
|
||||
List {
|
||||
// VStack(spacing: 0) {
|
||||
// searchBar
|
||||
// .padding(.horizontal, 16)
|
||||
@ -120,75 +118,63 @@ struct ChatsTab: View {
|
||||
// }
|
||||
// .background(Color(UIColor.systemBackground))
|
||||
|
||||
if let message = viewModel.errorMessage {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text(message)
|
||||
if let message = viewModel.errorMessage {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.orange)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: triggerChatsReload) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.orange)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: triggerChatsReload) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
.font(.subheadline)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
}
|
||||
|
||||
if isSearching {
|
||||
Section(header: localSearchHeader) {
|
||||
if localSearchResults.isEmpty {
|
||||
emptySearchResultView
|
||||
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
ForEach(localSearchResults) { chat in
|
||||
chatRowItem(for: chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isSearching {
|
||||
Section(header: localSearchHeader) {
|
||||
if localSearchResults.isEmpty {
|
||||
emptySearchResultView
|
||||
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
let firstLocalChatId = localSearchResults.first?.chatId
|
||||
ForEach(localSearchResults) { chat in
|
||||
chatRowItem(for: chat)
|
||||
.id(chat.chatId == firstLocalChatId ? scrollToTopAnchorId : chat.chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: globalSearchHeader) {
|
||||
globalSearchContent
|
||||
}
|
||||
} else {
|
||||
Section(header: globalSearchHeader) {
|
||||
globalSearchContent
|
||||
}
|
||||
} else {
|
||||
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
||||
// errorState(message: message)
|
||||
// } else
|
||||
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||||
loadingState
|
||||
if viewModel.chats.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
|
||||
ForEach(viewModel.chats) { chat in
|
||||
chatRowItem(for: chat)
|
||||
}
|
||||
|
||||
if viewModel.chats.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
|
||||
let firstChatId = viewModel.chats.first?.chatId
|
||||
ForEach(viewModel.chats) { chat in
|
||||
chatRowItem(for: chat)
|
||||
.id(chat.chatId == firstChatId ? scrollToTopAnchorId : chat.chatId)
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
loadingMoreRow
|
||||
}
|
||||
if viewModel.isLoadingMore {
|
||||
loadingMoreRow
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.modifier(ScrollDismissesKeyboardModifier())
|
||||
.simultaneousGesture(searchBarGesture)
|
||||
.simultaneousGesture(tapToDismissKeyboardGesture)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldScrollToTop)) { _ in
|
||||
scrollChatsToTop(using: proxy)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.modifier(ScrollDismissesKeyboardModifier())
|
||||
.simultaneousGesture(searchBarGesture)
|
||||
.simultaneousGesture(tapToDismissKeyboardGesture)
|
||||
// .safeAreaInset(edge: .top) {
|
||||
// VStack(spacing: 0) {
|
||||
// searchBar
|
||||
@ -200,8 +186,7 @@ struct ChatsTab: View {
|
||||
// .background(Color(UIColor.systemBackground))
|
||||
// }
|
||||
|
||||
pendingChatNavigationLink
|
||||
}
|
||||
pendingChatNavigationLink
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,14 +217,6 @@ struct ChatsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollChatsToTop(using proxy: ScrollViewProxy) {
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||||
proxy.scrollTo(scrollToTopAnchorId, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var searchBarGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
@ -342,10 +319,8 @@ struct ChatsTab: View {
|
||||
if globalSearchResults.isEmpty {
|
||||
globalSearchEmptyRow
|
||||
} else {
|
||||
let firstGlobalUserId = globalSearchResults.first?.id
|
||||
ForEach(globalSearchResults) { user in
|
||||
globalSearchRow(for: user)
|
||||
.id(user.id == firstGlobalUserId ? AnyHashable(scrollToTopAnchorId) : AnyHashable(user.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -365,26 +340,14 @@ struct ChatsTab: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// private var loadingState: some View {
|
||||
// VStack(spacing: 12) {
|
||||
// ProgressView()
|
||||
// Text(NSLocalizedString("Загружаем чаты…", comment: ""))
|
||||
// .font(.subheadline)
|
||||
// .foregroundColor(.secondary)
|
||||
// }
|
||||
// .frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// }
|
||||
|
||||
private var loadingState: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Spacer()
|
||||
Text(NSLocalizedString("Загружаем чаты…", comment: ""))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
|
||||
.listRowSeparator(.hidden)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func errorState(message: String) -> some View {
|
||||
@ -408,15 +371,15 @@ struct ChatsTab: View {
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Image(systemName: "bubble.left")
|
||||
// .font(.system(size: 48))
|
||||
// .foregroundColor(.secondary)
|
||||
Image(systemName: "bubble.left")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
// Button(action: triggerChatsReload) {
|
||||
// Text(NSLocalizedString("Обновить", comment: ""))
|
||||
// }
|
||||
Button(action: triggerChatsReload) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
@ -446,7 +409,7 @@ struct ChatsTab: View {
|
||||
@ViewBuilder
|
||||
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
|
||||
Button {
|
||||
openChat(chat)
|
||||
selectedChatId = chat.chatId
|
||||
} label: {
|
||||
ChatRowView(
|
||||
chat: chat,
|
||||
@ -469,7 +432,17 @@ struct ChatsTab: View {
|
||||
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
.background(
|
||||
NavigationLink(
|
||||
destination: PrivateChatView(chat: chat, currentUserId: currentUserId),
|
||||
tag: chat.chatId,
|
||||
selection: $selectedChatId
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
// .listRowSeparator(.hidden)
|
||||
.onAppear {
|
||||
guard !isSearching else { return }
|
||||
@ -477,12 +450,6 @@ struct ChatsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func openChat(_ chat: PrivateChatListItem) {
|
||||
pendingChatItem = chat
|
||||
isPendingChatActive = true
|
||||
}
|
||||
|
||||
private var globalSearchLoadingRow: some View {
|
||||
HStack {
|
||||
ProgressView()
|
||||
@ -973,27 +940,24 @@ private struct ChatRowView: View {
|
||||
}
|
||||
|
||||
private var initial: String {
|
||||
let nameSource: String?
|
||||
if let customName = chat.chatData?.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
nameSource = customName
|
||||
} else if let fullName = chat.chatData?.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
nameSource = fullName
|
||||
let sourceName: String
|
||||
|
||||
if let custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
sourceName = custom
|
||||
} else if let displayName = officialDisplayName {
|
||||
sourceName = displayName
|
||||
} else if let full = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !full.isEmpty {
|
||||
sourceName = full
|
||||
} else if let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
|
||||
sourceName = login
|
||||
} else {
|
||||
nameSource = nil
|
||||
sourceName = NSLocalizedString("Неизвестный пользователь", comment: "")
|
||||
}
|
||||
|
||||
if let name = nameSource {
|
||||
let components = name.split(separator: " ")
|
||||
let nameInitials = components.prefix(2).compactMap { $0.first }
|
||||
if !nameInitials.isEmpty {
|
||||
return nameInitials.map { String($0) }.joined().uppercased()
|
||||
}
|
||||
if let character = sourceName.first(where: { !$0.isWhitespace && $0 != "@" }) {
|
||||
return String(character).uppercased()
|
||||
}
|
||||
|
||||
if let login = chat.chatData?.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return String(login.prefix(1)).uppercased()
|
||||
}
|
||||
|
||||
|
||||
return "?"
|
||||
}
|
||||
|
||||
@ -1020,159 +984,112 @@ private struct ChatRowView: View {
|
||||
guard let message = chat.lastMessage else { return .secondary }
|
||||
return message.isViewed == true ? Color.accentColor : Color.secondary
|
||||
}
|
||||
|
||||
private var avatarUrl: URL? {
|
||||
guard let chatData = chat.chatData,
|
||||
let fileId = chatData.avatars?.current?.fileId else {
|
||||
return nil
|
||||
}
|
||||
let userId = chatData.userId
|
||||
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
if let url = avatarUrl, let fileId = chat.chatData?.avatars?.current?.fileId, let loggedInUserId = currentUserId {
|
||||
CachedAvatarView(url: url, fileId: fileId, userId: loggedInUserId) {
|
||||
placeholderAvatar
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
Circle()
|
||||
.fill(avatarBackgroundColor)
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
placeholderAvatar
|
||||
}
|
||||
.overlay(
|
||||
Group {
|
||||
if isDeletedUser {
|
||||
Image(systemName: deletedUserSymbolName)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.font(.system(size: avatarSize * 0.45, weight: .semibold))
|
||||
.foregroundColor(avatarTextColor)
|
||||
} else {
|
||||
Text(initial)
|
||||
.font(.system(size: avatarSize * 0.5, weight: .semibold))
|
||||
.foregroundColor(avatarTextColor)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack{
|
||||
if let officialName = officialDisplayName {
|
||||
HStack(spacing: 6) {
|
||||
if #available(iOS 16.0, *) {
|
||||
Text(officialName)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.strikethrough(isDeletedUser, color: Color.secondary)
|
||||
} else {
|
||||
Text(officialName)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.foregroundColor(Color.accentColor)
|
||||
.font(.caption)
|
||||
}
|
||||
} else {
|
||||
if let officialName = officialDisplayName {
|
||||
HStack(spacing: 6) {
|
||||
if #available(iOS 16.0, *) {
|
||||
Text(title)
|
||||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||||
Text(officialName)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.strikethrough(isDeletedUser, color: Color.secondary)
|
||||
} else {
|
||||
Text(title)
|
||||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||||
Text(officialName)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.foregroundColor(Color.accentColor)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
if let timestamp {
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
if shouldShowReadStatus {
|
||||
Image(systemName: readStatusIconName)
|
||||
.foregroundColor(readStatusColor)
|
||||
.font(.caption2)
|
||||
}
|
||||
|
||||
Text(timestamp)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
// if let login = loginDisplay {
|
||||
// Text(login)
|
||||
// .font(.footnote)
|
||||
// .foregroundColor(.secondary)
|
||||
// .lineLimit(1)
|
||||
// .truncationMode(.tail)
|
||||
// }
|
||||
} else {
|
||||
if #available(iOS 16.0, *) {
|
||||
Text(title)
|
||||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.strikethrough(isDeletedUser, color: Color.secondary)
|
||||
} else {
|
||||
Text(title)
|
||||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(messagePreview)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(subtitleColor)
|
||||
.lineLimit(messageLimitLine)
|
||||
.truncationMode(.tail)
|
||||
|
||||
if chat.unreadCount > 0 {
|
||||
Spacer()
|
||||
Text("\(chat.unreadCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule().fill(Color.accentColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(messagePreview)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(subtitleColor)
|
||||
.lineLimit(messageLimitLine)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Spacer()
|
||||
Spacer()
|
||||
|
||||
// VStack(alignment: .trailing, spacing: 6) {
|
||||
//// if let timestamp {
|
||||
//// HStack(spacing: 4) {
|
||||
//// if shouldShowReadStatus {
|
||||
//// Image(systemName: readStatusIconName)
|
||||
//// .foregroundColor(readStatusColor)
|
||||
//// .font(.caption2)
|
||||
//// }
|
||||
////
|
||||
//// Text(timestamp)
|
||||
//// .font(.caption)
|
||||
//// .foregroundColor(.secondary)
|
||||
//// }
|
||||
//// }
|
||||
//
|
||||
// if chat.unreadCount > 0 {
|
||||
// Text("\(chat.unreadCount)")
|
||||
// .font(.caption2.bold())
|
||||
// .foregroundColor(.white)
|
||||
// .padding(.horizontal, 8)
|
||||
// .padding(.vertical, 4)
|
||||
// .background(
|
||||
// Capsule().fill(Color.accentColor)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var placeholderAvatar: some View {
|
||||
Circle()
|
||||
.fill(avatarBackgroundColor)
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.overlay(
|
||||
Group {
|
||||
if isDeletedUser {
|
||||
Image(systemName: deletedUserSymbolName)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.font(.system(size: avatarSize * 0.45, weight: .semibold))
|
||||
.foregroundColor(avatarTextColor)
|
||||
} else {
|
||||
Text(initial)
|
||||
.font(.system(size: avatarSize * 0.5, weight: .semibold))
|
||||
.foregroundColor(avatarTextColor)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if let timestamp {
|
||||
HStack(spacing: 4) {
|
||||
if shouldShowReadStatus {
|
||||
Image(systemName: readStatusIconName)
|
||||
.foregroundColor(readStatusColor)
|
||||
.font(.caption2)
|
||||
}
|
||||
|
||||
Text(timestamp)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if chat.unreadCount > 0 {
|
||||
Text("\(chat.unreadCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule().fill(Color.accentColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private static func formattedTimestamp(for date: Date) -> String {
|
||||
@ -1264,5 +1181,4 @@ extension Notification.Name {
|
||||
static let debugRefreshChats = Notification.Name("debugRefreshChats")
|
||||
static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh")
|
||||
static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted")
|
||||
static let chatsShouldScrollToTop = Notification.Name("chatsShouldScrollToTop")
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -2,48 +2,37 @@ import SwiftUI
|
||||
|
||||
struct CustomTabBar: View {
|
||||
@Binding var selectedTab: Int
|
||||
let isMessengerModeEnabled: Bool
|
||||
var onCreate: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if isMessengerModeEnabled {
|
||||
// Tab 1: Feed
|
||||
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
|
||||
TabBarButton(systemName: "person.2.fill", text: NSLocalizedString("Контакты", comment: ""), isSelected: selectedTab == 4) {
|
||||
selectedTab = 4
|
||||
}
|
||||
// Tab 2: Search
|
||||
TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) {
|
||||
selectedTab = 1
|
||||
}
|
||||
|
||||
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
|
||||
handleChatsTabTap()
|
||||
}
|
||||
// Create Button
|
||||
CreateButton {
|
||||
onCreate()
|
||||
}
|
||||
|
||||
TabBarButton(systemName: "gearshape.fill", text: NSLocalizedString("Настройки", comment: ""), isSelected: selectedTab == 5) {
|
||||
selectedTab = 5
|
||||
}
|
||||
} else {
|
||||
TabBarButton(systemName: "list.bullet.rectangle", text: NSLocalizedString("Лента", comment: ""), isSelected: selectedTab == 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
// Tab 3: Chats
|
||||
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
|
||||
selectedTab = 2
|
||||
}
|
||||
|
||||
TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) {
|
||||
selectedTab = 1
|
||||
}
|
||||
|
||||
CreateButton {
|
||||
onCreate()
|
||||
}
|
||||
|
||||
TabBarButton(systemName: "bubble.left.and.bubble.right.fill", text: NSLocalizedString("Чаты", comment: ""), isSelected: selectedTab == 2) {
|
||||
handleChatsTabTap()
|
||||
}
|
||||
|
||||
TabBarButton(systemName: "person.crop.square", text: NSLocalizedString("Лицо", comment: ""), isSelected: selectedTab == 3) {
|
||||
selectedTab = 3
|
||||
}
|
||||
// Tab 4: Profile
|
||||
TabBarButton(systemName: "person.crop.square", text: NSLocalizedString("Лицо", comment: ""), isSelected: selectedTab == 3) {
|
||||
selectedTab = 3
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, isMessengerModeEnabled ? 6 : 1)
|
||||
.padding(.top, 1)
|
||||
.padding(.bottom, 30) // Добавляем отступ снизу
|
||||
// .background(Color(.systemGray6))
|
||||
}
|
||||
@ -93,13 +82,3 @@ struct CreateButton: View {
|
||||
.offset(y: -3)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CustomTabBar {
|
||||
func handleChatsTabTap() {
|
||||
if selectedTab == 2 {
|
||||
NotificationCenter.default.post(name: .chatsShouldScrollToTop, object: nil)
|
||||
} else {
|
||||
selectedTab = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,7 @@ import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@EnvironmentObject private var messageCenter: IncomingMessageCenter
|
||||
@State private var selectedTab: Int = 0
|
||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
||||
// @StateObject private var newHomeTabViewModel = NewHomeTabViewModel()
|
||||
|
||||
// Состояния для TopBarView
|
||||
@ -18,21 +16,14 @@ struct MainView: View {
|
||||
@State private var chatSearchRevealProgress: CGFloat = 0
|
||||
@State private var chatSearchText: String = ""
|
||||
@State private var isSettingsPresented = false
|
||||
@State private var isQrPresented = false
|
||||
@State private var deepLinkChatItem: PrivateChatListItem?
|
||||
@State private var isDeepLinkChatActive = false
|
||||
@State private var hasTriggeredSecuritySettingsOnboarding = false
|
||||
@State private var isAfterRegisterPresented = false
|
||||
|
||||
private var tabTitle: String {
|
||||
switch selectedTab {
|
||||
case 0: return NSLocalizedString("Home", comment: "")
|
||||
case 1: return NSLocalizedString("Concept", comment: "")
|
||||
case 2: return NSLocalizedString("Чаты", comment: "")
|
||||
case 3: return NSLocalizedString("Profile", comment: "")
|
||||
case 4: return NSLocalizedString("Контакты", comment: "")
|
||||
case 5: return NSLocalizedString("Настройки", comment: "")
|
||||
default: return NSLocalizedString("Home", comment: "")
|
||||
case 0: return "Home"
|
||||
case 1: return "Concept"
|
||||
case 2: return "Chats"
|
||||
case 3: return "Profile"
|
||||
default: return "Home"
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,54 +39,36 @@ struct MainView: View {
|
||||
VStack(spacing: 0) {
|
||||
TopBarView(
|
||||
title: tabTitle,
|
||||
isMessengerModeEnabled: isMessengerModeEnabled,
|
||||
selectedAccount: $selectedAccount,
|
||||
accounts: accounts,
|
||||
viewModel: viewModel,
|
||||
isSettingsPresented: $isSettingsPresented,
|
||||
isQrPresented: $isQrPresented,
|
||||
isSideMenuPresented: $isSideMenuPresented,
|
||||
chatSearchRevealProgress: $chatSearchRevealProgress,
|
||||
chatSearchText: $chatSearchText
|
||||
)
|
||||
|
||||
ZStack {
|
||||
if isMessengerModeEnabled {
|
||||
ChatsTab(
|
||||
loginViewModel: viewModel,
|
||||
searchRevealProgress: $chatSearchRevealProgress,
|
||||
searchText: $chatSearchText
|
||||
)
|
||||
NewHomeTab()
|
||||
.opacity(selectedTab == 0 ? 1 : 0)
|
||||
|
||||
ConceptTab()
|
||||
.opacity(selectedTab == 1 ? 1 : 0)
|
||||
|
||||
ChatsTab(
|
||||
loginViewModel: viewModel,
|
||||
searchRevealProgress: $chatSearchRevealProgress,
|
||||
searchText: $chatSearchText
|
||||
)
|
||||
.opacity(selectedTab == 2 ? 1 : 0)
|
||||
.allowsHitTesting(selectedTab == 2)
|
||||
|
||||
ContactsTab(viewModel: viewModel)
|
||||
.opacity(selectedTab == 4 ? 1 : 0)
|
||||
|
||||
SettingsView(viewModel: viewModel)
|
||||
.opacity(selectedTab == 5 ? 1 : 0)
|
||||
} else {
|
||||
NewHomeTab()
|
||||
.opacity(selectedTab == 0 ? 1 : 0)
|
||||
|
||||
ConceptTab()
|
||||
.opacity(selectedTab == 1 ? 1 : 0)
|
||||
|
||||
ChatsTab(
|
||||
loginViewModel: viewModel,
|
||||
searchRevealProgress: $chatSearchRevealProgress,
|
||||
searchText: $chatSearchText
|
||||
)
|
||||
.opacity(selectedTab == 2 ? 1 : 0)
|
||||
.allowsHitTesting(selectedTab == 2)
|
||||
|
||||
ProfileTab()
|
||||
.opacity(selectedTab == 3 ? 1 : 0)
|
||||
}
|
||||
|
||||
ProfileTab()
|
||||
.opacity(selectedTab == 3 ? 1 : 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
CustomTabBar(selectedTab: $selectedTab, isMessengerModeEnabled: isMessengerModeEnabled) {
|
||||
CustomTabBar(selectedTab: $selectedTab) {
|
||||
print("Create button tapped")
|
||||
}
|
||||
}
|
||||
@ -118,49 +91,41 @@ struct MainView: View {
|
||||
.allowsHitTesting(menuOffset > 0)
|
||||
|
||||
// Боковое меню
|
||||
if !isMessengerModeEnabled {
|
||||
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
|
||||
.frame(width: menuWidth)
|
||||
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
||||
.ignoresSafeArea(edges: .vertical)
|
||||
}
|
||||
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
|
||||
.frame(width: menuWidth)
|
||||
.offset(x: -menuWidth + menuOffset) // Новая логика смещения
|
||||
.ignoresSafeArea(edges: .vertical)
|
||||
}
|
||||
|
||||
deepLinkNavigationLink
|
||||
}
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { gesture in
|
||||
if !isMessengerModeEnabled {
|
||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||
|
||||
let translation = gesture.translation.width
|
||||
|
||||
// Определяем базовое смещение в зависимости от того, открыто меню или нет
|
||||
let baseOffset = isSideMenuPresented ? menuWidth : 0
|
||||
|
||||
// Новое смещение — это база плюс текущий свайп
|
||||
let newOffset = baseOffset + translation
|
||||
|
||||
// Жестко ограничиваем итоговое смещение между 0 и шириной меню
|
||||
self.menuOffset = max(0, min(menuWidth, newOffset))
|
||||
}
|
||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||
|
||||
let translation = gesture.translation.width
|
||||
|
||||
// Определяем базовое смещение в зависимости от того, открыто меню или нет
|
||||
let baseOffset = isSideMenuPresented ? menuWidth : 0
|
||||
|
||||
// Новое смещение — это база плюс текущий свайп
|
||||
let newOffset = baseOffset + translation
|
||||
|
||||
// Жестко ограничиваем итоговое смещение между 0 и шириной меню
|
||||
self.menuOffset = max(0, min(menuWidth, newOffset))
|
||||
}
|
||||
.onEnded { gesture in
|
||||
if !isMessengerModeEnabled {
|
||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||
|
||||
let threshold = menuWidth * 0.4
|
||||
|
||||
withAnimation(.easeInOut) {
|
||||
if self.menuOffset > threshold {
|
||||
isSideMenuPresented = true
|
||||
} else {
|
||||
isSideMenuPresented = false
|
||||
}
|
||||
// Устанавливаем финальное смещение после анимации
|
||||
self.menuOffset = isSideMenuPresented ? menuWidth : 0
|
||||
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
|
||||
|
||||
let threshold = menuWidth * 0.4
|
||||
|
||||
withAnimation(.easeInOut) {
|
||||
if self.menuOffset > threshold {
|
||||
isSideMenuPresented = true
|
||||
} else {
|
||||
isSideMenuPresented = false
|
||||
}
|
||||
// Устанавливаем финальное смещение после анимации
|
||||
self.menuOffset = isSideMenuPresented ? menuWidth : 0
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -172,100 +137,11 @@ struct MainView: View {
|
||||
menuOffset = presented ? menuWidth : 0
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
enforceTabSelectionForMessengerMode()
|
||||
handleAfterRegisterOnboardingIfNeeded()
|
||||
}
|
||||
.onChange(of: isMessengerModeEnabled) { _ in
|
||||
enforceTabSelectionForMessengerMode()
|
||||
handleAfterRegisterOnboardingIfNeeded()
|
||||
}
|
||||
.onChange(of: viewModel.onboardingDestination) { _ in
|
||||
handleAfterRegisterOnboardingIfNeeded()
|
||||
}
|
||||
.onChange(of: messageCenter.pendingNavigation?.id) { _ in
|
||||
guard !AppConfig.PRESENT_CHAT_AS_SHEET,
|
||||
let target = messageCenter.pendingNavigation else { return }
|
||||
withAnimation(.easeInOut) {
|
||||
isSideMenuPresented = false
|
||||
menuOffset = 0
|
||||
}
|
||||
if !chatSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
chatSearchText = ""
|
||||
}
|
||||
if chatSearchRevealProgress > 0 {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||||
chatSearchRevealProgress = 0
|
||||
}
|
||||
}
|
||||
deepLinkChatItem = target.chat
|
||||
isDeepLinkChatActive = true
|
||||
NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil)
|
||||
DispatchQueue.main.async {
|
||||
messageCenter.pendingNavigation = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedTab) { newValue in
|
||||
if newValue != 3 {
|
||||
isSettingsPresented = false
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $isAfterRegisterPresented) {
|
||||
AfterRegisterView(isPresented: $isAfterRegisterPresented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension MainView {
|
||||
func enforceTabSelectionForMessengerMode() {
|
||||
if isMessengerModeEnabled {
|
||||
if selectedTab < 2 {
|
||||
selectedTab = 2
|
||||
}
|
||||
} else if selectedTab > 3 {
|
||||
selectedTab = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func handleAfterRegisterOnboardingIfNeeded() {
|
||||
guard viewModel.onboardingDestination == .afterRegister else {
|
||||
return
|
||||
}
|
||||
|
||||
isAfterRegisterPresented = true
|
||||
viewModel.onboardingDestination = nil
|
||||
}
|
||||
|
||||
var deepLinkNavigationLink: some View {
|
||||
NavigationLink(
|
||||
destination: deepLinkChatDestination,
|
||||
isActive: Binding(
|
||||
get: { isDeepLinkChatActive && deepLinkChatItem != nil },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
isDeepLinkChatActive = false
|
||||
deepLinkChatItem = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var deepLinkChatDestination: some View {
|
||||
if let chatItem = deepLinkChatItem {
|
||||
PrivateChatView(
|
||||
chat: chatItem,
|
||||
currentUserId: messageCenter.currentUserId
|
||||
)
|
||||
.id(chatItem.chatId)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -273,7 +149,6 @@ struct MainView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let mockViewModel = LoginViewModel()
|
||||
MainView(viewModel: mockViewModel)
|
||||
.environmentObject(IncomingMessageCenter())
|
||||
.environmentObject(ThemeManager())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QrView: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
||||
}
|
||||
.navigationTitle("Qr")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -70,27 +70,35 @@ struct EditPrivacyView: View {
|
||||
Toggle(NSLocalizedString("Показывать био не-контактам", comment: ""), isOn: $profilePermissions.showBioToNonContacts)
|
||||
Toggle(NSLocalizedString("Показывать сторисы не-контактам", comment: ""), isOn: $profilePermissions.showStoriesToNonContacts)
|
||||
|
||||
privacyScopePicker(
|
||||
title: NSLocalizedString("Видимость статуса 'был в сети'", comment: ""),
|
||||
selection: $profilePermissions.lastSeenVisibility
|
||||
)
|
||||
Picker(NSLocalizedString("Видимость статуса 'был в сети'", comment: ""), selection: $profilePermissions.lastSeenVisibility) {
|
||||
ForEach(privacyScopeOptions) { scope in
|
||||
Text(scope.title).tag(scope.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section(header: Text(NSLocalizedString("Приглашения и звонки", comment: ""))) {
|
||||
privacyScopePicker(
|
||||
title: NSLocalizedString("Кто может приглашать в паблики", comment: ""),
|
||||
selection: $profilePermissions.publicInvitePermission
|
||||
)
|
||||
|
||||
privacyScopePicker(
|
||||
title: NSLocalizedString("Кто может приглашать в беседы", comment: ""),
|
||||
selection: $profilePermissions.groupInvitePermission
|
||||
)
|
||||
|
||||
privacyScopePicker(
|
||||
title: NSLocalizedString("Кто может звонить", comment: ""),
|
||||
selection: $profilePermissions.callPermission
|
||||
)
|
||||
Picker(NSLocalizedString("Кто может приглашать в паблики", comment: ""), selection: $profilePermissions.publicInvitePermission) {
|
||||
ForEach(privacyScopeOptions) { scope in
|
||||
Text(scope.title).tag(scope.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Picker(NSLocalizedString("Кто может приглашать в беседы", comment: ""), selection: $profilePermissions.groupInvitePermission) {
|
||||
ForEach(privacyScopeOptions) { scope in
|
||||
Text(scope.title).tag(scope.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Picker(NSLocalizedString("Кто может звонить", comment: ""), selection: $profilePermissions.callPermission) {
|
||||
ForEach(privacyScopeOptions) { scope in
|
||||
Text(scope.title).tag(scope.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section(header: Text(NSLocalizedString("Чаты и хранение", comment: ""))) {
|
||||
@ -184,24 +192,6 @@ struct EditPrivacyView: View {
|
||||
return "\(secondsString) (≈ \(formattedHours) ч.)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func privacyScopePicker(title: String, selection: Binding<Int>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Picker("", selection: selection) {
|
||||
ForEach(privacyScopeOptions) { scope in
|
||||
Text(scope.title).tag(scope.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private enum PrivacyScope: Int, CaseIterable, Identifiable {
|
||||
@ -1,729 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EditProfileView: View {
|
||||
// State for form fields
|
||||
@State private var displayName = ""
|
||||
@State private var description = ""
|
||||
@State private var originalDisplayName = ""
|
||||
@State private var originalDescription = ""
|
||||
|
||||
// State for profile data and avatar
|
||||
@State private var profile: ProfileDataPayload?
|
||||
@State private var avatarImage: UIImage?
|
||||
@State private var showImagePicker = false
|
||||
|
||||
// State for loading and errors
|
||||
@State private var isLoading = false
|
||||
@State private var isSaving = false
|
||||
@State private var isUploadingAvatar = false
|
||||
@State private var isPreparingDownload = false
|
||||
@State private var alertMessage: String?
|
||||
@State private var showAlert = false
|
||||
@State private var avatarViewerState: AvatarViewerState?
|
||||
@State private var shareItems: [Any] = []
|
||||
@State private var showShareSheet = false
|
||||
|
||||
private let profileService = ProfileService()
|
||||
private let descriptionLimit = 1024
|
||||
private let nameLimit = 32
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
if let image = avatarImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 120, height: 120)
|
||||
.clipShape(Circle())
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
presentAvatarViewer()
|
||||
}
|
||||
} else if let profile = profile,
|
||||
let fileId = profile.avatars?.current?.fileId,
|
||||
let url = avatarUrl(for: profile, fileId: fileId) {
|
||||
CachedAvatarView(url: url, fileId: fileId, userId: profile.userId.uuidString) {
|
||||
avatarPlaceholder
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 120, height: 120)
|
||||
.clipShape(Circle())
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
presentAvatarViewer()
|
||||
}
|
||||
} else {
|
||||
avatarPlaceholder
|
||||
.onTapGesture {
|
||||
presentAvatarViewer()
|
||||
}
|
||||
}
|
||||
|
||||
Button("Изменить фото") {
|
||||
showImagePicker = true
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color(UIColor.systemGroupedBackground))
|
||||
|
||||
Section(header: Text("Публичная информация")) {
|
||||
TextField("Отображаемое имя", text: $displayName)
|
||||
.onChange(of: displayName) { newValue in
|
||||
if newValue.count > nameLimit {
|
||||
displayName = String(newValue.prefix(nameLimit))
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Описание")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
TextEditor(text: $description)
|
||||
.frame(height: 150)
|
||||
.onChange(of: description) { newValue in
|
||||
if newValue.count > descriptionLimit {
|
||||
description = String(newValue.prefix(descriptionLimit))
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("\(description.count) / \(descriptionLimit)")
|
||||
.font(.caption)
|
||||
.foregroundColor(description.count > descriptionLimit ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await applyProfileChanges()
|
||||
}
|
||||
}) {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
} else {
|
||||
Text("Применить")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
.disabled(!hasProfileChanges || isBusy)
|
||||
}
|
||||
.navigationTitle("Профиль")
|
||||
.onAppear(perform: loadProfile)
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
ImagePicker(image: $avatarImage, allowsEditing: true)
|
||||
}
|
||||
.onChange(of: avatarImage) { newValue in
|
||||
guard let image = newValue else { return }
|
||||
Task {
|
||||
await uploadAvatarImage(image)
|
||||
}
|
||||
}
|
||||
.alert("Ошибка", isPresented: $showAlert, presenting: alertMessage) { _ in
|
||||
Button("OK") {}
|
||||
} message: { message in
|
||||
Text(message)
|
||||
}
|
||||
.fullScreenCover(item: $avatarViewerState) { state in
|
||||
AvatarViewerView(
|
||||
state: state,
|
||||
onClose: { avatarViewerState = nil },
|
||||
onDownload: { handleAvatarDownload(for: state) },
|
||||
onDelete: handleAvatarDeletion
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
ActivityView(activityItems: shareItems)
|
||||
}
|
||||
|
||||
if isBusy {
|
||||
Color.black.opacity(0.4).ignoresSafeArea()
|
||||
ProgressView(busyMessage)
|
||||
.padding()
|
||||
.background(Color.secondary.colorInvert())
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarPlaceholder: some View {
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.frame(width: 120, height: 120)
|
||||
.overlay(
|
||||
Text(profileInitials)
|
||||
.font(.system(size: 48, weight: .semibold))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
}
|
||||
|
||||
private var profileInitials: String {
|
||||
if let initials = initials(from: displayName) {
|
||||
return initials
|
||||
}
|
||||
if let profile = profile,
|
||||
let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty,
|
||||
let initials = initials(from: name) {
|
||||
return initials
|
||||
}
|
||||
if let username = profile?.login.trimmingCharacters(in: .whitespacesAndNewlines), !username.isEmpty {
|
||||
return String(username.prefix(1)).uppercased()
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
private func avatarUrl(for profile: ProfileDataPayload, fileId: String) -> URL? {
|
||||
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(profile.userId)?file_id=\(fileId)")
|
||||
}
|
||||
|
||||
private func loadProfile() {
|
||||
isLoading = true
|
||||
Task {
|
||||
do {
|
||||
let profile = try await profileService.fetchMyProfile()
|
||||
await MainActor.run {
|
||||
self.updateForm(with: profile)
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.alertMessage = error.localizedDescription
|
||||
self.showAlert = true
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasProfileChanges: Bool {
|
||||
displayName != originalDisplayName || description != originalDescription
|
||||
}
|
||||
|
||||
private var isBusy: Bool {
|
||||
isLoading || isSaving || isUploadingAvatar || isPreparingDownload
|
||||
}
|
||||
|
||||
private var busyMessage: String {
|
||||
if isUploadingAvatar {
|
||||
return "Обновление аватара..."
|
||||
}
|
||||
if isPreparingDownload {
|
||||
return "Подготовка изображения..."
|
||||
}
|
||||
if isSaving {
|
||||
return "Сохранение..."
|
||||
}
|
||||
return "Загрузка..."
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyProfileChanges() async {
|
||||
guard !isSaving else { return }
|
||||
guard let currentProfile = profile else {
|
||||
alertMessage = NSLocalizedString("Профиль пока не загружен. Попробуйте позже.", comment: "Profile not ready error")
|
||||
showAlert = true
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
|
||||
let request = ProfileUpdateRequestPayload(
|
||||
fullName: displayName,
|
||||
bio: description,
|
||||
profilePermissions: ProfilePermissionsRequestPayload(payload: currentProfile.profilePermissions)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await profileService.updateProfile(request)
|
||||
let refreshedProfile = try await profileService.fetchMyProfile()
|
||||
updateForm(with: refreshedProfile)
|
||||
} catch {
|
||||
let message: String
|
||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||
message = description
|
||||
} else {
|
||||
message = error.localizedDescription
|
||||
}
|
||||
alertMessage = message
|
||||
showAlert = true
|
||||
}
|
||||
|
||||
isSaving = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func uploadAvatarImage(_ image: UIImage) async {
|
||||
guard !isUploadingAvatar else { return }
|
||||
isUploadingAvatar = true
|
||||
defer { isUploadingAvatar = false }
|
||||
|
||||
do {
|
||||
_ = try await profileService.uploadAvatar(image: image)
|
||||
let refreshedProfile = try await profileService.fetchMyProfile()
|
||||
updateFormPreservingFields(profile: refreshedProfile)
|
||||
avatarImage = nil
|
||||
} catch {
|
||||
let message: String
|
||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||
message = description
|
||||
} else {
|
||||
message = error.localizedDescription
|
||||
}
|
||||
alertMessage = message
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateForm(with profile: ProfileDataPayload) {
|
||||
self.profile = profile
|
||||
applyProfileTexts(from: profile)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateFormPreservingFields(profile: ProfileDataPayload) {
|
||||
self.profile = profile
|
||||
if !hasProfileChanges {
|
||||
applyProfileTexts(from: profile)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyProfileTexts(from profile: ProfileDataPayload) {
|
||||
let loadedName = profile.fullName ?? ""
|
||||
let loadedBio = profile.bio ?? ""
|
||||
self.displayName = loadedName
|
||||
self.description = loadedBio
|
||||
self.originalDisplayName = loadedName
|
||||
self.originalDescription = loadedBio
|
||||
}
|
||||
|
||||
private func presentAvatarViewer() {
|
||||
if let image = avatarImage {
|
||||
avatarViewerState = AvatarViewerState(
|
||||
source: .local(image),
|
||||
intrinsicSize: image.size
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let profile,
|
||||
let fileId = profile.avatars?.current?.fileId,
|
||||
let url = avatarUrl(for: profile, fileId: fileId) else { return }
|
||||
let intrinsicSize: CGSize?
|
||||
if let width = profile.avatars?.current?.width,
|
||||
let height = profile.avatars?.current?.height,
|
||||
width > 0,
|
||||
height > 0 {
|
||||
intrinsicSize = CGSize(width: CGFloat(width), height: CGFloat(height))
|
||||
} else {
|
||||
intrinsicSize = nil
|
||||
}
|
||||
avatarViewerState = AvatarViewerState(
|
||||
source: .remote(url: url, fileId: fileId, userId: profile.userId.uuidString),
|
||||
intrinsicSize: intrinsicSize
|
||||
)
|
||||
}
|
||||
|
||||
private func handleAvatarDownload(for state: AvatarViewerState) {
|
||||
guard !isPreparingDownload else { return }
|
||||
isPreparingDownload = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
let image = try await resolveImage(for: state)
|
||||
await MainActor.run {
|
||||
shareItems = [image]
|
||||
showShareSheet = true
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alertMessage = error.localizedDescription
|
||||
showAlert = true
|
||||
}
|
||||
Form {
|
||||
Section(header: Text("Публичная информация")) {
|
||||
TextField("Отображаемое имя", text: $displayName)
|
||||
TextField("Описание", text: $description)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
isPreparingDownload = false
|
||||
Button(action: {
|
||||
// Действие для сохранения профиля
|
||||
print("DisplayName: \(displayName)")
|
||||
print("Description: \(description)")
|
||||
}) {
|
||||
Text("Применить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAvatarDeletion() {
|
||||
alertMessage = NSLocalizedString("Удаление аватара пока недоступно.", comment: "Avatar delete placeholder")
|
||||
showAlert = true
|
||||
}
|
||||
|
||||
private func resolveImage(for state: AvatarViewerState) async throws -> UIImage {
|
||||
switch state.source {
|
||||
case .local(let image):
|
||||
return image
|
||||
case .remote(let url, let fileId, let userId):
|
||||
if let cached = AvatarCacheService.shared.getImage(forKey: fileId, userId: userId) {
|
||||
return cached
|
||||
}
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
guard let image = UIImage(data: data) else {
|
||||
throw AvatarViewerError.imageDecodingFailed
|
||||
}
|
||||
AvatarCacheService.shared.saveImage(image, forKey: fileId, userId: userId)
|
||||
return image
|
||||
}
|
||||
.navigationTitle("Редактировать профиль")
|
||||
}
|
||||
}
|
||||
|
||||
private func initials(from text: String) -> String? {
|
||||
let components = text
|
||||
.split { $0.isWhitespace }
|
||||
.filter { !$0.isEmpty }
|
||||
let letters = components.prefix(2).compactMap { $0.first }
|
||||
guard !letters.isEmpty else { return nil }
|
||||
return letters.map { String($0).uppercased() }.joined()
|
||||
}
|
||||
|
||||
struct ImagePicker: UIViewControllerRepresentable {
|
||||
@Binding var image: UIImage?
|
||||
var allowsEditing: Bool = false
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
picker.allowsEditing = allowsEditing
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
||||
let parent: ImagePicker
|
||||
|
||||
init(_ parent: ImagePicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
if let editedImage = info[.editedImage] as? UIImage {
|
||||
parent.image = editedImage
|
||||
} else if let uiImage = info[.originalImage] as? UIImage {
|
||||
parent.image = uiImage
|
||||
}
|
||||
parent.presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AvatarViewerState: Identifiable {
|
||||
enum Source {
|
||||
case local(UIImage)
|
||||
case remote(url: URL, fileId: String, userId: String)
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let source: Source
|
||||
let intrinsicSize: CGSize?
|
||||
}
|
||||
|
||||
enum AvatarViewerError: LocalizedError {
|
||||
case imageDecodingFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .imageDecodingFailed:
|
||||
return NSLocalizedString("Не удалось подготовить изображение.", comment: "Avatar decoding error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AvatarViewerView: View {
|
||||
let state: AvatarViewerState
|
||||
let onClose: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var baseScale: CGFloat = 1.0
|
||||
@State private var panOffset: CGSize = .zero
|
||||
@State private var storedPanOffset: CGSize = .zero
|
||||
@State private var dismissOffset: CGSize = .zero
|
||||
@State private var dragMode: DragMode?
|
||||
@State private var containerSize: CGSize = .zero
|
||||
@State private var loadedImageSize: CGSize?
|
||||
|
||||
private enum DragMode {
|
||||
case vertical
|
||||
case horizontal
|
||||
}
|
||||
|
||||
private var currentOffset: CGSize {
|
||||
scale > 1.05 ? panOffset : dismissOffset
|
||||
}
|
||||
|
||||
private var dragProgress: CGFloat {
|
||||
guard scale <= 1.05 else { return 0 }
|
||||
let progress = min(1, abs(dismissOffset.height) / 220)
|
||||
return progress
|
||||
}
|
||||
|
||||
private var backgroundOpacity: Double {
|
||||
Double(1 - dragProgress * 0.6)
|
||||
}
|
||||
|
||||
private var overlayOpacity: Double {
|
||||
Double(1 - dragProgress * 0.8)
|
||||
}
|
||||
|
||||
private var effectiveImageSize: CGSize? {
|
||||
loadedImageSize ?? state.intrinsicSize
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(backgroundOpacity).ignoresSafeArea()
|
||||
|
||||
zoomableContent
|
||||
|
||||
topOverlay
|
||||
.opacity(overlayOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
private var topOverlay: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.imageScale(.large)
|
||||
}
|
||||
.tint(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("1 из 1")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu {
|
||||
Button(action: onDownload) {
|
||||
Label(NSLocalizedString("Скачать", comment: "Avatar download"), systemImage: "square.and.arrow.down")
|
||||
}
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Label(NSLocalizedString("Удалить фото", comment: "Avatar delete"), systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.imageScale(.large)
|
||||
}
|
||||
.tint(.white)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 24)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var zoomableContent: some View {
|
||||
GeometryReader { proxy in
|
||||
let size = proxy.size
|
||||
Color.clear
|
||||
.onAppear { containerSize = size }
|
||||
.onChange(of: size) { newValue in
|
||||
containerSize = newValue
|
||||
}
|
||||
.overlay {
|
||||
content(for: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for size: CGSize) -> some View {
|
||||
switch state.source {
|
||||
case .local(let image):
|
||||
zoomableImage(Image(uiImage: image))
|
||||
.onAppear {
|
||||
loadedImageSize = image.size
|
||||
}
|
||||
case .remote(let url, let fileId, let userId):
|
||||
RemoteZoomableImage(url: url, fileId: fileId, userId: userId) { uiImage in
|
||||
loadedImageSize = uiImage.size
|
||||
}
|
||||
.offset(currentOffset)
|
||||
.scaleEffect(scale, anchor: .center)
|
||||
.gesture(dragGesture)
|
||||
.simultaneousGesture(magnificationGesture)
|
||||
}
|
||||
}
|
||||
|
||||
private func zoomableImage(_ image: Image) -> some View {
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.offset(currentOffset)
|
||||
.scaleEffect(scale, anchor: .center)
|
||||
.gesture(dragGesture)
|
||||
.simultaneousGesture(magnificationGesture)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
if scale > 1.05 {
|
||||
dismissOffset = .zero
|
||||
let adjustedTranslation = CGSize(
|
||||
width: value.translation.width / scale,
|
||||
height: value.translation.height / scale
|
||||
)
|
||||
panOffset = CGSize(
|
||||
width: storedPanOffset.width + adjustedTranslation.width,
|
||||
height: storedPanOffset.height + adjustedTranslation.height
|
||||
)
|
||||
panOffset = clampedOffset(panOffset)
|
||||
} else {
|
||||
if dragMode == nil {
|
||||
if abs(value.translation.height) > abs(value.translation.width) {
|
||||
dragMode = .vertical
|
||||
} else {
|
||||
dragMode = .horizontal
|
||||
}
|
||||
}
|
||||
|
||||
switch dragMode {
|
||||
case .horizontal:
|
||||
let limitedWidth = min(max(value.translation.width, -80), 80)
|
||||
dismissOffset = CGSize(width: limitedWidth, height: 0)
|
||||
case .vertical, .none:
|
||||
dismissOffset = CGSize(width: 0, height: value.translation.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
if scale > 1.05 {
|
||||
let adjustedTranslation = CGSize(
|
||||
width: value.translation.width / scale,
|
||||
height: value.translation.height / scale
|
||||
)
|
||||
var newOffset = CGSize(
|
||||
width: storedPanOffset.width + adjustedTranslation.width,
|
||||
height: storedPanOffset.height + adjustedTranslation.height
|
||||
)
|
||||
newOffset = clampedOffset(newOffset)
|
||||
storedPanOffset = newOffset
|
||||
} else {
|
||||
if abs(value.translation.height) > 120 {
|
||||
onClose()
|
||||
} else {
|
||||
withAnimation(.spring()) {
|
||||
dismissOffset = .zero
|
||||
}
|
||||
}
|
||||
dragMode = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var magnificationGesture: some Gesture {
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let newScale = baseScale * value
|
||||
scale = min(max(newScale, 1), 4)
|
||||
}
|
||||
.onEnded { _ in
|
||||
baseScale = scale
|
||||
if baseScale <= 1.02 {
|
||||
baseScale = 1
|
||||
withAnimation(.spring()) {
|
||||
scale = 1
|
||||
storedPanOffset = .zero
|
||||
panOffset = .zero
|
||||
dismissOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clampedOffset(_ offset: CGSize) -> CGSize {
|
||||
guard scale > 1.01,
|
||||
containerSize != .zero else { return offset }
|
||||
|
||||
let fittedSize = fittedContentSize(in: containerSize)
|
||||
let scaledWidth = fittedSize.width * scale
|
||||
let scaledHeight = fittedSize.height * scale
|
||||
let maxX = max(0, (scaledWidth - containerSize.width) / 2)
|
||||
let maxY = max(0, (scaledHeight - containerSize.height) / 2)
|
||||
|
||||
let clampedX = max(-maxX, min(offset.width, maxX))
|
||||
let clampedY = max(-maxY, min(offset.height, maxY))
|
||||
return CGSize(width: clampedX, height: clampedY)
|
||||
}
|
||||
|
||||
private func fittedContentSize(in container: CGSize) -> CGSize {
|
||||
guard let imageSize = effectiveImageSize,
|
||||
imageSize.width > 0,
|
||||
imageSize.height > 0 else {
|
||||
return container
|
||||
}
|
||||
|
||||
let widthRatio = container.width / imageSize.width
|
||||
let heightRatio = container.height / imageSize.height
|
||||
let ratio = min(widthRatio, heightRatio)
|
||||
return CGSize(width: imageSize.width * ratio, height: imageSize.height * ratio)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemoteZoomableImage: View {
|
||||
@StateObject private var loader: ImageLoader
|
||||
let onImageLoaded: (UIImage) -> Void
|
||||
|
||||
init(url: URL, fileId: String, userId: String, onImageLoaded: @escaping (UIImage) -> Void) {
|
||||
_loader = StateObject(wrappedValue: ImageLoader(url: url, fileId: fileId, userId: userId))
|
||||
self.onImageLoaded = onImageLoaded
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = loader.image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.onAppear {
|
||||
onImageLoaded(image)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear(perform: loader.load)
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityView: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
@ -25,7 +25,6 @@ struct FeedbackView: View {
|
||||
ratingSection
|
||||
suggestionSection
|
||||
contactSection
|
||||
infoSection2
|
||||
|
||||
Button(action: submitSuggestion) {
|
||||
HStack(spacing: 10) {
|
||||
@ -57,7 +56,7 @@ struct FeedbackView: View {
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||
.navigationTitle(NSLocalizedString("Обратная связь", comment: "feedback: navigation title"))
|
||||
.navigationTitle(NSLocalizedString("Обратная связь (не работает)", comment: "feedback: navigation title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
@ -99,24 +98,6 @@ struct FeedbackView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var infoSection2: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label {
|
||||
Text(NSLocalizedString("Ваш отзыв создаст чат с командой поддержки, который появится в общем списке чатов.", comment: "feedback: info detail chat"))
|
||||
} icon: {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.08))
|
||||
)
|
||||
}
|
||||
|
||||
private var categorySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionTitle(NSLocalizedString("Что вы хотите обсудить?", comment: "feedback: category title"))
|
||||
@ -196,9 +177,9 @@ struct FeedbackView: View {
|
||||
|
||||
private var contactSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// sectionTitle(NSLocalizedString("Нужно ли вам ответить?", comment: "feedback: contact title"))
|
||||
sectionTitle(NSLocalizedString("Нужно ли вам ответить?", comment: "feedback: contact title"))
|
||||
|
||||
Toggle(NSLocalizedString("Уведомить об ответе по e-mail", comment: "feedback: contact toggle"), isOn: $wantsResponse)
|
||||
Toggle(NSLocalizedString("Получить ответ от команды", comment: "feedback: contact toggle"), isOn: $wantsResponse)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
|
||||
if wantsResponse {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -3,18 +3,8 @@ import SwiftUI
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@EnvironmentObject private var themeManager: ThemeManager
|
||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
||||
@State private var isThemeExpanded = false
|
||||
@State private var isSecurityActive = false
|
||||
@State private var messengerProfile: ProfileDataPayload?
|
||||
@State private var isMessengerProfileLoading = false
|
||||
@State private var messengerProfileError: String?
|
||||
private let themeOptions = ThemeOption.ordered
|
||||
private let profileService = ProfileService()
|
||||
private let messengerAvatarSize: CGFloat = 96
|
||||
private let bannerRowInsets = EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)
|
||||
private let aboutRowInsets = EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)
|
||||
private let compactSectionSpacing: CGFloat = 6
|
||||
|
||||
private var selectedThemeOption: ThemeOption {
|
||||
ThemeOption.option(for: themeManager.theme)
|
||||
@ -22,48 +12,27 @@ struct SettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if shouldShowLegacySupportBanner {
|
||||
LegacySupportBanner()
|
||||
.listRowInsets(bannerRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
if isMessengerModeEnabled {
|
||||
messengerProfileHeaderSection
|
||||
aboutSection
|
||||
}
|
||||
|
||||
// MARK: - Профиль
|
||||
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
|
||||
// NavigationLink(destination: EditProfileView()) {
|
||||
// Label("Мой профиль", systemImage: "person.crop.circle")
|
||||
// }
|
||||
|
||||
NavigationLink(destination: EditProfileView()) {
|
||||
Label(NSLocalizedString("Редактировать профиль", comment: ""), systemImage: "person.crop.circle")
|
||||
}
|
||||
|
||||
NavigationLink(destination: BlockedUsersView()) {
|
||||
Label(NSLocalizedString("Чёрный список", comment: ""), systemImage: "hand.raised.fill")
|
||||
NavigationLink(destination: EditPrivacyView()) {
|
||||
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Безопасность
|
||||
Section(header: Text(NSLocalizedString("Безопасность", comment: ""))) {
|
||||
NavigationLink(destination: EditPrivacyView()) {
|
||||
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
|
||||
}
|
||||
|
||||
NavigationLink(destination: ChangePasswordView()) {
|
||||
Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key")
|
||||
}
|
||||
NavigationLink(destination: ActiveSessionsView()) {
|
||||
Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
|
||||
NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) {
|
||||
Label("Двухфакторная аутентификация", systemImage: "lock.shield")
|
||||
}
|
||||
NavigationLink(isActive: $isSecurityActive) {
|
||||
SecuritySettingsView(viewModel: viewModel)
|
||||
} label: {
|
||||
Label(NSLocalizedString("Безопасность", comment: ""), systemImage: "lock.shield")
|
||||
NavigationLink(destination: Text("Заглушка: Активные сессии")) {
|
||||
Label("Активные сессии", systemImage: "iphone")
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,11 +56,11 @@ struct SettingsView: View {
|
||||
Label("Темы", systemImage: "moon.fill")
|
||||
}
|
||||
|
||||
NavigationLink(destination: DataSettingsView(currentUserId: viewModel.userId)) {
|
||||
NavigationLink(destination: Text("Заглушка: Хранилище данных")) {
|
||||
Label("Данные", systemImage: "externaldrive")
|
||||
}
|
||||
|
||||
NavigationLink(destination: OtherSettingsView()) {
|
||||
NavigationLink(destination: Text("Заглушка: Другие настройки")) {
|
||||
Label("Другое", systemImage: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
@ -138,14 +107,8 @@ struct SettingsView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Выход
|
||||
Section (
|
||||
header: Spacer()
|
||||
.frame(height: 32)
|
||||
.listRowInsets(EdgeInsets())
|
||||
){
|
||||
Section {
|
||||
Button(action: {
|
||||
viewModel.logoutCurrentUser()
|
||||
}) {
|
||||
@ -158,176 +121,13 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Настройки")
|
||||
.onAppear {
|
||||
loadMessengerProfileIfNeeded()
|
||||
}
|
||||
.onChange(of: isMessengerModeEnabled) { newValue in
|
||||
if newValue {
|
||||
loadMessengerProfileIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
.applyFormSectionSpacing(compactSectionSpacing)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var messengerProfileHeaderSection: some View {
|
||||
if messengerProfile != nil || isMessengerProfileLoading || messengerProfileError != nil {
|
||||
Section {
|
||||
if let profile = messengerProfile {
|
||||
NavigationLink(destination: EditProfileView()) {
|
||||
ProfileHeaderCardView(
|
||||
avatar: messengerAvatar(for: profile),
|
||||
displayName: messengerDisplayName(for: profile),
|
||||
presenceStatus: nil,
|
||||
statusTags: messengerStatusTags(for: profile),
|
||||
isOfficial: profile.isVerified
|
||||
)
|
||||
.contentShape(Rectangle()) // ← чтобы тап работал по всей площади
|
||||
}
|
||||
.buttonStyle(.plain) // ← чтобы не было system highlight
|
||||
.listRowInsets(bannerRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
} else if isMessengerProfileLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.listRowInsets(bannerRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
} else if let error = messengerProfileError {
|
||||
VStack(spacing: 8) {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
Button(NSLocalizedString("Повторить", comment: "Messenger profile header retry")) {
|
||||
loadMessengerProfileIfNeeded(force: true)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.listRowInsets(bannerRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func openLanguageSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func loadMessengerProfileIfNeeded(force: Bool = false) {
|
||||
guard isMessengerModeEnabled else { return }
|
||||
if isMessengerProfileLoading { return }
|
||||
if !force, messengerProfile != nil { return }
|
||||
|
||||
isMessengerProfileLoading = true
|
||||
messengerProfileError = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let profile = try await profileService.fetchMyProfile()
|
||||
await MainActor.run {
|
||||
messengerProfile = profile
|
||||
isMessengerProfileLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
messengerProfileError = error.localizedDescription
|
||||
isMessengerProfileLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func messengerAvatar(for profile: ProfileDataPayload) -> some View {
|
||||
if let fileId = profile.avatars?.current?.fileId,
|
||||
let url = URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(profile.userId.uuidString)?file_id=\(fileId)") {
|
||||
CachedAvatarView(url: url, fileId: fileId, userId: profile.userId.uuidString) {
|
||||
messengerAvatarPlaceholder(for: profile)
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: messengerAvatarSize, height: messengerAvatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
messengerAvatarPlaceholder(for: profile)
|
||||
}
|
||||
}
|
||||
|
||||
private func messengerAvatarPlaceholder(for profile: ProfileDataPayload) -> some View {
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.15))
|
||||
.frame(width: messengerAvatarSize, height: messengerAvatarSize)
|
||||
.overlay(
|
||||
Text(messengerInitials(for: profile))
|
||||
.font(.system(size: messengerAvatarSize * 0.45, weight: .semibold))
|
||||
.foregroundColor(Color.accentColor)
|
||||
)
|
||||
}
|
||||
|
||||
private func messengerInitials(for profile: ProfileDataPayload) -> String {
|
||||
if let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
let components = name.split(separator: " ")
|
||||
let initials = components.prefix(2).compactMap { $0.first }
|
||||
if !initials.isEmpty {
|
||||
return initials.map { String($0) }.joined().uppercased()
|
||||
}
|
||||
}
|
||||
return String(profile.login.prefix(1)).uppercased()
|
||||
}
|
||||
|
||||
private func messengerDisplayName(for profile: ProfileDataPayload) -> String {
|
||||
if let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
return "@\(profile.login)"
|
||||
}
|
||||
|
||||
private func messengerStatusTags(for profile: ProfileDataPayload) -> [ProfileHeaderCardView.StatusTag] {
|
||||
var tags: [ProfileHeaderCardView.StatusTag] = []
|
||||
// tags.append(
|
||||
// ProfileHeaderCardView.StatusTag(
|
||||
// icon: "at",
|
||||
// text: "@\(profile.login)",
|
||||
// background: Color.white.opacity(0.18),
|
||||
// tint: .white
|
||||
// )
|
||||
// )
|
||||
|
||||
// if let createdAt = profile.createdAt {
|
||||
// let formatted = SettingsView.membershipFormatter.string(from: createdAt)
|
||||
// tags.append(
|
||||
// ProfileHeaderCardView.StatusTag(
|
||||
// icon: "calendar",
|
||||
// text: String(
|
||||
// format: NSLocalizedString("С %@ на Yobble", comment: "Messenger profile membership"),
|
||||
// formatted
|
||||
// ),
|
||||
// background: Color.white.opacity(0.12),
|
||||
// tint: .white
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
|
||||
if profile.isVerified {
|
||||
tags.append(
|
||||
ProfileHeaderCardView.StatusTag(
|
||||
icon: "checkmark.seal.fill",
|
||||
text: NSLocalizedString("Подтверждённый профиль", comment: "Message profile verified tag"),
|
||||
background: Color.white.opacity(0.18),
|
||||
tint: .white
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
private func themeRow(for option: ThemeOption) -> some View {
|
||||
let isSelected = option == selectedThemeOption
|
||||
|
||||
@ -360,176 +160,4 @@ struct SettingsView: View {
|
||||
themeManager.setTheme(mappedTheme)
|
||||
}
|
||||
|
||||
private var shouldShowLegacySupportBanner: Bool {
|
||||
#if os(iOS)
|
||||
let requiredVersion = OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)
|
||||
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private static let membershipFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .none
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let ratingFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 1
|
||||
formatter.maximumFractionDigits = 1
|
||||
return formatter
|
||||
}()
|
||||
|
||||
@ViewBuilder
|
||||
private var aboutSection: some View {
|
||||
if let _ = messengerProfile {
|
||||
Section(
|
||||
header: Spacer()
|
||||
.frame(height: 16)
|
||||
.listRowInsets(EdgeInsets())
|
||||
){
|
||||
card {
|
||||
VStack(spacing: 0) {
|
||||
infoRow(
|
||||
title: NSLocalizedString("Юзернейм", comment: ""),
|
||||
value: loginDisplay ?? NSLocalizedString("Неизвестный пользователь", comment: "Messenger settings unknown user")
|
||||
)
|
||||
|
||||
if let membership = membershipDescription {
|
||||
rowDivider
|
||||
infoRow(
|
||||
title: NSLocalizedString("Дата регистрации в Yobble", comment: ""),
|
||||
value: membership
|
||||
)
|
||||
}
|
||||
|
||||
rowDivider
|
||||
infoRow(
|
||||
title: NSLocalizedString("Ваш рейтинг", comment: "Messenger settings rating title"),
|
||||
value: ratingDisplayValue
|
||||
)
|
||||
}
|
||||
}
|
||||
.listRowInsets(aboutRowInsets)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoRow(icon: String? = nil, title: String, value: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let icon {
|
||||
iconBackground(color: .accentColor.opacity(0.18)) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.body)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func iconBackground<Content: View>(color: Color, @ViewBuilder content: () -> Content) -> some View {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(color)
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay(content())
|
||||
}
|
||||
|
||||
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
content()
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.fill(Color(UIColor.secondarySystemGroupedBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private var rowDivider: some View {
|
||||
Divider()
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private var loginDisplay: String? {
|
||||
let login = messengerProfile?.login.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !login.isEmpty else { return nil }
|
||||
return "@\(login)"
|
||||
}
|
||||
|
||||
private var membershipDescription: String? {
|
||||
guard let createdAt = messengerProfile?.createdAt else { return nil }
|
||||
let formatted = SettingsView.membershipFormatter.string(from: createdAt)
|
||||
return formatted
|
||||
}
|
||||
|
||||
private var ratingDisplayValue: String {
|
||||
guard let rating = messengerProfile?.rating else {
|
||||
return NSLocalizedString("Недоступно", comment: "Messenger settings rating unavailable")
|
||||
}
|
||||
|
||||
let clamped = max(0, min(5, rating))
|
||||
let formatted = SettingsView.ratingFormatter.string(from: NSNumber(value: clamped))
|
||||
?? String(format: "%.1f", clamped)
|
||||
return String(
|
||||
format: NSLocalizedString("%@ из 5", comment: "Message profile rating format"),
|
||||
formatted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func applyFormSectionSpacing(_ spacing: CGFloat) -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
self.listSectionSpacing(.custom(spacing))
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LegacySupportBanner: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(.yellow)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Экспериментальная поддержка iOS 15/16")
|
||||
.font(.headline)
|
||||
Text("Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color.yellow.opacity(0.15))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color.yellow.opacity(0.4), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppConfig {
|
||||
static var DEBUG: Bool = false
|
||||
static var DEBUG: Bool = true
|
||||
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
|
||||
static let PROTOCOL = "https"
|
||||
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
|
||||
@ -12,10 +12,15 @@ struct AppConfig {
|
||||
static let APP_BUILD = "appstore" // appstore / freestore
|
||||
static let APP_VERSION = "0.1"
|
||||
|
||||
static let DISABLE_DB = false
|
||||
/// Controls whether incoming chat opens as a modal sheet (`true`) or navigates to Chats tab (`false`).
|
||||
static let PRESENT_CHAT_AS_SHEET = false
|
||||
|
||||
static let DISABLE_DB = false
|
||||
/// Fallback SQLCipher key used until the user sets an application password.
|
||||
static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
|
||||
}
|
||||
|
||||
struct AppInfo {
|
||||
static let text_1 = "\(NSLocalizedString("profile_down_text_1", comment: "")) yobble"
|
||||
static let text_2 = "\(NSLocalizedString("profile_down_text_2", comment: "")) 0.1test"
|
||||
static let text_3 = "\(NSLocalizedString("profile_down_text_3", comment: ""))2025"
|
||||
}
|
||||
|
||||
@ -2,13 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -10,91 +10,74 @@ import CoreData
|
||||
|
||||
@main
|
||||
struct yobbleApp: App {
|
||||
// @UIApplicationDelegateAdaptor(PushAppDelegate.self) var appDelegate
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
||||
|
||||
@StateObject private var themeManager = ThemeManager()
|
||||
@StateObject private var viewModel = LoginViewModel()
|
||||
@StateObject private var messageCenter = IncomingMessageCenter()
|
||||
@StateObject private var updateChecker = AppUpdateChecker()
|
||||
private let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if let notice = updateChecker.needUpdateNotice {
|
||||
NeedUpdateView(
|
||||
title: notice.title,
|
||||
message: notice.message,
|
||||
onUpdate: { updateChecker.openAppStore(link: notice.appStoreURL) }
|
||||
)
|
||||
} else {
|
||||
ZStack(alignment: .top) {
|
||||
Group {
|
||||
if viewModel.isInitialLoading {
|
||||
SplashScreenView()
|
||||
} else if viewModel.isLoggedIn {
|
||||
MainView(viewModel: viewModel)
|
||||
} else {
|
||||
LoginView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if let banner = messageCenter.banner {
|
||||
NewMessageBannerView(
|
||||
banner: banner,
|
||||
onOpen: { messageCenter.openCurrentChat() },
|
||||
onDismiss: { messageCenter.dismissBanner() }
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
|
||||
.sheet(item: AppConfig.PRESENT_CHAT_AS_SHEET ? $messageCenter.presentedChat : .constant(nil)) { chatItem in
|
||||
NavigationView {
|
||||
PrivateChatView(
|
||||
chat: chatItem,
|
||||
currentUserId: messageCenter.currentUserId
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(NSLocalizedString("Закрыть", comment: "")) {
|
||||
messageCenter.presentedChat = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: Binding(
|
||||
get: { updateChecker.softUpdateNotice },
|
||||
set: { newValue in
|
||||
if newValue == nil {
|
||||
updateChecker.dismissSoftUpdateIfNeeded()
|
||||
}
|
||||
}
|
||||
)) { notice in
|
||||
Alert(
|
||||
title: Text(notice.title),
|
||||
message: Text(notice.message),
|
||||
primaryButton: .default(Text(NSLocalizedString("Обновить", comment: ""))) {
|
||||
updateChecker.openAppStore(link: notice.appStoreURL)
|
||||
},
|
||||
secondaryButton: .cancel(Text(NSLocalizedString("Позже", comment: ""))) {
|
||||
updateChecker.dismissSoftUpdateIfNeeded(skipBuild: notice.skipBuild)
|
||||
}
|
||||
)
|
||||
ZStack(alignment: .top) {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
SplashScreenView()
|
||||
} else if viewModel.isLoggedIn {
|
||||
MainView(viewModel: viewModel)
|
||||
} else {
|
||||
LoginView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if let banner = messageCenter.banner {
|
||||
NewMessageBannerView(
|
||||
banner: banner,
|
||||
onOpen: { messageCenter.openCurrentChat() },
|
||||
onDismiss: { messageCenter.dismissBanner() }
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
|
||||
.sheet(item: AppConfig.PRESENT_CHAT_AS_SHEET ? $messageCenter.presentedChat : .constant(nil)) { chatItem in
|
||||
NavigationView {
|
||||
PrivateChatView(
|
||||
chat: chatItem,
|
||||
currentUserId: messageCenter.currentUserId
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(NSLocalizedString("Закрыть", comment: "")) {
|
||||
messageCenter.presentedChat = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(messageCenter)
|
||||
}
|
||||
.fullScreenCover(item: AppConfig.PRESENT_CHAT_AS_SHEET ? .constant(nil) : $messageCenter.presentedChat) { chatItem in
|
||||
NavigationView {
|
||||
PrivateChatView(
|
||||
chat: chatItem,
|
||||
currentUserId: messageCenter.currentUserId
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(NSLocalizedString("Закрыть", comment: "")) {
|
||||
messageCenter.presentedChat = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(messageCenter)
|
||||
}
|
||||
.environmentObject(messageCenter)
|
||||
.environmentObject(themeManager)
|
||||
.preferredColorScheme(themeManager.theme.colorScheme)
|
||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||
.onAppear {
|
||||
updateChecker.checkForUpdatesIfNeeded()
|
||||
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
|
||||
}
|
||||
.onChange(of: viewModel.userId) { newValue in
|
||||
|
||||