Compare commits
84 Commits
test-view-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cbbf4777d | |||
| 643466d878 | |||
| f14ff3293d | |||
| f22bce0e74 | |||
| 7a10ba5b33 | |||
| 8568f6c20e | |||
| 526a57b556 | |||
| 7f73216936 | |||
| 128ed5723a | |||
| 0a7d519567 | |||
| 9f6beecb49 | |||
| 3e9d6696b0 | |||
| 3f0543aa3a | |||
| 052ff5fe4f | |||
| 3c394446d2 | |||
| 9f2a938b1e | |||
| 7a2fb798a3 | |||
| b46fc3ae16 | |||
| b466864350 | |||
| 107318ef21 | |||
| e3cf374893 | |||
| 6eed966fc9 | |||
| 58c841b5c7 | |||
| 854561b5f7 | |||
| 020aa8de5d | |||
| be6394f6fb | |||
| cf5d2ad7fb | |||
| 26534e88c1 | |||
| 7034503983 | |||
| dd2abde5b8 | |||
| e135556fa6 | |||
| 910eef3703 | |||
| e6d7258b70 | |||
| 374bd1713b | |||
| aac0a25c4d | |||
| e79cbd7ea4 | |||
| 813795aece | |||
| 2eabbd59c3 | |||
| 43a5d8193d | |||
| 6b81860960 | |||
| 8acacdb8c1 | |||
| 1c9f249289 | |||
| 198b51bd91 | |||
| 40a5f4c628 | |||
| d692c7c984 | |||
| 3ae7576c24 | |||
| 52cf7e3b1c | |||
| 85fb780c96 | |||
| a28402136d | |||
| 140e82e122 | |||
| 726a6983b2 | |||
| 2f8c1f3514 | |||
| fa1637a5af | |||
| b47922694d | |||
| adba8fc568 | |||
| 2000ddadc2 | |||
| 9e95f9c9d9 | |||
| 9460024734 | |||
| b0888c2921 | |||
| 61e1feb8bd | |||
| de2d7c4020 | |||
| 91a3117595 | |||
| 5f03feba66 | |||
| b267e5a999 | |||
| b9ea0807e5 | |||
| 055c57c208 | |||
| bbed505033 | |||
| ee4f783fe7 | |||
| 93c865f5ca | |||
| 2d3299fe96 | |||
| 2c31d25596 | |||
| c6e17f0fc5 | |||
| 9685674056 | |||
| c1e39128fb | |||
| 331ec94ede | |||
| aa3e619d37 | |||
| 4442a40aac | |||
| 5e419e8b0f | |||
| 67125b230f | |||
| 44f7336c8d | |||
| 266742e15d | |||
| 6d8b322688 | |||
| edbf4faf00 | |||
| 1bc4dda14c |
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]
|
||||||
|
}
|
||||||
|
|||||||
@ -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: "")
|
||||||
}
|
}
|
||||||
|
|||||||
231
yobble/Network/BlockedUsersService.swift
Normal 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
|
||||||
|
}
|
||||||
173
yobble/Network/ContactsService.swift
Normal 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
|
||||||
|
}()
|
||||||
|
}
|
||||||
265
yobble/Network/SessionsService.swift
Normal 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
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 534 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 720 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1018 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 13 KiB |
@ -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" : {
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
102
yobble/Views/Login/TermsViews.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
72
yobble/Views/Tab/AfterRegisterView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
326
yobble/Views/Tab/ContactsTab.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
yobble/Views/Tab/QrView.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct QrView: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
|
||||||
|
}
|
||||||
|
.navigationTitle("Qr")
|
||||||
|
}
|
||||||
|
}
|
||||||
283
yobble/Views/Tab/Settings/BlockedUsersView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
27
yobble/Views/Tab/Settings/OtherSettingsView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
382
yobble/Views/Tab/Settings/Security/ActiveSessionsView.swift
Normal 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
|
||||||
|
}
|
||||||
82
yobble/Views/Tab/Settings/Security/AppLockSettingsView.swift
Normal 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
|
||||||
@ -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
|
||||||
226
yobble/Views/Tab/Settings/Security/TwoFactorAuthView.swift
Normal 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
|
||||||
76
yobble/Views/Tab/Settings/SecuritySettingsView.swift
Normal 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
|
||||||
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
|
||||||
|
|||||||