Compare commits

...

84 Commits

Author SHA1 Message Date
0cbbf4777d patch 2025-10-26 07:09:05 +03:00
643466d878 add localization 2025-10-26 03:48:24 +03:00
f14ff3293d add feedback changes 2025-10-26 03:34:37 +03:00
f22bce0e74 edit feedback 2025-10-26 03:25:44 +03:00
7a10ba5b33 patch first message after register 2025-10-26 03:19:54 +03:00
8568f6c20e fix pagination load 2025-10-26 02:57:47 +03:00
526a57b556 patch error 2025-10-26 02:52:16 +03:00
7f73216936 add msg 2025-10-26 02:49:59 +03:00
128ed5723a edit loading 2025-10-26 02:47:58 +03:00
0a7d519567 add error msg 2025-10-26 02:39:55 +03:00
9f6beecb49 patch 2025-10-26 02:34:28 +03:00
3e9d6696b0 patch 2025-10-26 01:53:21 +03:00
3f0543aa3a add new model to blacklist 2025-10-26 01:46:31 +03:00
052ff5fe4f add error 422 to reg 2025-10-24 21:49:32 +03:00
3c394446d2 add afterregister 2025-10-24 21:23:53 +03:00
9f2a938b1e add buttons 2025-10-24 21:19:49 +03:00
7a2fb798a3 delete old open settings 2025-10-24 21:15:21 +03:00
b46fc3ae16 add afterregister sheet 2025-10-24 21:05:11 +03:00
b466864350 add auto open security 2025-10-24 11:58:09 +03:00
107318ef21 patch security setting 2025-10-24 11:43:28 +03:00
e3cf374893 add secureview 2025-10-24 11:13:47 +03:00
6eed966fc9 add 2fa 2025-10-24 11:03:27 +03:00
58c841b5c7 add desription to ismessangermode 2025-10-24 10:44:14 +03:00
854561b5f7 add delete solo session 2025-10-24 10:35:44 +03:00
020aa8de5d change position to revoke all session 2025-10-24 10:29:20 +03:00
be6394f6fb add confirm revoke 2025-10-24 10:20:20 +03:00
cf5d2ad7fb patct sessions list 2025-10-24 10:17:15 +03:00
26534e88c1 add session list 2025-10-24 10:06:26 +03:00
7034503983 change loading circle 2025-10-24 00:21:39 +03:00
dd2abde5b8 disable burger menu in msg mode 2025-10-23 23:55:19 +03:00
e135556fa6 add context menu in contacts list 2025-10-23 23:46:19 +03:00
910eef3703 edit view contact list 2025-10-23 23:22:52 +03:00
e6d7258b70 edit view contacts 2025-10-23 23:18:11 +03:00
374bd1713b edit view contacts 2025-10-23 23:15:22 +03:00
aac0a25c4d add qr screen 2025-10-23 23:04:09 +03:00
e79cbd7ea4 add contact list 2025-10-23 22:49:19 +03:00
813795aece disable refresh 2025-10-23 22:39:32 +03:00
2eabbd59c3 add delete user from blacklist 2025-10-23 22:37:23 +03:00
43a5d8193d add confirm while delete 2025-10-23 22:29:23 +03:00
6b81860960 add blocked user list 2025-10-23 22:23:20 +03:00
8acacdb8c1 add blocked user 2025-10-23 22:08:00 +03:00
1c9f249289 scroll to top while tap tap 2025-10-23 21:49:31 +03:00
198b51bd91 edit padding 2025-10-23 21:12:11 +03:00
40a5f4c628 edit login screen 2025-10-23 21:05:18 +03:00
d692c7c984 add messenger mod 2025-10-23 20:50:44 +03:00
3ae7576c24 add other settings 2025-10-23 20:19:57 +03:00
52cf7e3b1c patch terms flag 2025-10-23 19:22:40 +03:00
85fb780c96 patch terms 2025-10-23 19:20:04 +03:00
a28402136d patch terms 2025-10-23 19:12:21 +03:00
140e82e122 add terms 2025-10-23 19:04:25 +03:00
726a6983b2 add notification legacy support ios 15 2025-10-23 18:48:52 +03:00
2f8c1f3514 add limit lines 2025-10-23 18:41:14 +03:00
fa1637a5af fix warning 2025-10-23 18:35:52 +03:00
b47922694d add legacy ios 15 2025-10-23 18:30:41 +03:00
adba8fc568 add ios 15 2025-10-23 04:07:54 +03:00
2000ddadc2 edit msg 2025-10-23 04:01:48 +03:00
9e95f9c9d9 edit msg 2025-10-23 03:58:35 +03:00
9460024734 edit view msg 2025-10-23 02:14:36 +03:00
b0888c2921 fix position 2025-10-22 06:39:29 +03:00
61e1feb8bd change scroll pos 2025-10-22 06:33:06 +03:00
de2d7c4020 change padding 2025-10-22 06:22:46 +03:00
91a3117595 fix scroll down 2025-10-22 06:20:41 +03:00
5f03feba66 fix pos 2025-10-22 06:14:18 +03:00
b267e5a999 add button scroll down 2025-10-22 06:11:19 +03:00
b9ea0807e5 scroll down 2025-10-22 06:03:10 +03:00
055c57c208 disable keyboard 2025-10-22 05:59:34 +03:00
bbed505033 patch send msg 2025-10-22 05:49:19 +03:00
ee4f783fe7 fix keyboard 2025-10-22 05:41:51 +03:00
93c865f5ca fix pos buttom 2025-10-22 05:38:58 +03:00
2d3299fe96 fix 2025-10-22 05:32:55 +03:00
2c31d25596 fix size 2025-10-22 05:31:01 +03:00
c6e17f0fc5 edit padding 2025-10-22 05:25:20 +03:00
9685674056 edit padding 2025-10-22 05:18:58 +03:00
c1e39128fb edit spacing 2025-10-22 05:16:21 +03:00
331ec94ede fix pos 2025-10-22 05:12:06 +03:00
aa3e619d37 fix pos 2025-10-22 05:10:22 +03:00
4442a40aac fix ico pos 2025-10-22 05:07:28 +03:00
5e419e8b0f testflight build 8 2025-10-22 04:57:49 +03:00
67125b230f new chat view 2025-10-22 04:43:38 +03:00
44f7336c8d пупупу 2025-10-22 03:59:48 +03:00
266742e15d privatechatview patch 2025-10-22 03:51:34 +03:00
6d8b322688 fix open in settings 2025-10-22 03:44:25 +03:00
edbf4faf00 delete error 2025-10-22 02:46:21 +03:00
1bc4dda14c cfg change 2025-10-21 21:03:11 +03:00
69 changed files with 3775 additions and 285 deletions

View File

@ -404,7 +404,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements; CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = V22H44W47J; DEVELOPMENT_TEAM = V22H44W47J;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -420,7 +420,7 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 16; IPHONEOS_DEPLOYMENT_TARGET = 15;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 11; MACOSX_DEPLOYMENT_TARGET = 11;
@ -444,7 +444,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements; CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = V22H44W47J; DEVELOPMENT_TEAM = V22H44W47J;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -460,7 +460,7 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 16; IPHONEOS_DEPLOYMENT_TARGET = 15;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 11; MACOSX_DEPLOYMENT_TARGET = 11;

View File

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

View File

@ -29,3 +29,16 @@ struct ErrorResponse: Decodable {
struct MessagePayload: Decodable { struct MessagePayload: Decodable {
let message: String let message: String
} }
struct BlockedUserInfo: Decodable {
let userId: UUID
let login: String
let fullName: String?
let customName: String?
let createdAt: Date
}
struct BlockedUsersPayload: Decodable {
let hasMore: Bool
let items: [BlockedUserInfo]
}

View File

@ -229,11 +229,24 @@ final class AuthService {
return mappedRegistrationMessage(for: message, statusCode: statusCode) return mappedRegistrationMessage(for: message, statusCode: statusCode)
} }
let message = extractMessage(from: data)
switch statusCode { switch statusCode {
case 400: case 400:
return NSLocalizedString("Неверный запрос (400).", comment: "") return NSLocalizedString("Неверный запрос (400).", comment: "")
case 403: case 403:
return NSLocalizedString("Регистрация запрещена.", comment: "") return NSLocalizedString("Регистрация запрещена.", comment: "")
case 409:
return NSLocalizedString("Логин уже занят.", comment: "")
case 422:
if let message {
if message == "Value error, Login must not end with 'bot' for non-bot accounts"{
return NSLocalizedString("Login must not end with 'bot' for non-bot accounts", comment: "")
}
return message
} else {
return NSLocalizedString("Ошибка в данных. Проверьте введённую информацию.", comment: "")
}
case 429: case 429:
return NSLocalizedString("Слишком много запросов.", comment: "") return NSLocalizedString("Слишком много запросов.", comment: "")
case 502: case 502:
@ -268,7 +281,7 @@ final class AuthService {
return NSLocalizedString("Регистрация временно недоступна.", comment: "") return NSLocalizedString("Регистрация временно недоступна.", comment: "")
} }
} }
if statusCode == 429 { if statusCode == 429 {
return NSLocalizedString("Слишком много запросов.", comment: "") return NSLocalizedString("Слишком много запросов.", comment: "")
} }

View File

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

View File

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

View File

@ -0,0 +1,265 @@
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)
}
}
}
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
}()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 B

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1018 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -61,8 +61,17 @@
} }
} }
}, },
"2FA включена" : {
"comment" : "Заголовок уведомления об успешной активации 2FA"
},
"2FA отключена" : {
"comment" : "Заголовок уведомления об отключении 2FA"
},
"Companion ID" : { "Companion ID" : {
"comment" : "Search placeholder companion title" "comment" : "Search placeholder companion title"
},
"Concept" : {
}, },
"DEBUG UPDATE" : { "DEBUG UPDATE" : {
"localizations" : { "localizations" : {
@ -80,6 +89,12 @@
} }
} }
}, },
"Email" : {
"comment" : "Заголовок экрана настроек email"
},
"Email не подтверждён. Подтвердите, чтобы активировать дополнительные проверки." : {
"comment" : "Описание необходимости подтверждения email"
},
"Fun Fest" : { "Fun Fest" : {
"comment" : "Fun Fest", "comment" : "Fun Fest",
"localizations" : { "localizations" : {
@ -100,9 +115,15 @@
} }
} }
} }
},
"Home" : {
},
"Login must not end with 'bot' for non-bot accounts" : {
}, },
"OK" : { "OK" : {
"comment" : "Profile update alert button", "comment" : "Common OK\nProfile update alert button\nОбщий текст кнопки OK",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -140,13 +161,7 @@
} }
} }
}, },
"profile_down_text_1" : { "Profile" : {
},
"profile_down_text_2" : {
},
"profile_down_text_3" : {
}, },
"Push-уведомления" : { "Push-уведомления" : {
@ -158,6 +173,9 @@
} }
} }
} }
},
"Qr" : {
}, },
"Yobble" : { "Yobble" : {
"localizations" : { "localizations" : {
@ -186,6 +204,7 @@
} }
}, },
"Активные сессии" : { "Активные сессии" : {
"comment" : "Заголовок экрана активных сессий",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -195,6 +214,9 @@
} }
} }
}, },
"Активные сессии не найдены" : {
"comment" : "Пустой список активных сессий"
},
"Аудио" : { "Аудио" : {
"comment" : "Audio message placeholder" "comment" : "Audio message placeholder"
}, },
@ -202,6 +224,7 @@
}, },
"Безопасность" : { "Безопасность" : {
"comment" : "Заголовок экрана настроек безопасности",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -211,6 +234,15 @@
} }
} }
}, },
"Безопасность аккаунта" : {
},
"Блокировка контакта \"%1$@\" появится позже." : {
"comment" : "Contacts block placeholder message"
},
"Бот" : {
"comment" : "Тип сессии — бот"
},
"В чате пока нет сообщений." : { "В чате пока нет сообщений." : {
}, },
@ -225,6 +257,26 @@
} }
} }
}, },
"Ваш отзыв создаст чат с командой поддержки, который появится в общем списке чатов." : {
"comment" : "feedback: info detail chat",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Your feedback will create a chat with the support team, which will appear in the general chat list."
}
}
}
},
"Введите код из приложения" : {
"comment" : "Поле ввода кода 2FA"
},
"Введите пароль" : {
"comment" : "Поле ввода пароля на приложение"
},
"Веб" : {
"comment" : "Тип сессии — веб"
},
"Версия:" : { "Версия:" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -258,6 +310,15 @@
} }
} }
}, },
"Включено" : {
},
"Включить" : {
"comment" : "Кнопка подтверждения включения 2FA"
},
"Включить 2FA" : {
"comment" : "Тоггл активации 2FA"
},
"Включить автоудаление аккаунта" : { "Включить автоудаление аккаунта" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -268,8 +329,14 @@
} }
} }
}, },
"Включить двухфакторную аутентификацию?" : {
"comment" : "Заголовок подтверждения включения 2FA"
},
"Вложение" : { "Вложение" : {
},
"Войдите с другого устройства, чтобы увидеть его здесь." : {
"comment" : "Подсказка при отсутствии активных сессий"
}, },
"Войти" : { "Войти" : {
"localizations" : { "localizations" : {
@ -301,6 +368,12 @@
} }
} }
}, },
"Всего сессий" : {
"comment" : "Сводка по количеству сессий"
},
"Вход и защита аккаунта (заглушка)" : {
"comment" : "Раздел настроек безопасности для аутентификации"
},
"Вы" : { "Вы" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -311,6 +384,18 @@
} }
} }
}, },
"Вы всегда можете отключить двухфакторную защиту, но мы рекомендуем оставлять её включённой для безопасности." : {
"comment" : "Рекомендация оставить 2FA включенной"
},
"Вы выйдете из выбранной сессии." : {
"comment" : "Описание подтверждения завершения конкретной сессии"
},
"Вы выйдете со всех устройств, кроме текущего." : {
"comment" : "Описание подтверждения завершения сессий"
},
"Вы можете включить защиту снова в любой момент." : {
"comment" : "Сообщение после отключения 2FA"
},
"Выберите оценку — это поможет нам понять настроение." : { "Выберите оценку — это поможет нам понять настроение." : {
"comment" : "feedback: rating hint", "comment" : "feedback: rating hint",
"localizations" : { "localizations" : {
@ -331,6 +416,9 @@
} }
} }
} }
},
"Выключено" : {
}, },
"Где найти сохранённые черновики?" : { "Где найти сохранённые черновики?" : {
"comment" : "FAQ question: drafts" "comment" : "FAQ question: drafts"
@ -339,7 +427,7 @@
"comment" : "Global search section" "comment" : "Global search section"
}, },
"Готово" : { "Готово" : {
"comment" : "Profile update success title", "comment" : "Profile update success title\nЗаголовок успешного уведомления",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -360,6 +448,7 @@
} }
}, },
"Двухфакторная аутентификация" : { "Двухфакторная аутентификация" : {
"comment" : "Заголовок экрана 2FA\nПереход к настройкам двухфакторной аутентификации",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -368,6 +457,15 @@
} }
} }
} }
},
"Двухфакторная аутентификация настроена." : {
"comment" : "Сообщение после успешного подтверждения кода 2FA"
},
"Десктоп" : {
"comment" : "Тип сессии — десктоп"
},
"Для начала, мы рекомендуем настроить параметры безопасности вашего аккаунта." : {
}, },
"Добавить друзей" : { "Добавить друзей" : {
"comment" : "Add friends", "comment" : "Add friends",
@ -380,6 +478,21 @@
} }
} }
}, },
"Добавление новых блокировок появится позже." : {
"comment" : "Add blocked user placeholder message"
},
"Добавьте контакты, чтобы быстрее выходить на связь." : {
"comment" : "Contacts empty state subtitle"
},
"Добавьте новый аккаунт в приложении аутентификации и введите следующий ключ:" : {
"comment" : "Инструкция по добавлению ключа 2FA"
},
"Добро пожаловать в Yobble!" : {
},
"Другие устройства (%d)" : {
"comment" : "Заголовок секции других устройств с количеством"
},
"Другое" : { "Другое" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -391,18 +504,39 @@
} }
}, },
"Если не нашли ответ, напишите нам своё предложение или проблему." : { "Если не нашли ответ, напишите нам своё предложение или проблему." : {
"comment" : "FAQ: contact developers footer" "comment" : "FAQ: contact developers footer",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "If you haven't found an answer, send us your suggestion or describe the issue."
}
}
}
},
"Заблокированные" : {
},
"Заблокировать контакт" : {
"comment" : "Contacts context action block"
},
"Завершить" : {
"comment" : "Кнопка завершения конкретной сессии\nПодтверждение завершения других сессий\nПодтверждение завершения конкретной сессии"
},
"Завершить другие сессии" : {
"comment" : "Кнопка завершения других сессий"
},
"Завершить сессии на других устройствах?" : {
"comment" : "Заголовок подтверждения завершения сессий"
},
"Завершить эту сессию?" : {
"comment" : "Заголовок подтверждения завершения отдельной сессии"
}, },
"Заглушка: Push-уведомления" : { "Заглушка: Push-уведомления" : {
},
"Заглушка: Активные сессии" : {
},
"Заглушка: Двухфакторная аутентификация" : {
}, },
"Заглушка: Другие настройки" : { "Заглушка: Другие настройки" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -493,6 +627,12 @@
} }
} }
}, },
"Защита входа" : {
"comment" : "Раздел защиты входа через email"
},
"Защита приложением будет добавлена в будущих обновлениях." : {
"comment" : "Сообщение заглушки пароля на приложение"
},
"Здесь не будут чаты" : { "Здесь не будут чаты" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -505,6 +645,12 @@
}, },
"Здесь появится информация о собеседнике и существующих чатах." : { "Здесь появится информация о собеседнике и существующих чатах." : {
"comment" : "Search placeholder description" "comment" : "Search placeholder description"
},
"Значение сохранено в буфере обмена." : {
"comment" : "Сообщение после копирования"
},
"Идет загрузка..." : {
}, },
"Идея" : { "Идея" : {
"comment" : "feedback category: idea", "comment" : "feedback category: idea",
@ -519,6 +665,9 @@
}, },
"Избранные сообщения" : { "Избранные сообщения" : {
},
"Изменение контакта \"%1$@\" появится позже." : {
"comment" : "Contacts edit placeholder message"
}, },
"Изменение пароля" : { "Изменение пароля" : {
"localizations" : { "localizations" : {
@ -530,6 +679,9 @@
} }
} }
}, },
"Изменить контакт" : {
"comment" : "Contacts context action edit"
},
"Изображение" : { "Изображение" : {
"comment" : "Image message placeholder" "comment" : "Image message placeholder"
}, },
@ -562,7 +714,15 @@
"comment" : "FAQ question: reset password" "comment" : "FAQ question: reset password"
}, },
"Как связаться с поддержкой?" : { "Как связаться с поддержкой?" : {
"comment" : "FAQ question: support" "comment" : "FAQ question: support",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "How to contact support?"
}
}
}
}, },
"Кастомная" : { "Кастомная" : {
"localizations" : { "localizations" : {
@ -577,6 +737,18 @@
"Кликер в разработке" : { "Кликер в разработке" : {
"comment" : "Concept tab placeholder title" "comment" : "Concept tab placeholder title"
}, },
"Код дружбы" : {
"comment" : "Friend code badge"
},
"Код принят" : {
"comment" : "Заголовок успешного подтверждения кода 2FA"
},
"Коды восстановления" : {
"comment" : "Раздел кодов восстановления 2FA"
},
"Контактов пока нет" : {
"comment" : "Contacts empty state title"
},
"Контакты" : { "Контакты" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -727,6 +899,12 @@
} }
} }
} }
},
"Мессенджер-режим сейчас проработан примерно на 50%." : {
},
"Мессенджер-режим сейчас проработан примерно на 60%." : {
}, },
"Мини-приложения" : { "Мини-приложения" : {
"comment" : "Applets", "comment" : "Applets",
@ -739,6 +917,9 @@
} }
} }
}, },
"Мобильное приложение" : {
"comment" : "Тип сессии — мобильное приложение"
},
"Мои загрузки" : { "Мои загрузки" : {
"comment" : "My Downloads", "comment" : "My Downloads",
"localizations" : { "localizations" : {
@ -772,6 +953,12 @@
} }
} }
}, },
"Мы отправим код подтверждения на привязанный email каждый раз при входе." : {
"comment" : "Описание работы кодов при входе"
},
"Мы отправим письмо, как только функция будет готова." : {
"comment" : "Сообщение при недоступной отправке письма"
},
"Мы постараемся всё исправить. Напишите, что смутило." : { "Мы постараемся всё исправить. Напишите, что смутило." : {
"comment" : "feedback: rating description 2", "comment" : "feedback: rating description 2",
"localizations" : { "localizations" : {
@ -863,6 +1050,12 @@
} }
} }
}, },
"Настоящая защита приложения появится позже. Пока вы можете ознакомится с макетом." : {
"comment" : "Описание заглушки для пароля на приложение"
},
"Настройка приложения" : {
"comment" : "Раздел инструкций подключения"
},
"Настройки" : { "Настройки" : {
"comment" : "Settings", "comment" : "Settings",
"localizations" : { "localizations" : {
@ -874,6 +1067,9 @@
} }
} }
}, },
"Настройки email" : {
"comment" : "Переход к настройкам безопасности email"
},
"Настройки приватности" : { "Настройки приватности" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -894,16 +1090,31 @@
} }
} }
} }
},
"Начальная настройка" : {
}, },
"Не удалось выполнить поиск." : { "Не удалось выполнить поиск." : {
"comment" : "Search error fallback\nSearch service decoding error" "comment" : "Search error fallback\nSearch service decoding error"
}, },
"Не удалось завершить другие сессии." : {
"comment" : "Sessions service revoke-all unexpected status"
},
"Не удалось завершить сессию." : {
"comment" : "Sessions service revoke unexpected status"
},
"Не удалось загрузить историю чата." : { "Не удалось загрузить историю чата." : {
},
"Не удалось загрузить контакты." : {
"comment" : "Contacts service decoding error\nContacts service unexpected status"
}, },
"Не удалось загрузить профиль." : { "Не удалось загрузить профиль." : {
"comment" : "Profile service decoding error\nProfile unexpected status" "comment" : "Profile service decoding error\nProfile unexpected status"
}, },
"Не удалось загрузить список сессий." : {
"comment" : "Sessions service decoding error\nSessions service unexpected status"
},
"Не удалось загрузить список чатов." : { "Не удалось загрузить список чатов." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -913,6 +1124,12 @@
} }
} }
} }
},
"Не удалось загрузить список." : {
"comment" : "Blocked users service decoding error\nBlocked users service unexpected status"
},
"Не удалось загрузить текст правил." : {
}, },
"Не удалось загрузить чаты." : { "Не удалось загрузить чаты." : {
"localizations" : { "localizations" : {
@ -968,7 +1185,7 @@
}, },
"Не удалось подготовить данные запроса." : { "Не удалось подготовить данные запроса." : {
"comment" : "Profile update encoding error" "comment" : "Blocked users delete encoding error\nProfile update encoding error"
}, },
"Не удалось сериализовать данные запроса." : { "Не удалось сериализовать данные запроса." : {
"localizations" : { "localizations" : {
@ -986,6 +1203,9 @@
"Не удалось сохранить изменения профиля." : { "Не удалось сохранить изменения профиля." : {
"comment" : "Profile update unexpected status" "comment" : "Profile update unexpected status"
}, },
"Не удалось удалить пользователя из списка." : {
"comment" : "Blocked users delete unexpected status"
},
"Неверный запрос (400)." : { "Неверный запрос (400)." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -996,6 +1216,9 @@
} }
} }
}, },
"Неверный код" : {
"comment" : "Заголовок ошибки неправильного кода 2FA"
},
"Неверный код приглашения." : { "Неверный код приглашения." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1197,6 +1420,7 @@
} }
}, },
"Обновить" : { "Обновить" : {
"comment" : "Contacts retry button",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1217,9 +1441,6 @@
} }
} }
}, },
"Обратная связь (не работает)" : {
"comment" : "feedback: navigation title"
},
"Ограничить таймер автоудаления (максимум)" : { "Ограничить таймер автоудаления (максимум)" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1244,6 +1465,21 @@
} }
} }
}, },
"Основной режим находится в ранней разработке (около 10%)." : {
},
"Отключить" : {
"comment" : "Кнопка подтверждения отключения 2FA"
},
"Отключить двухфакторную аутентификацию?" : {
"comment" : "Заголовок подтверждения отключения 2FA"
},
"Открыть правила" : {
},
"Отмена" : {
"comment" : "Common cancel\nОбщий текст кнопки отмены"
},
"Отображаемое имя" : { "Отображаемое имя" : {
}, },
@ -1258,6 +1494,9 @@
} }
} }
}, },
"Отправить письмо подтверждения" : {
"comment" : "Кнопка отправки письма подтверждения"
},
"Отправляем..." : { "Отправляем..." : {
"comment" : "feedback: sending state", "comment" : "feedback: sending state",
"localizations" : { "localizations" : {
@ -1292,7 +1531,7 @@
} }
}, },
"Ошибка" : { "Ошибка" : {
"comment" : "Profile update error title", "comment" : "Common error title\nContacts load error title\nProfile update error title\nЗаголовок сообщения об ошибке",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1311,6 +1550,9 @@
} }
} }
} }
},
"Ошибка в данных. Проверьте введённую информацию." : {
}, },
"Ошибка при деавторизации." : { "Ошибка при деавторизации." : {
"localizations" : { "localizations" : {
@ -1377,7 +1619,7 @@
} }
}, },
"Пароли не совпадают" : { "Пароли не совпадают" : {
"comment" : "Пароли не совпадают", "comment" : "Заголовок ошибки несовпадения паролей\nПароли не совпадают",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1430,6 +1672,9 @@
} }
} }
}, },
"Пароль на приложение" : {
"comment" : "Заголовок экрана пароля на приложение\nПереход к настройкам пароля на приложение"
},
"Пароль не удовлетворяет требованиям" : { "Пароль не удовлетворяет требованиям" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@ -1451,9 +1696,18 @@
} }
} }
}, },
"Пароль-приложение" : {
"comment" : "Раздел формы установки пароля на приложение"
},
"Первый вход: %@" : {
"comment" : "Дата первого входа в сессию"
},
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : { "Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
"comment" : "FAQ answer: reset password" "comment" : "FAQ answer: reset password"
}, },
"Повторите пароль" : {
"comment" : "Поле подтверждения пароля на приложение"
},
"Повторить" : { "Повторить" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1473,6 +1727,9 @@
} }
} }
} }
},
"Поддержка iOS 15 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 16+." : {
}, },
"Поделитесь идеями, сообщите об ошибке или расскажите, что работает отлично." : { "Поделитесь идеями, сообщите об ошибке или расскажите, что работает отлично." : {
"comment" : "feedback: info detail", "comment" : "feedback: info detail",
@ -1498,6 +1755,12 @@
}, },
"Подключение" : { "Подключение" : {
},
"Подтвердить" : {
"comment" : "Кнопка подтверждения кода 2FA"
},
"Подтверждение email" : {
"comment" : "Раздел подтверждения email"
}, },
"Подтверждение пароля" : { "Подтверждение пароля" : {
"comment" : "Подтверждение пароля", "comment" : "Подтверждение пароля",
@ -1526,6 +1789,9 @@
}, },
"Поиск отменён." : { "Поиск отменён." : {
"comment" : "Search cancelled" "comment" : "Search cancelled"
},
"Поиск стикеров" : {
}, },
"Пока что у вас нет чатов" : { "Пока что у вас нет чатов" : {
"localizations" : { "localizations" : {
@ -1567,8 +1833,12 @@
} }
} }
}, },
"Получать коды на email при входе" : {
"comment" : "Переключатель отправки кодов при входе"
},
"Получить ответ от команды" : { "Получить ответ от команды" : {
"comment" : "feedback: contact toggle", "comment" : "feedback: contact toggle",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1578,6 +1848,9 @@
} }
} }
}, },
"Пользователь \"%1$@\" будет удалён из списка заблокированных." : {
"comment" : "Unblock confirmation message"
},
"Пользователь Системы 1" : { "Пользователь Системы 1" : {
"comment" : "Тестовая подмена офф аккаунта", "comment" : "Тестовая подмена офф аккаунта",
"extractionState" : "manual", "extractionState" : "manual",
@ -1606,6 +1879,9 @@
}, },
"Попробуйте изменить запрос поиска." : { "Попробуйте изменить запрос поиска." : {
},
"Последний вход: %@" : {
"comment" : "Дата последнего входа в сессию"
}, },
"Похвала" : { "Похвала" : {
"comment" : "feedback category: praise", "comment" : "feedback category: praise",
@ -1617,6 +1893,9 @@
} }
} }
} }
},
"Правила сервиса" : {
}, },
"Предложите, что добавить" : { "Предложите, что добавить" : {
"comment" : "feedback category subtitle: idea", "comment" : "feedback category subtitle: idea",
@ -1639,6 +1918,9 @@
} }
} }
} }
},
"Приватность и контроль" : {
}, },
"Приватные чаты" : { "Приватные чаты" : {
"localizations" : { "localizations" : {
@ -1741,6 +2023,12 @@
} }
} }
}, },
"Проверочный код" : {
"comment" : "Раздел верификации 2FA"
},
"Проверьте ввод и попробуйте снова." : {
"comment" : "Сообщение ошибки несовпадения паролей"
},
"Проверьте данные и повторите попытку." : { "Проверьте данные и повторите попытку." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1750,6 +2038,12 @@
} }
} }
} }
},
"Проверьте цифры и попробуйте снова." : {
"comment" : "Описание ошибки неверного кода 2FA"
},
"Продолжить" : {
}, },
"Произошла неизвестная ошибка." : { "Произошла неизвестная ошибка." : {
"comment" : "Search unknown error" "comment" : "Search unknown error"
@ -1767,6 +2061,12 @@
} }
} }
}, },
"Пропустить" : {
},
"Просмотр \"%1$@\" появится позже." : {
"comment" : "Contacts placeholder message"
},
"Профиль" : { "Профиль" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1792,6 +2092,9 @@
}, },
"Публичная информация" : { "Публичная информация" : {
},
"Разблокировать" : {
"comment" : "Unblock confirmation action"
}, },
"Разрешить пересылку сообщений" : { "Разрешить пересылку сообщений" : {
"localizations" : { "localizations" : {
@ -1905,6 +2208,9 @@
} }
} }
} }
},
"Режим мессенжера" : {
}, },
"Сборка:" : { "Сборка:" : {
"localizations" : { "localizations" : {
@ -1937,7 +2243,18 @@
} }
}, },
"Связаться с разработчиками" : { "Связаться с разработчиками" : {
"comment" : "FAQ: contact developers link" "comment" : "FAQ: contact developers link",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Contact developers"
}
}
}
},
"Сгенерируйте резервные коды и сохраните их в надежном месте." : {
"comment" : "Подсказка о необходимости генерации кодов"
}, },
"Сервер вернул ошибку (%d)." : { "Сервер вернул ошибку (%d)." : {
"comment" : "Search error server status" "comment" : "Search error server status"
@ -1952,6 +2269,9 @@
} }
} }
}, },
"Сессий на других устройствах: %d" : {
"comment" : "Количество сессий на других устройствах"
},
"Сессия истекла. Войдите снова." : { "Сессия истекла. Войдите снова." : {
"comment" : "Chat creation unauthorized\nSearch error unauthorized", "comment" : "Chat creation unauthorized\nSearch error unauthorized",
"localizations" : { "localizations" : {
@ -1984,9 +2304,21 @@
} }
} }
}, },
"Скопировано" : {
"comment" : "Заголовок уведомления о копировании"
},
"Скопировать" : { "Скопировать" : {
"comment" : "Search placeholder copy" "comment" : "Search placeholder copy"
}, },
"Скопировать ключ" : {
"comment" : "Кнопка копирования секретного ключа"
},
"Скопировать код" : {
"comment" : "Кнопка копирования кода восстановления"
},
"Скоро" : {
"comment" : "Add blocked user placeholder title\nContacts placeholder title\nЗаголовок заглушки"
},
"Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!" : { "Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!" : {
"comment" : "Concept tab placeholder description" "comment" : "Concept tab placeholder description"
}, },
@ -2026,6 +2358,12 @@
} }
} }
}, },
"Согласиться с правилами" : {
},
"Создать новые коды" : {
"comment" : "Кнопка генерации резервных кодов"
},
"Сообщение" : { "Сообщение" : {
}, },
@ -2043,6 +2381,9 @@
} }
} }
}, },
"Сохраните секретный ключ и введите код из приложения, чтобы завершить настройку." : {
"comment" : "Сообщение после активации 2FA"
},
"Сохранить изменения" : { "Сохранить изменения" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -2053,6 +2394,9 @@
} }
} }
}, },
"Сохранить пароль" : {
"comment" : "Кнопка сохранения пароля на приложение"
},
"Спасибо! Мы получили ваш отзыв" : { "Спасибо! Мы получили ваш отзыв" : {
"comment" : "feedback: success title", "comment" : "feedback: success title",
"localizations" : { "localizations" : {
@ -2096,6 +2440,21 @@
} }
} }
}, },
"Статус защиты" : {
"comment" : "Раздел состояния 2FA"
},
"Стикеры" : {
},
"Текущая" : {
"comment" : "Маркер текущей сессии"
},
"Текущая сессия не найдена" : {
"comment" : "Сообщение об отсутствии текущей сессии"
},
"Текущая сессия останется активной" : {
"comment" : "Подсказка под кнопкой завершения других сессий"
},
"Тема: %@" : { "Тема: %@" : {
"comment" : "feedback: success category", "comment" : "feedback: success category",
"localizations" : { "localizations" : {
@ -2147,6 +2506,20 @@
} }
} }
}, },
"У вас нет заблокированных пользователей" : {
},
"Уведомить об ответе по e-mail" : {
"comment" : "feedback: contact toggle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Notify about the response via e-mail"
}
}
}
},
"Уведомления" : { "Уведомления" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -2157,6 +2530,15 @@
} }
} }
}, },
"Удаление контакта \"%1$@\" появится позже." : {
"comment" : "Contacts delete placeholder message"
},
"Удалить из заблокированных?" : {
"comment" : "Unblock confirmation title"
},
"Удалить контакт" : {
"comment" : "Contacts context action delete"
},
"Удалить чат (скоро)" : { "Удалить чат (скоро)" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -2177,6 +2559,9 @@
} }
} }
}, },
"Функция пока недоступна." : {
"comment" : "Сообщение заглушки"
},
"Центр авторов" : { "Центр авторов" : {
"comment" : "Creator Center", "comment" : "Creator Center",
"localizations" : { "localizations" : {
@ -2242,6 +2627,9 @@
}, },
"Черновики доступны в боковом меню в разделе Drafts." : { "Черновики доступны в боковом меню в разделе Drafts." : {
"comment" : "FAQ answer: drafts" "comment" : "FAQ answer: drafts"
},
"Чёрный список" : {
}, },
"Что вам понравилось?" : { "Что вам понравилось?" : {
"comment" : "feedback prompt: praise", "comment" : "feedback prompt: praise",
@ -2286,6 +2674,15 @@
} }
} }
} }
},
"Экспериментальная поддержка iOS 15" : {
},
"Это устройство" : {
"comment" : "Заголовок секции текущего устройства"
},
"Я ознакомился и принимаю правила сервиса" : {
}, },
"Язык" : { "Язык" : {
"localizations" : { "localizations" : {

View File

@ -1,6 +1,11 @@
import Foundation import Foundation
import Combine import Combine
struct ChatNavigationTarget: Identifiable {
let id = UUID()
let chat: PrivateChatListItem
}
final class IncomingMessageCenter: ObservableObject { final class IncomingMessageCenter: ObservableObject {
@Published private(set) var banner: IncomingMessageBanner? @Published private(set) var banner: IncomingMessageBanner?
@Published var presentedChat: PrivateChatListItem? @Published var presentedChat: PrivateChatListItem?
@ -122,9 +127,4 @@ final class IncomingMessageCenter: ObservableObject {
dismissWorkItem = workItem dismissWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
} }
struct ChatNavigationTarget: Identifiable {
let id = UUID()
let chat: PrivateChatListItem
}
} }

View File

@ -18,6 +18,11 @@ class LoginViewModel: ObservableObject {
@Published var isLoggedIn: Bool = false @Published var isLoggedIn: Bool = false
@Published var socketState: SocketService.ConnectionState @Published var socketState: SocketService.ConnectionState
@Published var chatLoadingState: ChatLoadingState = .idle @Published var chatLoadingState: ChatLoadingState = .idle
@Published var hasAcceptedTerms: Bool = false
@Published var isLoadingTerms: Bool = false
@Published var termsContent: String = ""
@Published var termsErrorMessage: String?
@Published var onboardingDestination: OnboardingDestination?
private let authService = AuthService() private let authService = AuthService()
private let socketService = SocketService.shared private let socketService = SocketService.shared
@ -28,6 +33,10 @@ class LoginViewModel: ObservableObject {
case loading case loading
} }
enum OnboardingDestination: Equatable {
case afterRegister
}
private enum DefaultsKeys { private enum DefaultsKeys {
static let currentUser = "currentUser" static let currentUser = "currentUser"
static let userId = "userId" static let userId = "userId"
@ -123,6 +132,7 @@ class LoginViewModel: ObservableObject {
self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
self?.loadStoredUser() self?.loadStoredUser()
self?.socketService.connectForCurrentUser() self?.socketService.connectForCurrentUser()
self?.onboardingDestination = .afterRegister
} else { } else {
self?.socketService.disconnect() self?.socketService.disconnect()
} }
@ -169,4 +179,53 @@ class LoginViewModel: ObservableObject {
if AppConfig.DEBUG{ print("username: \(username) | userId: \(userId)")} if AppConfig.DEBUG{ print("username: \(username) | userId: \(userId)")}
} }
func loadTermsIfNeeded() {
guard !isLoadingTerms else { return }
if !termsContent.isEmpty {
termsErrorMessage = nil
return
}
isLoadingTerms = true
termsErrorMessage = nil
NetworkClient.shared.request(
path: "/legal/terms",
headers: ["Accept": "text/plain"],
requiresAuth: false,
callbackQueue: .main
) { [weak self] result in
guard let self else { return }
self.isLoadingTerms = false
switch result {
case .success(let response):
if let content = String(data: response.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!content.isEmpty {
self.termsContent = content
return
}
if let jsonObject = try? JSONSerialization.jsonObject(with: response.data, options: []),
let json = jsonObject as? [String: Any],
let content = (json["content"] as? String) ?? (json["text"] as? String),
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.termsContent = content
} else {
self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "")
}
case .failure:
self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "")
}
}
}
func reloadTerms() {
termsContent = ""
termsErrorMessage = nil
loadTermsIfNeeded()
}
} }

View File

@ -1,12 +1,23 @@
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct PrivateChatView: View { struct PrivateChatView: View {
let chat: PrivateChatListItem let chat: PrivateChatListItem
let currentUserId: String? let currentUserId: String?
private let bottomAnchorId = "PrivateChatBottomAnchor"
let lineLimitInChat = 6
@StateObject private var viewModel: PrivateChatViewModel @StateObject private var viewModel: PrivateChatViewModel
@State private var hasPositionedToBottom: Bool = false @State private var hasPositionedToBottom: Bool = false
@State private var scrollToBottomTrigger: UUID = .init()
@State private var isBottomAnchorVisible: Bool = true
@State private var draftText: String = "" @State private var draftText: String = ""
@State private var inputTab: ComposerTab = .chat
@State private var isVideoPreferred: Bool = false
@State private var legacyComposerHeight: CGFloat = 40
@FocusState private var isComposerFocused: Bool @FocusState private var isComposerFocused: Bool
@EnvironmentObject private var messageCenter: IncomingMessageCenter @EnvironmentObject private var messageCenter: IncomingMessageCenter
@ -18,17 +29,22 @@ struct PrivateChatView: View {
var body: some View { var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
content ZStack(alignment: .bottomTrailing) {
.onChange(of: viewModel.messages.count) { _ in content
guard !viewModel.isLoadingMore, .onChange(of: viewModel.messages.count) { _ in
let lastId = viewModel.messages.last?.id else { return } guard !viewModel.isLoadingMore else { return }
DispatchQueue.main.async { scrollToBottom(proxy: proxy)
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(lastId, anchor: .bottom)
}
hasPositionedToBottom = true
} }
.onChange(of: scrollToBottomTrigger) { _ in
scrollToBottom(proxy: proxy)
}
if !isBottomAnchorVisible {
scrollToBottomButton(proxy: proxy)
.padding(.trailing, 12)
.padding(.bottom, 4)
} }
}
} }
.navigationTitle(title) .navigationTitle(title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -83,9 +99,21 @@ struct PrivateChatView: View {
!viewModel.messages.isEmpty { !viewModel.messages.isEmpty {
errorBanner(message: message) errorBanner(message: message)
} }
Color.clear
.frame(height: 1)
.id(bottomAnchorId)
.onAppear { isBottomAnchorVisible = true }
.onDisappear { isBottomAnchorVisible = false }
} }
.padding(.vertical, 12) .padding(.vertical, 12)
} }
.simultaneousGesture(
DragGesture().onChanged { value in
guard value.translation.height > 0 else { return }
isComposerFocused = false
}
)
.refreshable { .refreshable {
viewModel.refresh() viewModel.refresh()
} }
@ -128,7 +156,7 @@ struct PrivateChatView: View {
private func messageRow(for message: MessageItem) -> some View { private func messageRow(for message: MessageItem) -> some View {
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
return HStack { return HStack(alignment: .bottom, spacing: 12) {
if isCurrentUser { Spacer(minLength: 32) } if isCurrentUser { Spacer(minLength: 32) }
VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 6) { VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 6) {
@ -138,25 +166,34 @@ struct PrivateChatView: View {
// .foregroundColor(.secondary) // .foregroundColor(.secondary)
// } // }
Text(contentText(for: message)) HStack(alignment: .bottom) {
.font(.body) Text(contentText(for: message))
.foregroundColor(isCurrentUser ? .white : .primary) .font(.body)
.frame(maxWidth: .infinity, alignment: isCurrentUser ? .trailing : .leading) .foregroundColor(isCurrentUser ? .white : .primary)
.multilineTextAlignment(.leading)
Text(timestamp(for: message))
.font(.caption2) Text(timestamp(for: message))
.foregroundColor(isCurrentUser ? Color.white.opacity(0.8) : .secondary) .font(.caption2)
.foregroundColor(isCurrentUser ? Color.white.opacity(0.8) : .secondary)
}
} }
.padding(.vertical, 10) .padding(.vertical, 10)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground)) .background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading)
.fixedSize(horizontal: false, vertical: true)
if !isCurrentUser { Spacer(minLength: 32) } if !isCurrentUser { Spacer(minLength: 32) }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
private var messageBubbleMaxWidth: CGFloat {
min(UIScreen.main.bounds.width * 0.72, 360)
}
private func senderName(for message: MessageItem) -> String { private func senderName(for message: MessageItem) -> String {
if let full = message.senderData?.fullName, !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let full = message.senderData?.fullName, !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return full return full
@ -200,47 +237,215 @@ struct PrivateChatView: View {
} }
private var composer: some View { private var composer: some View {
VStack(spacing: 0) { VStack(spacing: 10) {
Divider() HStack(alignment: .bottom, spacing: 4) {
Button(action: { }) { // переключатель на стикеры
HStack(alignment: .center, spacing: 12) { Image(systemName: "paperclip")
TextField(NSLocalizedString("Сообщение", comment: ""), text: $draftText, axis: .vertical)
.lineLimit(1...4)
.focused($isComposerFocused)
.submitLabel(.send)
.disabled(viewModel.isSending || currentUserId == nil)
.onSubmit { sendCurrentMessage() }
Button(action: sendCurrentMessage) {
Image(systemName: viewModel.isSending ? "hourglass" : "paperplane.fill")
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
// .buttonStyle(ComposerIconButtonStyle())
.frame(width: 36, height: 36)
ZStack(alignment: .bottomTrailing) {
Group {
if #available(iOS 16.0, *) {
TextField(inputTab.placeholder, text: $draftText, axis: .vertical)
.lineLimit(1...lineLimitInChat)
.focused($isComposerFocused)
.submitLabel(.send)
.disabled(currentUserId == nil)
.onSubmit { sendCurrentMessage() }
} else {
LegacyMultilineTextView(
text: $draftText,
placeholder: inputTab.placeholder,
isFocused: Binding(
get: { isComposerFocused },
set: { isComposerFocused = $0 }
),
isEnabled: currentUserId != nil,
minHeight: 10,
maxLines: lineLimitInChat,
calculatedHeight: $legacyComposerHeight,
onSubmit: sendCurrentMessage
)
.frame(height: legacyComposerHeight)
}
}
.padding(.top, 10)
.padding(.leading, 12)
.padding(.trailing, 44)
.padding(.bottom, 10)
.frame(maxWidth: .infinity, minHeight: 40, alignment: .bottomLeading)
Button(action: { }) { // переключатель на стикеры
Image(systemName: "face.smiling")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
.padding(.trailing, 12)
.padding(.bottom, 10)
}
.frame(minHeight: 40, alignment: .bottom)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.alignmentGuide(.bottom) { dimension in
dimension[VerticalAlignment.bottom] - 2
}
if !isSendAvailable {
Button(action: { isVideoPreferred.toggle() }) {
Image(systemName: isVideoPreferred ? "video.fill" : "mic.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
// .buttonStyle(ComposerIconButtonStyle())
.frame(width: 36, height: 36)
} else {
sendButton
} }
.disabled(isSendDisabled)
.buttonStyle(.plain)
} }
.padding(.vertical, 10)
.padding(.horizontal, 16)
.background(.clear)
} }
.padding(.horizontal, 6)
.padding(.top, 10)
.padding(.bottom, 8)
.background(.ultraThinMaterial) .background(.ultraThinMaterial)
} }
private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View {
Button {
scrollToBottom(proxy: proxy)
} label: {
Image(systemName: "arrow.down")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.frame(width: 44, height: 44)
// .background(Color.accentColor)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
// .overlay(
// Circle().stroke(Color.white.opacity(0.35), lineWidth: 1)
// )
}
.buttonStyle(.plain)
.shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2)
}
private var isSendDisabled: Bool { private var isSendDisabled: Bool {
draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending || currentUserId == nil draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || currentUserId == nil
}
private var isSendAvailable: Bool {
!draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && currentUserId != nil
}
private var sendButton: some View {
Button(action: sendCurrentMessage) {
Image(systemName: "leaf.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.white.opacity(isSendDisabled ? 0.6 : 1))
.frame(width: 36, height: 36)
.background(isSendDisabled ? Color.accentColor.opacity(0.4) : Color.accentColor)
.clipShape(Circle())
}
.disabled(isSendDisabled)
.buttonStyle(.plain)
}
// private func composerToolbarButton(systemName: String, action: @escaping () -> Void) -> some View {
// Button(action: action) {
// Image(systemName: systemName)
// .font(.system(size: 16, weight: .medium))
// }
private func composerModeButton(_ tab: ComposerTab) -> some View {
Button(action: { inputTab = tab }) {
Text(tab.title)
.font(.caption)
.fontWeight(inputTab == tab ? .semibold : .regular)
.padding(.vertical, 8)
.padding(.horizontal, 14)
.background(
Group {
if inputTab == tab {
Color.accentColor.opacity(0.15)
} else {
Color(.secondarySystemBackground)
}
}
)
.foregroundColor(inputTab == tab ? .accentColor : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
} }
private func sendCurrentMessage() { private func sendCurrentMessage() {
let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines) let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
draftText = ""
scrollToBottomTrigger = .init()
viewModel.sendMessage(text: text) { success in viewModel.sendMessage(text: text) { success in
if success { if success {
draftText = ""
hasPositionedToBottom = true hasPositionedToBottom = true
} }
} }
} }
private func scrollToBottom(proxy: ScrollViewProxy) {
hasPositionedToBottom = true
let targetId = viewModel.messages.last?.id ?? bottomAnchorId
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(targetId, anchor: .bottom)
}
}
}
private struct ComposerIconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: 36, height: 36)
.background(Color(.secondarySystemBackground))
.clipShape(Circle())
.overlay(
Circle().stroke(Color.secondary.opacity(0.15))
)
.scaleEffect(configuration.isPressed ? 0.94 : 1)
.opacity(configuration.isPressed ? 0.75 : 1)
}
}
private enum ComposerTab: String {
case chat
case stickers
var title: String {
switch self {
case .chat: return NSLocalizedString("Чат", comment: "")
case .stickers: return NSLocalizedString("Стикеры", comment: "")
}
}
var iconName: String {
switch self {
case .chat: return "text.bubble"
case .stickers: return "face.smiling"
}
}
var placeholder: String {
switch self {
case .chat: return NSLocalizedString("Сообщение", comment: "")
case .stickers: return NSLocalizedString("Поиск стикеров", comment: "")
}
}
}
private static let timeFormatter: DateFormatter = { private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateStyle = .none formatter.dateStyle = .none
@ -262,6 +467,171 @@ struct PrivateChatView: View {
} }
} }
#if canImport(UIKit)
private struct LegacyMultilineTextView: UIViewRepresentable {
@Binding var text: String
var placeholder: String
@Binding var isFocused: Bool
var isEnabled: Bool
var minHeight: CGFloat
var maxLines: Int
@Binding var calculatedHeight: CGFloat
var onSubmit: (() -> Void)?
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.isScrollEnabled = false
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.text = text
textView.returnKeyType = .send
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let placeholderLabel = context.coordinator.placeholderLabel
placeholderLabel.text = placeholder
placeholderLabel.font = textView.font
placeholderLabel.textColor = UIColor.secondaryLabel
placeholderLabel.numberOfLines = 1
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
textView.addSubview(placeholderLabel)
NSLayoutConstraint.activate([
placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor),
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: textView.trailingAnchor)
])
context.coordinator.updatePlaceholderVisibility(for: textView)
DispatchQueue.main.async {
Self.recalculateHeight(
for: textView,
result: calculatedHeightBinding,
minHeight: minHeight,
maxLines: maxLines
)
}
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
context.coordinator.parent = self
if uiView.text != text {
uiView.text = text
}
uiView.isEditable = isEnabled
uiView.isSelectable = isEnabled
uiView.textColor = isEnabled ? UIColor.label : UIColor.secondaryLabel
let placeholderLabel = context.coordinator.placeholderLabel
if placeholderLabel.text != placeholder {
placeholderLabel.text = placeholder
}
placeholderLabel.font = uiView.font
context.coordinator.updatePlaceholderVisibility(for: uiView)
if isFocused && !uiView.isFirstResponder {
uiView.becomeFirstResponder()
} else if !isFocused && uiView.isFirstResponder {
uiView.resignFirstResponder()
}
Self.recalculateHeight(
for: uiView,
result: calculatedHeightBinding,
minHeight: minHeight,
maxLines: maxLines
)
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
private var calculatedHeightBinding: Binding<CGFloat> {
Binding(get: { calculatedHeight }, set: { calculatedHeight = $0 })
}
private static func recalculateHeight(for textView: UITextView, result: Binding<CGFloat>, minHeight: CGFloat, maxLines: Int) {
let width = textView.bounds.width
guard width > 0 else {
DispatchQueue.main.async {
recalculateHeight(for: textView, result: result, minHeight: minHeight, maxLines: maxLines)
}
return
}
let fittingSize = CGSize(width: width, height: .greatestFiniteMagnitude)
let targetSize = textView.sizeThatFits(fittingSize)
let lineHeight = textView.font?.lineHeight ?? UIFont.preferredFont(forTextStyle: .body).lineHeight
let maxHeight = minHeight + lineHeight * CGFloat(max(maxLines - 1, 0))
let clampedHeight = min(max(targetSize.height, minHeight), maxHeight)
let shouldScroll = targetSize.height > maxHeight + 0.5
if abs(result.wrappedValue - clampedHeight) > 0.5 {
let newHeight = clampedHeight
DispatchQueue.main.async {
if abs(result.wrappedValue - newHeight) > 0.5 {
result.wrappedValue = newHeight
}
}
}
textView.isScrollEnabled = shouldScroll
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: LegacyMultilineTextView
let placeholderLabel = UILabel()
init(parent: LegacyMultilineTextView) {
self.parent = parent
}
func textViewDidBeginEditing(_ textView: UITextView) {
if !parent.isFocused {
parent.isFocused = true
}
}
func textViewDidEndEditing(_ textView: UITextView) {
if parent.isFocused {
parent.isFocused = false
}
}
func textViewDidChange(_ textView: UITextView) {
if parent.text != textView.text {
parent.text = textView.text
}
updatePlaceholderVisibility(for: textView)
LegacyMultilineTextView.recalculateHeight(for: textView, result: parent.calculatedHeightBinding, minHeight: parent.minHeight, maxLines: parent.maxLines)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if text == "\n" {
if let onSubmit = parent.onSubmit {
DispatchQueue.main.async {
onSubmit()
}
return false
}
}
return true
}
func updatePlaceholderVisibility(for textView: UITextView) {
placeholderLabel.isHidden = !textView.text.isEmpty
}
}
}
#endif
// MARK: - Preview // MARK: - Preview
// Previews intentionally omitted - MessageItem has custom decoding-only initializer. // Previews intentionally omitted - MessageItem has custom decoding-only initializer.

View File

@ -12,8 +12,12 @@ struct LoginView: View {
@EnvironmentObject private var themeManager: ThemeManager @EnvironmentObject private var themeManager: ThemeManager
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
private let themeOptions = ThemeOption.ordered private let themeOptions = ThemeOption.ordered
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
@State private var isShowingRegistration = false @State private var isShowingRegistration = false
@State private var showLegacySupportNotice = false
@State private var isShowingTerms = false
@State private var hasResetTermsOnAppear = false
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
private enum Field: Hashable { private enum Field: Hashable {
@ -29,6 +33,11 @@ struct LoginView: View {
private var isPasswordValid: Bool { private var isPasswordValid: Bool {
return viewModel.password.count >= 8 && viewModel.password.count <= 128 return viewModel.password.count >= 8 && viewModel.password.count <= 128
} }
private var isLoginButtonEnabled: Bool {
// !viewModel.isLoading && isUsernameValid && isPasswordValid && viewModel.hasAcceptedTerms
!viewModel.isLoading && isUsernameValid && isPasswordValid
}
var body: some View { var body: some View {
@ -98,7 +107,7 @@ struct LoginView: View {
viewModel.password = String(newValue.prefix(32)) viewModel.password = String(newValue.prefix(32))
} }
} }
// Показываем ошибку для пароля // Показываем ошибку для пароля
if !isPasswordValid && !viewModel.password.isEmpty { if !isPasswordValid && !viewModel.password.isEmpty {
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль")) Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
@ -106,9 +115,25 @@ struct LoginView: View {
.font(.caption) .font(.caption)
} }
var isButtonEnabled: Bool { // TermsAgreementCard(
!viewModel.isLoading && isUsernameValid && isPasswordValid // isAccepted: $viewModel.hasAcceptedTerms,
// openTerms: {
// viewModel.loadTermsIfNeeded()
// isShowingTerms = true
// }
// )
// .padding(.vertical, 12)
VStack(alignment: .leading, spacing: 4) {
Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text(isMessengerModeEnabled
? "Мессенджер-режим сейчас проработан примерно на 60%."
: "Основной режим находится в ранней разработке (около 10%).")
.font(.footnote)
.foregroundColor(.secondary)
} }
.padding(.vertical, 8)
Button(action: { Button(action: {
viewModel.login() viewModel.login()
@ -125,17 +150,18 @@ struct LoginView: View {
.foregroundColor(.white) .foregroundColor(.white)
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(isButtonEnabled ? Color.blue : Color.gray) .background(isLoginButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(8) .cornerRadius(8)
} }
} }
.disabled(!isButtonEnabled) .disabled(!isLoginButtonEnabled)
// Spacer() // Spacer()
// Кнопка регистрации // Кнопка регистрации
Button(action: { Button(action: {
isShowingRegistration = true isShowingRegistration = true
viewModel.hasAcceptedTerms = false
}) { }) {
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация")) Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
.foregroundColor(.blue) .foregroundColor(.blue)
@ -156,9 +182,40 @@ struct LoginView: View {
dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
) )
} }
.onAppear {
if !hasResetTermsOnAppear {
viewModel.hasAcceptedTerms = false
hasResetTermsOnAppear = true
}
if shouldShowLegacySupportNotice {
showLegacySupportNotice = true
}
}
.onTapGesture { .onTapGesture {
focusedField = nil focusedField = nil
} }
if showLegacySupportNotice {
LegacySupportNoticeView(isPresented: $showLegacySupportNotice)
.transition(.opacity)
.zIndex(1)
}
}
.fullScreenCover(isPresented: $isShowingTerms) {
TermsFullScreenView(
isPresented: $isShowingTerms,
title: NSLocalizedString("Правила сервиса", comment: ""),
content: viewModel.termsContent,
isLoading: viewModel.isLoadingTerms,
errorMessage: viewModel.termsErrorMessage,
onRetry: {
viewModel.reloadTerms()
}
)
.onAppear {
if viewModel.termsContent.isEmpty {
viewModel.loadTermsIfNeeded()
}
}
} }
} }
private var themeIconName: String { private var themeIconName: String {
@ -172,11 +229,68 @@ struct LoginView: View {
} }
} }
private var shouldShowLegacySupportNotice: Bool {
#if os(iOS)
let requiredVersion = OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0)
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
#else
return false
#endif
}
private func openLanguageSettings() { private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return } guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url) UIApplication.shared.open(url)
} }
private struct LegacySupportNoticeView: View {
@Binding var isPresented: Bool
var body: some View {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture {
isPresented = false
}
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40, weight: .bold))
.foregroundColor(.yellow)
Text("Экспериментальная поддержка iOS 15")
.font(.headline)
.multilineTextAlignment(.center)
Text("Поддержка iOS 15 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 16+.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
isPresented = false
} label: {
Text("Понятно")
.bold()
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.systemBackground))
)
.frame(maxWidth: 320)
.shadow(radius: 10)
}
}
}
private var selectedThemeOption: ThemeOption { private var selectedThemeOption: ThemeOption {
ThemeOption.option(for: themeManager.theme) ThemeOption.option(for: themeManager.theme)
} }

View File

@ -20,6 +20,7 @@ struct RegistrationView: View {
@State private var isLoading: Bool = false @State private var isLoading: Bool = false
@State private var showError: Bool = false @State private var showError: Bool = false
@State private var errorMessage: String = "" @State private var errorMessage: String = ""
@State private var isShowingTerms: Bool = false
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@ -44,7 +45,7 @@ struct RegistrationView: View {
} }
private var isFormValid: Bool { private var isFormValid: Bool {
isUsernameValid && isPasswordValid && isConfirmPasswordValid isUsernameValid && isPasswordValid && isConfirmPasswordValid && viewModel.hasAcceptedTerms
} }
var body: some View { var body: some View {
@ -146,6 +147,14 @@ struct RegistrationView: View {
.focused($focusedField, equals: .invite) .focused($focusedField, equals: .invite)
} }
TermsAgreementCard(
isAccepted: $viewModel.hasAcceptedTerms,
openTerms: {
viewModel.loadTermsIfNeeded()
isShowingTerms = true
}
)
Button(action: registerUser) { Button(action: registerUser) {
if isLoading { if isLoading {
ProgressView() ProgressView()
@ -184,6 +193,23 @@ struct RegistrationView: View {
) )
} }
} }
.fullScreenCover(isPresented: $isShowingTerms) {
TermsFullScreenView(
isPresented: $isShowingTerms,
title: NSLocalizedString("Правила сервиса", comment: ""),
content: viewModel.termsContent,
isLoading: viewModel.isLoadingTerms,
errorMessage: viewModel.termsErrorMessage,
onRetry: {
viewModel.reloadTerms()
}
)
.onAppear {
if viewModel.termsContent.isEmpty {
viewModel.loadTermsIfNeeded()
}
}
}
} }
private func registerUser() { private func registerUser() {
@ -202,6 +228,7 @@ struct RegistrationView: View {
private func dismissSheet() { private func dismissSheet() {
focusedField = nil focusedField = nil
viewModel.hasAcceptedTerms = false
isPresented = false isPresented = false
presentationMode.wrappedValue.dismiss() presentationMode.wrappedValue.dismiss()
} }

View File

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

View File

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

View File

@ -12,7 +12,6 @@ import UIKit
struct ChatsTab: View { struct ChatsTab: View {
@ObservedObject private var loginViewModel: LoginViewModel @ObservedObject private var loginViewModel: LoginViewModel
@Binding private var pendingNavigation: IncomingMessageCenter.ChatNavigationTarget?
@Binding var searchRevealProgress: CGFloat @Binding var searchRevealProgress: CGFloat
@Binding var searchText: String @Binding var searchText: String
private let searchService = SearchService() private let searchService = SearchService()
@ -33,6 +32,7 @@ struct ChatsTab: View {
@State private var isPendingChatActive: Bool = false @State private var isPendingChatActive: Bool = false
private let searchRevealDistance: CGFloat = 90 private let searchRevealDistance: CGFloat = 90
private let scrollToTopAnchorId = "ChatsListTopAnchor"
private var currentUserId: String? { private var currentUserId: String? {
let userId = loginViewModel.userId let userId = loginViewModel.userId
@ -41,12 +41,10 @@ struct ChatsTab: View {
init( init(
loginViewModel: LoginViewModel, loginViewModel: LoginViewModel,
pendingNavigation: Binding<IncomingMessageCenter.ChatNavigationTarget?>,
searchRevealProgress: Binding<CGFloat>, searchRevealProgress: Binding<CGFloat>,
searchText: Binding<String> searchText: Binding<String>
) { ) {
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel) self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
self._pendingNavigation = pendingNavigation
self._searchRevealProgress = searchRevealProgress self._searchRevealProgress = searchRevealProgress
self._searchText = searchText self._searchText = searchText
} }
@ -100,27 +98,21 @@ struct ChatsTab: View {
globalSearchTask?.cancel() globalSearchTask?.cancel()
globalSearchTask = nil globalSearchTask = nil
} }
.onChange(of: pendingNavigation?.id) { _ in
guard let target = pendingNavigation else { return }
handleNavigationTarget(target.chat)
DispatchQueue.main.async {
pendingNavigation = nil
}
}
} }
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if viewModel.isInitialLoading && viewModel.chats.isEmpty { // if viewModel.isInitialLoading && viewModel.chats.isEmpty {
loadingState // loadingState
} else { // }
chatList chatList
}
} }
private var chatList: some View { private var chatList: some View {
ZStack { ScrollViewReader { proxy in
List { ZStack {
List {
// VStack(spacing: 0) { // VStack(spacing: 0) {
// searchBar // searchBar
// .padding(.horizontal, 16) // .padding(.horizontal, 16)
@ -128,63 +120,74 @@ struct ChatsTab: View {
// } // }
// .background(Color(UIColor.systemBackground)) // .background(Color(UIColor.systemBackground))
if let message = viewModel.errorMessage { if let message = viewModel.errorMessage {
Section { Section {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange) .foregroundColor(.orange)
Text(message) Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer(minLength: 0)
Button(action: triggerChatsReload) {
Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline) .font(.subheadline)
.foregroundColor(.orange)
Spacer(minLength: 0)
Button(action: triggerChatsReload) {
Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline)
}
} }
.padding(.vertical, 4)
} }
.padding(.vertical, 4) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
.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)
}
}
} }
Section(header: globalSearchHeader) { if isSearching {
globalSearchContent Section(header: localSearchHeader) {
} if localSearchResults.isEmpty {
} else { emptySearchResultView
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.listRowSeparator(.hidden)
} else {
let firstLocalChatId = localSearchResults.first?.chatId
ForEach(localSearchResults) { chat in
chatRowItem(for: chat)
.id(chat.chatId == firstLocalChatId ? scrollToTopAnchorId : chat.chatId)
}
}
}
Section(header: globalSearchHeader) {
globalSearchContent
}
} else {
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty { // if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
// errorState(message: message) // errorState(message: message)
// } else // } else
if viewModel.chats.isEmpty { if viewModel.isInitialLoading && viewModel.chats.isEmpty {
emptyState loadingState
} else {
ForEach(viewModel.chats) { chat in
chatRowItem(for: chat)
} }
if viewModel.isLoadingMore { if viewModel.chats.isEmpty {
loadingMoreRow emptyState
} else {
let firstChatId = viewModel.chats.first?.chatId
ForEach(viewModel.chats) { chat in
chatRowItem(for: chat)
.id(chat.chatId == firstChatId ? scrollToTopAnchorId : chat.chatId)
}
if viewModel.isLoadingMore {
loadingMoreRow
}
} }
} }
} }
} .listStyle(.plain)
.listStyle(.plain) .modifier(ScrollDismissesKeyboardModifier())
.modifier(ScrollDismissesKeyboardModifier()) .simultaneousGesture(searchBarGesture)
.simultaneousGesture(searchBarGesture) .simultaneousGesture(tapToDismissKeyboardGesture)
.simultaneousGesture(tapToDismissKeyboardGesture) .onReceive(NotificationCenter.default.publisher(for: .chatsShouldScrollToTop)) { _ in
scrollChatsToTop(using: proxy)
}
// .safeAreaInset(edge: .top) { // .safeAreaInset(edge: .top) {
// VStack(spacing: 0) { // VStack(spacing: 0) {
// searchBar // searchBar
@ -196,7 +199,8 @@ struct ChatsTab: View {
// .background(Color(UIColor.systemBackground)) // .background(Color(UIColor.systemBackground))
// } // }
pendingChatNavigationLink pendingChatNavigationLink
}
} }
} }
@ -227,6 +231,14 @@ struct ChatsTab: View {
} }
} }
private func scrollChatsToTop(using proxy: ScrollViewProxy) {
DispatchQueue.main.async {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
proxy.scrollTo(scrollToTopAnchorId, anchor: .top)
}
}
}
private var searchBarGesture: some Gesture { private var searchBarGesture: some Gesture {
DragGesture(minimumDistance: 10, coordinateSpace: .local) DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in .onChanged { value in
@ -329,8 +341,10 @@ struct ChatsTab: View {
if globalSearchResults.isEmpty { if globalSearchResults.isEmpty {
globalSearchEmptyRow globalSearchEmptyRow
} else { } else {
let firstGlobalUserId = globalSearchResults.first?.id
ForEach(globalSearchResults) { user in ForEach(globalSearchResults) { user in
globalSearchRow(for: user) globalSearchRow(for: user)
.id(user.id == firstGlobalUserId ? AnyHashable(scrollToTopAnchorId) : AnyHashable(user.id))
} }
} }
} }
@ -350,14 +364,26 @@ struct ChatsTab: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
// private var loadingState: some View {
// VStack(spacing: 12) {
// ProgressView()
// Text(NSLocalizedString("Загружаем чаты", comment: ""))
// .font(.subheadline)
// .foregroundColor(.secondary)
// }
// .frame(maxWidth: .infinity, maxHeight: .infinity)
// }
private var loadingState: some View { private var loadingState: some View {
VStack(spacing: 12) { HStack {
Spacer()
ProgressView() ProgressView()
Text(NSLocalizedString("Загружаем чаты…", comment: "")) .progressViewStyle(CircularProgressViewStyle())
.font(.subheadline) Spacer()
.foregroundColor(.secondary)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 18)
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
.listRowSeparator(.hidden)
} }
private func errorState(message: String) -> some View { private func errorState(message: String) -> some View {
@ -381,15 +407,15 @@ struct ChatsTab: View {
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Image(systemName: "bubble.left") // Image(systemName: "bubble.left")
.font(.system(size: 48)) // .font(.system(size: 48))
.foregroundColor(.secondary) // .foregroundColor(.secondary)
Text(NSLocalizedString("Пока что у вас нет чатов", comment: "")) Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button(action: triggerChatsReload) { // Button(action: triggerChatsReload) {
Text(NSLocalizedString("Обновить", comment: "")) // Text(NSLocalizedString("Обновить", comment: ""))
} // }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
.padding() .padding()
@ -452,7 +478,7 @@ struct ChatsTab: View {
} }
.hidden() .hidden()
) )
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
// .listRowSeparator(.hidden) // .listRowSeparator(.hidden)
.onAppear { .onAppear {
guard !isSearching else { return } guard !isSearching else { return }
@ -552,30 +578,6 @@ private extension ChatsTab {
#endif #endif
} }
func handleNavigationTarget(_ chatItem: PrivateChatListItem) {
dismissKeyboard()
if !searchText.isEmpty {
searchText = ""
}
if searchRevealProgress > 0 {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
searchRevealProgress = 0
}
}
let existingChat = viewModel.chats.first(where: { $0.chatId == chatItem.chatId })
pendingChatItem = existingChat ?? chatItem
selectedChatId = chatItem.chatId
isPendingChatActive = true
if existingChat == nil {
if loginViewModel.chatLoadingState != .loading {
loginViewModel.chatLoadingState = .loading
}
viewModel.refresh()
}
}
func handleSearchQueryChange(_ query: String) { func handleSearchQueryChange(_ query: String) {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
@ -1195,12 +1197,10 @@ struct ChatsTab_Previews: PreviewProvider {
@State private var progress: CGFloat = 1 @State private var progress: CGFloat = 1
@State private var searchText: String = "" @State private var searchText: String = ""
@StateObject private var loginViewModel = LoginViewModel() @StateObject private var loginViewModel = LoginViewModel()
@State private var pendingNavigation: IncomingMessageCenter.ChatNavigationTarget?
var body: some View { var body: some View {
ChatsTab( ChatsTab(
loginViewModel: loginViewModel, loginViewModel: loginViewModel,
pendingNavigation: $pendingNavigation,
searchRevealProgress: $progress, searchRevealProgress: $progress,
searchText: $searchText searchText: $searchText
) )
@ -1217,4 +1217,5 @@ extension Notification.Name {
static let debugRefreshChats = Notification.Name("debugRefreshChats") static let debugRefreshChats = Notification.Name("debugRefreshChats")
static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh") static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh")
static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted") static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted")
static let chatsShouldScrollToTop = Notification.Name("chatsShouldScrollToTop")
} }

View File

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

View File

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

View File

@ -4,6 +4,7 @@ struct MainView: View {
@ObservedObject var viewModel: LoginViewModel @ObservedObject var viewModel: LoginViewModel
@EnvironmentObject private var messageCenter: IncomingMessageCenter @EnvironmentObject private var messageCenter: IncomingMessageCenter
@State private var selectedTab: Int = 0 @State private var selectedTab: Int = 0
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
// @StateObject private var newHomeTabViewModel = NewHomeTabViewModel() // @StateObject private var newHomeTabViewModel = NewHomeTabViewModel()
// Состояния для TopBarView // Состояния для TopBarView
@ -17,14 +18,21 @@ struct MainView: View {
@State private var chatSearchRevealProgress: CGFloat = 0 @State private var chatSearchRevealProgress: CGFloat = 0
@State private var chatSearchText: String = "" @State private var chatSearchText: String = ""
@State private var isSettingsPresented = false @State private var isSettingsPresented = false
@State private var isQrPresented = false
@State private var deepLinkChatItem: PrivateChatListItem?
@State private var isDeepLinkChatActive = false
@State private var hasTriggeredSecuritySettingsOnboarding = false
@State private var isAfterRegisterPresented = false
private var tabTitle: String { private var tabTitle: String {
switch selectedTab { switch selectedTab {
case 0: return "Home" case 0: return NSLocalizedString("Home", comment: "")
case 1: return "Concept" case 1: return NSLocalizedString("Concept", comment: "")
case 2: return "Chats" case 2: return NSLocalizedString("Чаты", comment: "")
case 3: return "Profile" case 3: return NSLocalizedString("Profile", comment: "")
default: return "Home" case 4: return NSLocalizedString("Контакты", comment: "")
case 5: return NSLocalizedString("Настройки", comment: "")
default: return NSLocalizedString("Home", comment: "")
} }
} }
@ -34,49 +42,60 @@ struct MainView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
let pendingNavigationBinding: Binding<IncomingMessageCenter.ChatNavigationTarget?> = AppConfig.PRESENT_CHAT_AS_SHEET
? .constant(nil)
: Binding(
get: { messageCenter.pendingNavigation },
set: { messageCenter.pendingNavigation = $0 }
)
ZStack(alignment: .top) { ZStack(alignment: .top) {
ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю
// Основной контент // Основной контент
VStack(spacing: 0) { VStack(spacing: 0) {
TopBarView( TopBarView(
title: tabTitle, title: tabTitle,
isMessengerModeEnabled: isMessengerModeEnabled,
selectedAccount: $selectedAccount, selectedAccount: $selectedAccount,
accounts: accounts, accounts: accounts,
viewModel: viewModel, viewModel: viewModel,
isSettingsPresented: $isSettingsPresented, isSettingsPresented: $isSettingsPresented,
isQrPresented: $isQrPresented,
isSideMenuPresented: $isSideMenuPresented, isSideMenuPresented: $isSideMenuPresented,
chatSearchRevealProgress: $chatSearchRevealProgress, chatSearchRevealProgress: $chatSearchRevealProgress,
chatSearchText: $chatSearchText chatSearchText: $chatSearchText
) )
ZStack { ZStack {
NewHomeTab() if isMessengerModeEnabled {
.opacity(selectedTab == 0 ? 1 : 0) ChatsTab(
loginViewModel: viewModel,
ConceptTab() searchRevealProgress: $chatSearchRevealProgress,
.opacity(selectedTab == 1 ? 1 : 0) searchText: $chatSearchText
)
ChatsTab(
loginViewModel: viewModel,
pendingNavigation: pendingNavigationBinding,
searchRevealProgress: $chatSearchRevealProgress,
searchText: $chatSearchText
)
.opacity(selectedTab == 2 ? 1 : 0) .opacity(selectedTab == 2 ? 1 : 0)
.allowsHitTesting(selectedTab == 2) .allowsHitTesting(selectedTab == 2)
ProfileTab() ContactsTab()
.opacity(selectedTab == 3 ? 1 : 0) .opacity(selectedTab == 4 ? 1 : 0)
SettingsView(viewModel: viewModel)
.opacity(selectedTab == 5 ? 1 : 0)
} else {
NewHomeTab()
.opacity(selectedTab == 0 ? 1 : 0)
ConceptTab()
.opacity(selectedTab == 1 ? 1 : 0)
ChatsTab(
loginViewModel: viewModel,
searchRevealProgress: $chatSearchRevealProgress,
searchText: $chatSearchText
)
.opacity(selectedTab == 2 ? 1 : 0)
.allowsHitTesting(selectedTab == 2)
ProfileTab()
.opacity(selectedTab == 3 ? 1 : 0)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
CustomTabBar(selectedTab: $selectedTab) { CustomTabBar(selectedTab: $selectedTab, isMessengerModeEnabled: isMessengerModeEnabled) {
print("Create button tapped") print("Create button tapped")
} }
} }
@ -99,41 +118,49 @@ struct MainView: View {
.allowsHitTesting(menuOffset > 0) .allowsHitTesting(menuOffset > 0)
// Боковое меню // Боковое меню
SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented) if !isMessengerModeEnabled {
.frame(width: menuWidth) SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented)
.offset(x: -menuWidth + menuOffset) // Новая логика смещения .frame(width: menuWidth)
.ignoresSafeArea(edges: .vertical) .offset(x: -menuWidth + menuOffset) // Новая логика смещения
.ignoresSafeArea(edges: .vertical)
}
} }
deepLinkNavigationLink
} }
.gesture( .gesture(
DragGesture() DragGesture()
.onChanged { gesture in .onChanged { gesture in
if !isSideMenuPresented && gesture.startLocation.x > 60 { return } if !isMessengerModeEnabled {
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
let translation = gesture.translation.width
let translation = gesture.translation.width
// Определяем базовое смещение в зависимости от того, открыто меню или нет
let baseOffset = isSideMenuPresented ? menuWidth : 0 // Определяем базовое смещение в зависимости от того, открыто меню или нет
let baseOffset = isSideMenuPresented ? menuWidth : 0
// Новое смещение это база плюс текущий свайп
let newOffset = baseOffset + translation // Новое смещение это база плюс текущий свайп
let newOffset = baseOffset + translation
// Жестко ограничиваем итоговое смещение между 0 и шириной меню
self.menuOffset = max(0, min(menuWidth, newOffset)) // Жестко ограничиваем итоговое смещение между 0 и шириной меню
self.menuOffset = max(0, min(menuWidth, newOffset))
}
} }
.onEnded { gesture in .onEnded { gesture in
if !isSideMenuPresented && gesture.startLocation.x > 60 { return } if !isMessengerModeEnabled {
if !isSideMenuPresented && gesture.startLocation.x > 60 { return }
let threshold = menuWidth * 0.4
let threshold = menuWidth * 0.4
withAnimation(.easeInOut) {
if self.menuOffset > threshold { withAnimation(.easeInOut) {
isSideMenuPresented = true if self.menuOffset > threshold {
} else { isSideMenuPresented = true
isSideMenuPresented = false } else {
isSideMenuPresented = false
}
// Устанавливаем финальное смещение после анимации
self.menuOffset = isSideMenuPresented ? menuWidth : 0
} }
// Устанавливаем финальное смещение после анимации
self.menuOffset = isSideMenuPresented ? menuWidth : 0
} }
} }
) )
@ -145,20 +172,100 @@ struct MainView: View {
menuOffset = presented ? menuWidth : 0 menuOffset = presented ? menuWidth : 0
} }
} }
.onAppear {
enforceTabSelectionForMessengerMode()
handleAfterRegisterOnboardingIfNeeded()
}
.onChange(of: isMessengerModeEnabled) { _ in
enforceTabSelectionForMessengerMode()
handleAfterRegisterOnboardingIfNeeded()
}
.onChange(of: viewModel.onboardingDestination) { _ in
handleAfterRegisterOnboardingIfNeeded()
}
.onChange(of: messageCenter.pendingNavigation?.id) { _ in .onChange(of: messageCenter.pendingNavigation?.id) { _ in
guard !AppConfig.PRESENT_CHAT_AS_SHEET, guard !AppConfig.PRESENT_CHAT_AS_SHEET,
messageCenter.pendingNavigation != nil else { return } let target = messageCenter.pendingNavigation else { return }
withAnimation(.easeInOut) { withAnimation(.easeInOut) {
selectedTab = 2
isSideMenuPresented = false isSideMenuPresented = false
menuOffset = 0 menuOffset = 0
} }
if !chatSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
chatSearchText = ""
}
if chatSearchRevealProgress > 0 {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
chatSearchRevealProgress = 0
}
}
deepLinkChatItem = target.chat
isDeepLinkChatActive = true
NotificationCenter.default.post(name: .chatsShouldRefresh, object: nil)
DispatchQueue.main.async {
messageCenter.pendingNavigation = nil
}
} }
.onChange(of: selectedTab) { newValue in .onChange(of: selectedTab) { newValue in
if newValue != 3 { if newValue != 3 {
isSettingsPresented = false isSettingsPresented = false
} }
} }
.fullScreenCover(isPresented: $isAfterRegisterPresented) {
AfterRegisterView(isPresented: $isAfterRegisterPresented)
}
}
}
private extension MainView {
func enforceTabSelectionForMessengerMode() {
if isMessengerModeEnabled {
if selectedTab < 2 {
selectedTab = 2
}
} else if selectedTab > 3 {
selectedTab = 0
}
}
func handleAfterRegisterOnboardingIfNeeded() {
guard viewModel.onboardingDestination == .afterRegister else {
return
}
isAfterRegisterPresented = true
viewModel.onboardingDestination = nil
}
var deepLinkNavigationLink: some View {
NavigationLink(
destination: deepLinkChatDestination,
isActive: Binding(
get: { isDeepLinkChatActive && deepLinkChatItem != nil },
set: { newValue in
if !newValue {
isDeepLinkChatActive = false
deepLinkChatItem = nil
}
}
)
) {
EmptyView()
}
.hidden()
}
@ViewBuilder
var deepLinkChatDestination: some View {
if let chatItem = deepLinkChatItem {
PrivateChatView(
chat: chatItem,
currentUserId: messageCenter.currentUserId
)
.id(chatItem.chatId)
} else {
EmptyView()
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ struct SettingsView: View {
@ObservedObject var viewModel: LoginViewModel @ObservedObject var viewModel: LoginViewModel
@EnvironmentObject private var themeManager: ThemeManager @EnvironmentObject private var themeManager: ThemeManager
@State private var isThemeExpanded = false @State private var isThemeExpanded = false
@State private var isSecurityActive = false
private let themeOptions = ThemeOption.ordered private let themeOptions = ThemeOption.ordered
private var selectedThemeOption: ThemeOption { private var selectedThemeOption: ThemeOption {
@ -18,21 +19,31 @@ struct SettingsView: View {
// Label("Мой профиль", systemImage: "person.crop.circle") // Label("Мой профиль", systemImage: "person.crop.circle")
// } // }
NavigationLink(destination: EditPrivacyView()) { NavigationLink(destination: EditProfileView()) {
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill") Label(NSLocalizedString("Редактировать профиль", comment: ""), systemImage: "person.crop.circle")
}
NavigationLink(destination: BlockedUsersView()) {
Label(NSLocalizedString("Чёрный список", comment: ""), systemImage: "hand.raised.fill")
} }
} }
// MARK: - Безопасность // MARK: - Безопасность
Section(header: Text(NSLocalizedString("Безопасность", comment: ""))) { Section(header: Text(NSLocalizedString("Безопасность", comment: ""))) {
NavigationLink(destination: EditPrivacyView()) {
Label(NSLocalizedString("Конфиденциальность", comment: ""), systemImage: "lock.fill")
}
NavigationLink(destination: ChangePasswordView()) { NavigationLink(destination: ChangePasswordView()) {
Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key") Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key")
} }
NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) { NavigationLink(destination: ActiveSessionsView()) {
Label("Двухфакторная аутентификация", systemImage: "lock.shield") Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
} }
NavigationLink(destination: Text("Заглушка: Активные сессии")) { NavigationLink(isActive: $isSecurityActive) {
Label("Активные сессии", systemImage: "iphone") SecuritySettingsView(viewModel: viewModel)
} label: {
Label(NSLocalizedString("Безопасность", comment: ""), systemImage: "lock.shield")
} }
} }
@ -60,7 +71,7 @@ struct SettingsView: View {
Label("Данные", systemImage: "externaldrive") Label("Данные", systemImage: "externaldrive")
} }
NavigationLink(destination: Text("Заглушка: Другие настройки")) { NavigationLink(destination: OtherSettingsView()) {
Label("Другое", systemImage: "ellipsis.circle") Label("Другое", systemImage: "ellipsis.circle")
} }
} }

View File

@ -18,9 +18,3 @@ struct AppConfig {
/// Fallback SQLCipher key used until the user sets an application password. /// Fallback SQLCipher key used until the user sets an application password.
static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me" static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
} }
struct AppInfo {
static let text_1 = "\(NSLocalizedString("profile_down_text_1", comment: "")) yobble"
static let text_2 = "\(NSLocalizedString("profile_down_text_2", comment: "")) 0.1test"
static let text_3 = "\(NSLocalizedString("profile_down_text_3", comment: ""))2025"
}