diff --git a/yobble.xcodeproj/project.pbxproj b/yobble.xcodeproj/project.pbxproj
index 0796202..605aa74 100644
--- a/yobble.xcodeproj/project.pbxproj
+++ b/yobble.xcodeproj/project.pbxproj
@@ -192,6 +192,7 @@
knownRegions = (
en,
Base,
+ ru,
);
mainGroup = 1A6D61C32E7CD03E00B9F736;
minimizedProjectReferenceProxies = 1;
@@ -327,6 +328,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
@@ -382,6 +384,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_EMIT_LOC_STRINGS = YES;
};
name = Release;
};
@@ -397,6 +400,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -407,11 +411,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- IPHONEOS_DEPLOYMENT_TARGET = 18.5;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
- MACOSX_DEPLOYMENT_TARGET = 15.5;
- MARKETING_VERSION = 1.0;
+ MACOSX_DEPLOYMENT_TARGET = 11.5;
+ MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = org.yobble.yobble;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -420,7 +424,7 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
- XROS_DEPLOYMENT_TARGET = 2.5;
+ XROS_DEPLOYMENT_TARGET = 1.3;
};
name = Debug;
};
@@ -436,6 +440,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -446,11 +451,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- IPHONEOS_DEPLOYMENT_TARGET = 18.5;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
- MACOSX_DEPLOYMENT_TARGET = 15.5;
- MARKETING_VERSION = 1.0;
+ MACOSX_DEPLOYMENT_TARGET = 11.5;
+ MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = org.yobble.yobble;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -459,7 +464,7 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
- XROS_DEPLOYMENT_TARGET = 2.5;
+ XROS_DEPLOYMENT_TARGET = 1.3;
};
name = Release;
};
diff --git a/yobble.xcodeproj/xcshareddata/xcschemes/yobble.xcscheme b/yobble.xcodeproj/xcshareddata/xcschemes/yobble.xcscheme
new file mode 100644
index 0000000..a69cd71
--- /dev/null
+++ b/yobble.xcodeproj/xcshareddata/xcschemes/yobble.xcscheme
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/yobble.xcodeproj/xcuserdata/cheykrym.xcuserdatad/xcschemes/xcschememanagement.plist b/yobble.xcodeproj/xcuserdata/cheykrym.xcuserdatad/xcschemes/xcschememanagement.plist
index dcc224a..20f6480 100644
--- a/yobble.xcodeproj/xcuserdata/cheykrym.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/yobble.xcodeproj/xcuserdata/cheykrym.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -10,5 +10,23 @@
0
+ SuppressBuildableAutocreation
+
+ 1A6D61CB2E7CD03E00B9F736
+
+ primary
+
+
+ 1A6D61D92E7CD04000B9F736
+
+ primary
+
+
+ 1A6D61E32E7CD04100B9F736
+
+ primary
+
+
+
diff --git a/yobble/Assets.xcassets/AppIcon.appiconset/Contents.json b/yobble/Assets.xcassets/AppIcon.appiconset/Contents.json
deleted file mode 100644
index ffdfe15..0000000
--- a/yobble/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ /dev/null
@@ -1,85 +0,0 @@
-{
- "images" : [
- {
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- },
- {
- "appearances" : [
- {
- "appearance" : "luminosity",
- "value" : "dark"
- }
- ],
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- },
- {
- "appearances" : [
- {
- "appearance" : "luminosity",
- "value" : "tinted"
- }
- ],
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- },
- {
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "16x16"
- },
- {
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "16x16"
- },
- {
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "32x32"
- },
- {
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "32x32"
- },
- {
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "128x128"
- },
- {
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "128x128"
- },
- {
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "256x256"
- },
- {
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "256x256"
- },
- {
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "512x512"
- },
- {
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "512x512"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/yobble/Network/AuthService.swift b/yobble/Network/AuthService.swift
new file mode 100644
index 0000000..c193d60
--- /dev/null
+++ b/yobble/Network/AuthService.swift
@@ -0,0 +1,294 @@
+//
+// AuthService.swift
+// VolnahubApp
+//
+// Created by cheykrym on 09/06/2025.
+//
+
+import Foundation
+
+class AuthService {
+
+ func autoLogin(completion: @escaping (Bool, String?) -> Void) {
+ // 1️⃣ Проверяем наличие текущего пользователя
+ if let currentUser = UserDefaults.standard.string(forKey: "currentUser"),
+ let _ = KeychainService.shared.get(forKey: "access_token", service: currentUser),
+ let _ = KeychainService.shared.get(forKey: "refresh_token", service: currentUser) {
+ if AppConfig.DEBUG{ print("AutoLogin: найден текущий пользователь — \(currentUser)")}
+ completion(true, nil)
+ return
+ }
+
+ // 2️⃣ Текущий пользователь не найден или токены отсутствуют
+ if AppConfig.DEBUG{ print("AutoLogin: текущий пользователь не найден или токены отсутствуют. Пробуем найти другого пользователя...")}
+
+ let allUsers = KeychainService.shared.getAllServices()
+
+ for user in allUsers {
+ let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil
+ let hasRefreshToken = KeychainService.shared.get(forKey: "refresh_token", service: user) != nil
+
+ if hasAccessToken && hasRefreshToken {
+ // Нашли пользователя с токенами — назначаем как currentUser
+ UserDefaults.standard.set(user, forKey: "currentUser")
+ if AppConfig.DEBUG{ print("AutoLogin: переключились на пользователя \(user)")}
+ completion(true, nil)
+ return
+ }
+ }
+
+ // 3️⃣ Если никто не найден
+// completion(false, "Не найден авторизованный пользователь. Пожалуйста, войдите снова.")
+ completion(false, nil)
+ }
+
+
+ func login(username: String, password: String, completion: @escaping (Bool, String?) -> Void) {
+ let url = URL(string: "\(AppConfig.API_SERVER)/v1/auth/login")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("\(AppConfig.USER_AGENT)", forHTTPHeaderField: "User-Agent")
+
+ let payload: [String: String] = [
+ "login": username,
+ "password": password
+ ]
+
+ do {
+ let jsonData = try JSONEncoder().encode(payload)
+ request.httpBody = jsonData
+ } catch {
+ DispatchQueue.main.async {
+ completion(false, NSLocalizedString("AuthService_error_serialization", comment: ""))
+ }
+ return
+ }
+
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
+ DispatchQueue.main.async {
+ if let error = error {
+ let errorMessage = String(format: NSLocalizedString("AuthService_error_network", comment: ""), error.localizedDescription)
+ completion(false, errorMessage)
+ return
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ completion(false, NSLocalizedString("AuthService_error_invalid_response", comment: ""))
+ return
+ }
+
+ guard (200...299).contains(httpResponse.statusCode) else {
+ if httpResponse.statusCode == 401{
+ completion(false, NSLocalizedString("AuthService_error_invalid_credentials", comment: ""))
+ } else if httpResponse.statusCode == 502{
+ completion(false, NSLocalizedString("AuthService_error_server_unavailable", comment: ""))
+ } else if httpResponse.statusCode == 429 {
+ completion(false, NSLocalizedString("AuthService_error_too_many_requests", comment: ""))
+ } else {
+ let errorMessage = String(format: NSLocalizedString("AuthService_error_server_error", comment: ""), "\(httpResponse.statusCode)")
+ completion(false, errorMessage)
+ }
+ return
+ }
+
+ guard let data = data else {
+ completion(false, NSLocalizedString("AuthService_error_empty_response", comment: ""))
+ return
+ }
+
+ do {
+ let decoder = JSONDecoder()
+ let loginResponse = try decoder.decode(LoginResponse.self, from: data)
+
+ // Сохраняем токены в Keychain
+ KeychainService.shared.save(loginResponse.access_token, forKey: "access_token", service: username)
+ KeychainService.shared.save(loginResponse.refresh_token, forKey: "refresh_token", service: username)
+ UserDefaults.standard.set(username, forKey: "currentUser")
+
+ completion(true, nil)
+ } catch {
+ completion(false, NSLocalizedString("AuthService_error_parsing_response", comment: ""))
+ }
+ }
+ }
+ task.resume()
+ }
+
+ func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
+ let url = URL(string: "\(AppConfig.API_SERVER)/v1/auth/register")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue(AppConfig.USER_AGENT, forHTTPHeaderField: "User-Agent")
+
+ let payload: [String: Any] = [
+ "login": username,
+ "password": password,
+ "invite": invite ?? NSNull()
+ ]
+
+ do {
+ let jsonData = try JSONSerialization.data(withJSONObject: payload)
+ request.httpBody = jsonData
+ } catch {
+ DispatchQueue.main.async {
+ completion(false, NSLocalizedString("AuthService_error_serialization", comment: ""))
+ }
+ return
+ }
+
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
+ DispatchQueue.main.async {
+ if let error = error {
+ let errorMessage = String(format: NSLocalizedString("AuthService_error_network", comment: ""), error.localizedDescription)
+ completion(false, errorMessage)
+ return
+ }
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ completion(false, NSLocalizedString("AuthService_error_invalid_response", comment: ""))
+ return
+ }
+
+ guard let data = data else {
+ completion(false, NSLocalizedString("AuthService_error_empty_response", comment: ""))
+ return
+ }
+
+ let decoder = JSONDecoder()
+
+ if (200...299).contains(httpResponse.statusCode) {
+ do {
+ let _ = try decoder.decode(RegisterResponse.self, from: data)
+ if AppConfig.DEBUG{ print("Регистрация успешна. Пытаемся сразу войти...")}
+
+ // Сразу логинимся
+ self.login(username: username, password: password) { loginSuccess, loginMessage in
+ if loginSuccess {
+ completion(true, "Регистрация и вход выполнены успешно.")
+ } else {
+ // Регистрация успешна, но логин не удался — покажем сообщение
+ completion(false, loginMessage ?? NSLocalizedString("AuthService_login_success_but_failed", comment: ""))
+ }
+ }
+ } catch {
+ completion(false, NSLocalizedString("AuthService_error_parsing_response", comment: ""))
+ }
+ } else {
+ // Ошибка сервера — пробуем распарсить message
+ if let errorResponseMessage = try? decoder.decode(ErrorResponseMessage.self, from: data),
+ let message = errorResponseMessage.message {
+
+ if let jsonString = String(data: data, encoding: .utf8) {
+ if AppConfig.DEBUG{ print("Raw JSON:", jsonString)}
+ }
+ if AppConfig.DEBUG{ print("message:", message)}
+
+ if httpResponse.statusCode == 400 {
+ if message.contains("Invalid invitation code") {
+ completion(false, NSLocalizedString("AuthService_error_invalid_invitation_code", comment: ""))
+ } else if message.contains("This invitation is not active") {
+ completion(false, NSLocalizedString("AuthService_error_invitation_not_active", comment: ""))
+ } else if message.contains("This invitation has reached its usage limit") {
+ completion(false, NSLocalizedString("AuthService_error_invitation_usage_limit", comment: ""))
+ } else if message.contains("This invitation has expired") {
+ completion(false, NSLocalizedString("AuthService_error_invitation_expired", comment: ""))
+ } else if message.contains("Login already registered") {
+ completion(false, NSLocalizedString("AuthService_error_login_already_registered", comment: ""))
+ } else {
+ completion(false, message)
+ }
+ } else if httpResponse.statusCode == 403 {
+ if message.contains("Registration is currently disabled") {
+ completion(false, NSLocalizedString("AuthService_error_registration_disabled", comment: ""))
+ } else {
+ completion(false, message)
+ }
+ } else if httpResponse.statusCode == 429 {
+ completion(false, NSLocalizedString("AuthService_error_too_many_requests", comment: ""))
+ } else if httpResponse.statusCode == 502{
+ completion(false, NSLocalizedString("AuthService_error_server_unavailable", comment: ""))
+ } else {
+ let errorMessage = String(format: NSLocalizedString("AuthService_error_server_error", comment: ""), "\(httpResponse.statusCode)")
+ completion(false, errorMessage)
+ }
+ } else {
+ // Не удалось распарсить JSON — fallback
+ if httpResponse.statusCode == 400 {
+ completion(false, NSLocalizedString("AuthService_error_invalid_request", comment: ""))
+ } else if httpResponse.statusCode == 403 {
+ completion(false, NSLocalizedString("AuthService_error_registration_forbidden", comment: ""))
+ } else if httpResponse.statusCode == 429 {
+ completion(false, NSLocalizedString("AuthService_error_too_many_requests", comment: ""))
+ } else if httpResponse.statusCode == 502{
+ completion(false, NSLocalizedString("AuthService_error_server_unavailable", comment: ""))
+ } else {
+ let errorMessage = String(format: NSLocalizedString("AuthService_error_server_error", comment: ""), "\(httpResponse.statusCode)")
+ completion(false, errorMessage)
+ }
+ }
+ }
+ }
+ }
+ task.resume()
+ }
+
+
+
+
+ func logoutCurrentUser(completion: @escaping (Bool, String?) -> Void) {
+ guard let currentUser = UserDefaults.standard.string(forKey: "currentUser") else {
+ completion(false, "Не найден текущий пользователь.")
+ return
+ }
+
+ // Удаляем токены текущего пользователя
+ KeychainService.shared.delete(forKey: "access_token", service: currentUser)
+ KeychainService.shared.delete(forKey: "refresh_token", service: currentUser)
+
+ // Сбрасываем текущего пользователя
+ UserDefaults.standard.removeObject(forKey: "currentUser")
+
+ // Пробуем переключиться на другого пользователя
+ let allUsers = KeychainService.shared.getAllServices()
+ for user in allUsers {
+ let hasAccessToken = KeychainService.shared.get(forKey: "access_token", service: user) != nil
+ let hasRefreshToken = KeychainService.shared.get(forKey: "refresh_token", service: user) != nil
+
+ if hasAccessToken && hasRefreshToken {
+ UserDefaults.standard.set(user, forKey: "currentUser")
+ if AppConfig.DEBUG{ print("Logout: переключились на пользователя \(user)")}
+ completion(true, nil)
+ return
+ }
+ }
+
+ // Если пользователей больше нет
+// completion(false, "Нет доступных пользователей. Пожалуйста, войдите снова.")
+ completion(false, nil)
+ }
+}
+
+struct LoginResponse: Decodable {
+ let status: String
+ let access_token: String
+ let refresh_token: String
+ let token_type: String
+}
+
+struct TokenRefreshResponse: Decodable {
+ let status: String
+ let access_token: String
+ let token_type: String
+}
+
+struct RegisterResponse: Decodable {
+ let status: String
+ let message: String
+}
+
+struct ErrorResponseMessage: Decodable {
+ let status: String?
+ let message: String?
+}
diff --git a/yobble/Assets.xcassets/AccentColor.colorset/Contents.json b/yobble/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
similarity index 100%
rename from yobble/Assets.xcassets/AccentColor.colorset/Contents.json
rename to yobble/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/100.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/100.png
new file mode 100644
index 0000000..e7e35bd
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/100.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/102.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/102.png
new file mode 100644
index 0000000..da01594
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/102.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/1024.png
new file mode 100644
index 0000000..727dd02
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/1024.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/108.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/108.png
new file mode 100644
index 0000000..3c616dd
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/108.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/114.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/114.png
new file mode 100644
index 0000000..165f9c4
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/114.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/120.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/120.png
new file mode 100644
index 0000000..a81e0c9
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/120.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/128.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/128.png
new file mode 100644
index 0000000..1700f66
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/128.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/144.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/144.png
new file mode 100644
index 0000000..a94cff4
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/144.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/152.png
new file mode 100644
index 0000000..1124f64
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/16.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/16.png
new file mode 100644
index 0000000..e156c51
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/16.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/167.png
new file mode 100644
index 0000000..216eb70
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/167.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/172.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/172.png
new file mode 100644
index 0000000..7396acf
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/172.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/180.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/180.png
new file mode 100644
index 0000000..7ba99db
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/180.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/196.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/196.png
new file mode 100644
index 0000000..ce2a027
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/196.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/20.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/20.png
new file mode 100644
index 0000000..3a1ec69
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/20.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/216.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/216.png
new file mode 100644
index 0000000..d4c6e7e
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/216.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/234.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/234.png
new file mode 100644
index 0000000..9ee82ca
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/234.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/256.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/256.png
new file mode 100644
index 0000000..5a12362
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/256.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/258.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/258.png
new file mode 100644
index 0000000..624e507
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/258.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/29.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/29.png
new file mode 100644
index 0000000..eb70042
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/29.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/32.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/32.png
new file mode 100644
index 0000000..564d950
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/32.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/40.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/40.png
new file mode 100644
index 0000000..82632ff
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/40.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/48.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/48.png
new file mode 100644
index 0000000..936fc8d
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/48.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/50.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/50.png
new file mode 100644
index 0000000..f2eaa52
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/50.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/512.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/512.png
new file mode 100644
index 0000000..08d2ac6
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/512.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/55.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/55.png
new file mode 100644
index 0000000..b36a5cc
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/55.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/57.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/57.png
new file mode 100644
index 0000000..2a9e598
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/57.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/58.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/58.png
new file mode 100644
index 0000000..fd7632e
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/58.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/60.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/60.png
new file mode 100644
index 0000000..03ad2c0
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/60.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/64.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/64.png
new file mode 100644
index 0000000..782e386
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/64.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/66.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/66.png
new file mode 100644
index 0000000..8349796
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/66.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/72.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/72.png
new file mode 100644
index 0000000..f57d039
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/72.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/76.png
new file mode 100644
index 0000000..b2a9d7e
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/80.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/80.png
new file mode 100644
index 0000000..8377493
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/80.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/87.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/87.png
new file mode 100644
index 0000000..a2342a7
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/87.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/88.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/88.png
new file mode 100644
index 0000000..31f78b8
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/88.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/92.png b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/92.png
new file mode 100644
index 0000000..c5ee038
Binary files /dev/null and b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/92.png differ
diff --git a/yobble/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..1319290
--- /dev/null
+++ b/yobble/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1 @@
+{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]}
\ No newline at end of file
diff --git a/yobble/Assets.xcassets/Contents.json b/yobble/Resources/Assets.xcassets/Contents.json
similarity index 100%
rename from yobble/Assets.xcassets/Contents.json
rename to yobble/Resources/Assets.xcassets/Contents.json
diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings
new file mode 100644
index 0000000..b5fe91f
--- /dev/null
+++ b/yobble/Resources/Localizable.xcstrings
@@ -0,0 +1,154 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "🌍" : {
+
+ },
+ "AuthService_error_empty_response" : {
+
+ },
+ "AuthService_error_invalid_credentials" : {
+
+ },
+ "AuthService_error_invalid_invitation_code" : {
+
+ },
+ "AuthService_error_invalid_request" : {
+
+ },
+ "AuthService_error_invalid_response" : {
+
+ },
+ "AuthService_error_invitation_expired" : {
+
+ },
+ "AuthService_error_invitation_not_active" : {
+
+ },
+ "AuthService_error_invitation_usage_limit" : {
+
+ },
+ "AuthService_error_login_already_registered" : {
+
+ },
+ "AuthService_error_network" : {
+
+ },
+ "AuthService_error_parsing_response" : {
+
+ },
+ "AuthService_error_registration_disabled" : {
+
+ },
+ "AuthService_error_registration_forbidden" : {
+
+ },
+ "AuthService_error_serialization" : {
+
+ },
+ "AuthService_error_server_error" : {
+
+ },
+ "AuthService_error_server_unavailable" : {
+
+ },
+ "AuthService_error_too_many_requests" : {
+
+ },
+ "AuthService_login_success_but_failed" : {
+
+ },
+ "Hello, world!" : {
+
+ },
+ "loading_placeholder" : {
+
+ },
+ "LoginView_button_login" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ебите меня четверо"
+ }
+ }
+ }
+ },
+ "ok" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "55454545"
+ }
+ }
+ }
+ },
+ "OK" : {
+
+ },
+ "profile_down_text_1" : {
+
+ },
+ "profile_down_text_2" : {
+
+ },
+ "profile_down_text_3" : {
+
+ },
+ "Войти" : {
+
+ },
+ "Закрыть" : {
+ "comment" : "Закрыть"
+ },
+ "Зарегистрироваться" : {
+ "comment" : "Зарегистрироваться"
+ },
+ "Инвайт-код (необязательно)" : {
+ "comment" : "Инвайт-код"
+ },
+ "Логин" : {
+ "comment" : "Логин"
+ },
+ "Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)" : {
+ "comment" : "Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)"
+ },
+ "Неверный логин" : {
+ "comment" : "Неверный логин"
+ },
+ "Неверный пароль" : {
+ "comment" : "Неверный пароль"
+ },
+ "Неизвестная ошибка." : {
+
+ },
+ "Нет аккаунта? Регистрация" : {
+ "comment" : "Регистрация"
+ },
+ "Ошибка авторизации" : {
+
+ },
+ "Ошибка регистрация" : {
+ "comment" : "Ошибка"
+ },
+ "Пароли не совпадают" : {
+ "comment" : "Пароли не совпадают"
+ },
+ "Пароль" : {
+ "comment" : "Пароль"
+ },
+ "Пароль должен быть от 8 до 128 символов" : {
+ "comment" : "Пароль должен быть от 6 до 32 символов"
+ },
+ "Подтверждение пароля" : {
+ "comment" : "Подтверждение пароля"
+ },
+ "Регистрация" : {
+ "comment" : "Регистрация"
+ }
+ },
+ "version" : "1.0"
+}
\ No newline at end of file
diff --git a/yobble/Services/KeychainService.swift b/yobble/Services/KeychainService.swift
new file mode 100644
index 0000000..14f7503
--- /dev/null
+++ b/yobble/Services/KeychainService.swift
@@ -0,0 +1,113 @@
+import Foundation
+import Security
+
+
+//let username = "user1"
+
+// Сохраняем токены
+//KeychainService.shared.save("access_token_value", forKey: "access_token", service: username)
+//KeychainService.shared.save("refresh_token_value", forKey: "refresh_token", service: username)
+
+// Получаем токены
+//let accessToken = KeychainService.shared.get(forKey: "access_token", service: username)
+//let refreshToken = KeychainService.shared.get(forKey: "refresh_token", service: username)
+
+// получение всех пользователей
+//let users = KeychainService.shared.getAllServices()
+//print("Все пользователи: \(users)")
+
+// Удаление всех пользователей
+//KeychainService.shared.deleteAll()
+
+// удаление по одному
+//KeychainService.shared.delete(forKey: "access_token", service: username)
+//KeychainService.shared.delete(forKey: "refresh_token", service: username)
+
+
+class KeychainService {
+
+ static let shared = KeychainService()
+
+ private init() {}
+
+ func save(_ value: String, forKey key: String, service: String) {
+ guard let data = value.data(using: .utf8) else { return }
+
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service, // ключ группировки
+ kSecAttrAccount as String: key,
+ kSecValueData as String: data
+ ]
+
+ SecItemDelete(query as CFDictionary)
+ let status = SecItemAdd(query as CFDictionary, nil)
+ if status != errSecSuccess {
+ print("Error saving to Keychain: \(status)")
+ }
+ }
+
+ func get(forKey key: String, service: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+
+ var dataTypeRef: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
+ if status == errSecSuccess,
+ let data = dataTypeRef as? Data,
+ let value = String(data: data, encoding: .utf8) {
+ return value
+ }
+ return nil
+ }
+
+ func getAllServices() -> [String] {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecReturnAttributes as String: true,
+ kSecMatchLimit as String: kSecMatchLimitAll
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ guard status == errSecSuccess, let items = result as? [[String: Any]] else {
+ return []
+ }
+
+ // Собираем все уникальные service (username)
+ var services = Set()
+ for item in items {
+ if let service = item[kSecAttrService as String] as? String {
+ services.insert(service)
+ }
+ }
+
+ return Array(services)
+ }
+
+ func delete(forKey key: String, service: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+
+ /// Удалить все записи Keychain, сохранённые этим приложением
+ func deleteAll() {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword
+ ]
+ let status = SecItemDelete(query as CFDictionary)
+ if status != errSecSuccess && status != errSecItemNotFound {
+ print("Error deleting all Keychain items: \(status)")
+ }
+ }
+}
diff --git a/yobble/Services/ThemeManager.swift b/yobble/Services/ThemeManager.swift
new file mode 100644
index 0000000..1152117
--- /dev/null
+++ b/yobble/Services/ThemeManager.swift
@@ -0,0 +1,53 @@
+import SwiftUI
+
+// Enum to represent the three theme options
+enum Theme: String, CaseIterable {
+ case system = "System"
+ case light = "Light"
+ case dark = "Dark"
+
+ var colorScheme: ColorScheme? {
+ switch self {
+ case .system:
+ return nil
+ case .light:
+ return .light
+ case .dark:
+ return .dark
+ }
+ }
+}
+
+// Observable class to manage the theme state
+class ThemeManager: ObservableObject {
+ @AppStorage("selectedTheme") private var selectedThemeValue: String = Theme.system.rawValue
+
+ @Published var theme: Theme
+
+ init() {
+ // Read directly from UserDefaults to avoid using self before initialization is complete.
+ let storedThemeValue = UserDefaults.standard.string(forKey: "selectedTheme") ?? ""
+ self.theme = Theme(rawValue: storedThemeValue) ?? .system
+ }
+
+ func setTheme(_ theme: Theme) {
+ self.theme = theme
+ selectedThemeValue = theme.rawValue
+ }
+
+ // This will be called from the button
+ func toggleTheme(from currentSystemScheme: ColorScheme) {
+ let newTheme: Theme
+
+ switch theme {
+ case .system:
+ // If system is active, toggle to the opposite of the current system theme
+ newTheme = currentSystemScheme == .dark ? .light : .dark
+ case .light:
+ newTheme = .dark
+ case .dark:
+ newTheme = .light
+ }
+ setTheme(newTheme)
+ }
+}
diff --git a/yobble/ViewModels/LoginViewModel.swift b/yobble/ViewModels/LoginViewModel.swift
new file mode 100644
index 0000000..4ec02b9
--- /dev/null
+++ b/yobble/ViewModels/LoginViewModel.swift
@@ -0,0 +1,103 @@
+//
+// LoginViewModel.swift
+// VolnahubApp
+//
+// Created by cheykrym on 09/06/2025.
+//
+
+import Foundation
+import Combine
+
+class LoginViewModel: ObservableObject {
+ @Published var username: String = ""
+ @Published var password: String = ""
+ @Published var isLoading: Bool = true // сразу true, чтобы показать спиннер при автологине
+ @Published var showError: Bool = false
+ @Published var errorMessage: String = ""
+ @Published var isLoggedIn: Bool = false
+
+ private let authService = AuthService()
+
+ init() {
+ // Если username сохранён, подставим его сразу
+ if let savedUsername = UserDefaults.standard.string(forKey: "currentUser") {
+ username = savedUsername
+ }
+
+ // Запускаем автологин
+ autoLogin()
+ }
+
+ func autoLogin() {
+ authService.autoLogin { [weak self] success, error in
+ DispatchQueue.main.async {
+// self?.isLoading = false
+ if success {
+ self?.isLoggedIn = true
+ } else {
+ self?.isLoggedIn = false
+ self?.errorMessage = error ?? "Произошла ошибка."
+ self?.showError = false
+ }
+ self?.isLoading = false
+ }
+ }
+ }
+
+
+ func login() {
+ isLoading = true
+ showError = false
+
+ authService.login(username: username, password: password) { [weak self] success, error in
+ DispatchQueue.main.async {
+ self?.isLoading = false
+ if success {
+ self?.isLoggedIn = true
+ } else {
+ self?.errorMessage = error ?? "Неизвестная ошибка"
+ self?.showError = true
+ }
+ }
+ }
+ }
+
+ func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
+ authService.register(username: username, password: password, invite: invite) { [weak self] success, message in
+ DispatchQueue.main.async {
+ if success {
+ self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина
+ }
+ completion(success, message)
+ }
+ }
+ }
+
+ func logoutCurrentUser() {
+ authService.logoutCurrentUser { [weak self] success, error in
+ DispatchQueue.main.async {
+ if success {
+ self?.username = UserDefaults.standard.string(forKey: "currentUser") ?? ""
+ self?.password = ""
+ self?.isLoggedIn = true
+ self?.showError = false
+ } else {
+ self?.username = ""
+ self?.password = ""
+ self?.isLoggedIn = false
+ self?.errorMessage = error ?? "Ошибка при деавторизации."
+ self?.showError = false
+ }
+ }
+ }
+ }
+
+
+// func logout() {
+// username = ""
+// password = ""
+// isLoggedIn = false
+// showError = false
+// errorMessage = ""
+// }
+}
diff --git a/yobble/Views/Login/LoginView.swift b/yobble/Views/Login/LoginView.swift
new file mode 100644
index 0000000..d7c15b3
--- /dev/null
+++ b/yobble/Views/Login/LoginView.swift
@@ -0,0 +1,172 @@
+//
+// LoginView.swift
+// VolnahubApp
+//
+// Created by cheykrym on 09/06/2025.
+//
+
+import SwiftUI
+
+struct LoginView: View {
+ @ObservedObject var viewModel: LoginViewModel
+ @AppStorage("isDarkMode") private var isDarkMode: Bool = true
+
+ @State private var isShowingRegistration = false
+
+ private var isUsernameValid: Bool {
+ let pattern = "^[A-Za-z0-9_]{3,32}$"
+ return viewModel.username.range(of: pattern, options: .regularExpression) != nil
+ }
+
+ private var isPasswordValid: Bool {
+ return viewModel.password.count >= 8 && viewModel.password.count <= 128
+ }
+
+ var body: some View {
+
+ ZStack {
+ Color.clear // чтобы поймать тап
+ .contentShape(Rectangle())
+ .onTapGesture {
+ hideKeyboard()
+ }
+
+ VStack {
+ HStack {
+
+ Button(action: openLanguageSettings) {
+ Text("🌍")
+ .padding()
+ }
+ Spacer()
+ Button(action: toggleTheme) {
+ Image(systemName: isDarkMode ? "moon.fill" : "sun.max.fill")
+ .padding()
+ }
+ }
+ .onTapGesture {
+ hideKeyboard()
+ }
+
+ Spacer()
+
+ TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
+ .padding()
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(8)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+ .onChange(of: viewModel.username) { newValue in
+ if newValue.count > 32 {
+ viewModel.username = String(newValue.prefix(32))
+ }
+ }
+
+ // Показываем ошибку для логина
+ if !isUsernameValid && !viewModel.username.isEmpty {
+ Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
+ .foregroundColor(.red)
+ .font(.caption)
+ }
+
+ // Показываем поле пароля
+ SecureField(NSLocalizedString("Пароль", comment: ""), text: $viewModel.password)
+ .padding()
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(8)
+ .autocapitalization(.none)
+ .onChange(of: viewModel.password) { newValue in
+ if newValue.count > 32 {
+ viewModel.password = String(newValue.prefix(32))
+ }
+ }
+
+ // Показываем ошибку для пароля
+ if !isPasswordValid && !viewModel.password.isEmpty {
+ Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
+ .foregroundColor(.red)
+ .font(.caption)
+ }
+
+ var isButtonEnabled: Bool {
+ !viewModel.isLoading && isUsernameValid && isPasswordValid
+ }
+
+ Button(action: {
+ viewModel.login()
+ }) {
+ if viewModel.isLoading {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle())
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color.gray.opacity(0.6))
+ .cornerRadius(8)
+ } else {
+ Text(NSLocalizedString("Войти", comment: ""))
+ .foregroundColor(.white)
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(isButtonEnabled ? Color.blue : Color.gray)
+ .cornerRadius(8)
+ }
+ }
+ .disabled(!isButtonEnabled)
+
+// Spacer()
+
+ // Кнопка регистрации
+ Button(action: {
+ isShowingRegistration = true
+ }) {
+ Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
+ .foregroundColor(.blue)
+ }
+ .padding(.top, 10)
+ .sheet(isPresented: $isShowingRegistration) {
+ RegistrationView(viewModel: viewModel)
+ }
+
+ Spacer()
+
+ }
+ .padding()
+ .alert(isPresented: $viewModel.showError) {
+ Alert(
+ title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
+ message: Text(viewModel.errorMessage),
+ dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
+ )
+ }
+ .onTapGesture {
+ hideKeyboard()
+ }
+ }
+ }
+
+
+
+
+
+ private func toggleTheme() {
+ isDarkMode.toggle()
+ }
+
+ private func openLanguageSettings() {
+ guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+ UIApplication.shared.open(url)
+ }
+
+ private func hideKeyboard() {
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+
+}
+
+struct LoginView_Previews: PreviewProvider {
+ static var previews: some View {
+ let viewModel = LoginViewModel()
+ viewModel.isLoading = false // чтобы убрать спиннер
+ return LoginView(viewModel: viewModel)
+ }
+}
diff --git a/yobble/Views/Login/RegistrationView.swift b/yobble/Views/Login/RegistrationView.swift
new file mode 100644
index 0000000..43a83f0
--- /dev/null
+++ b/yobble/Views/Login/RegistrationView.swift
@@ -0,0 +1,213 @@
+//
+// RegistrationView.swift
+// VolnahubApp
+//
+// Created by cheykrym on 09/06/2025.
+//
+
+import SwiftUI
+
+struct RegistrationView: View {
+ @ObservedObject var viewModel: LoginViewModel
+ @Environment(\.presentationMode) private var presentationMode
+
+ @State private var username: String = ""
+ @State private var password: String = ""
+ @State private var confirmPassword: String = ""
+ @State private var inviteCode: String = ""
+ @AppStorage("isDarkMode") private var isDarkMode: Bool = true
+
+ @State private var isLoading: Bool = false
+ @State private var showError: Bool = false
+ @State private var errorMessage: String = ""
+
+ private var isUsernameValid: Bool {
+ let pattern = "^[A-Za-z0-9_]{3,32}$"
+ return username.range(of: pattern, options: .regularExpression) != nil
+ }
+
+ private var isPasswordValid: Bool {
+ password.count >= 8 && password.count <= 128
+ }
+
+ private var isConfirmPasswordValid: Bool {
+ confirmPassword == password && !confirmPassword.isEmpty
+ }
+
+ private var isFormValid: Bool {
+ isUsernameValid && isPasswordValid && isConfirmPasswordValid
+ }
+
+ var body: some View {
+ NavigationView {
+
+ ScrollView {
+ ZStack {
+ Color.clear
+ .contentShape(Rectangle())
+ .onTapGesture {
+ hideKeyboard()
+ }
+
+ VStack(alignment: .leading, spacing: 16) {
+
+ Group {
+
+ HStack {
+ TextField(NSLocalizedString("Логин", comment: "Логин"), text: $username)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+ Spacer()
+ if !username.isEmpty {
+ Image(systemName: isUsernameValid ? "checkmark.circle" : "xmark.circle")
+ .foregroundColor(isUsernameValid ? .green : .red)
+ }
+ }
+ .padding()
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(8)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+ .onChange(of: username) { newValue in
+ if newValue.count > 32 {
+ username = String(newValue.prefix(32))
+ }
+ }
+
+ if !isUsernameValid && !username.isEmpty {
+ Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: "Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)"))
+ .foregroundColor(.red)
+ .font(.caption)
+ }
+
+ HStack {
+ SecureField(NSLocalizedString("Пароль", comment: "Пароль"), text: $password)
+ .autocapitalization(.none)
+ Spacer()
+ if !password.isEmpty {
+ Image(systemName: isPasswordValid ? "checkmark.circle" : "xmark.circle")
+ .foregroundColor(isPasswordValid ? .green : .red)
+ }
+ }
+ .padding()
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(8)
+ .autocapitalization(.none)
+ .onChange(of: password) { newValue in
+ if newValue.count > 32 {
+ password = String(newValue.prefix(32))
+ }
+ }
+
+ if !isPasswordValid && !password.isEmpty {
+ Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: "Пароль должен быть от 6 до 32 символов"))
+ .foregroundColor(.red)
+ .font(.caption)
+ }
+
+ HStack {
+ SecureField(NSLocalizedString("Подтверждение пароля", comment: "Подтверждение пароля"), text: $confirmPassword)
+ .autocapitalization(.none)
+ Spacer()
+ if !confirmPassword.isEmpty {
+ Image(systemName: isConfirmPasswordValid ? "checkmark.circle" : "xmark.circle")
+ .foregroundColor(isConfirmPasswordValid ? .green : .red)
+ }
+ }
+ .padding()
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(8)
+ .autocapitalization(.none)
+ .onChange(of: confirmPassword) { newValue in
+ if newValue.count > 32 {
+ confirmPassword = String(newValue.prefix(32))
+ }
+ }
+
+ if !isConfirmPasswordValid && !confirmPassword.isEmpty {
+ Text(NSLocalizedString("Пароли не совпадают", comment: "Пароли не совпадают"))
+ .foregroundColor(.red)
+ .font(.caption)
+ }
+
+ TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: "Инвайт-код"), text: $inviteCode)
+ .padding()
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(8)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+ }
+
+ Button(action: registerUser) {
+ if isLoading {
+ ProgressView()
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color.gray.opacity(0.6))
+ .cornerRadius(8)
+ } else {
+ Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться"))
+ .foregroundColor(.white)
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(isFormValid ? Color.blue : Color.gray.opacity(0.6))
+ .cornerRadius(8)
+ }
+ }
+ .disabled(!isFormValid)
+ .padding(.bottom)
+ }
+ .padding()
+ }
+ .navigationBarItems(trailing:
+ Button(action: {
+ presentationMode.wrappedValue.dismiss()
+ }) {
+ Text(NSLocalizedString("Закрыть", comment: "Закрыть"))
+ }
+ )
+ .navigationTitle(Text(NSLocalizedString("Регистрация", comment: "Регистрация")))
+ .alert(isPresented: $showError) {
+ Alert(
+ title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")),
+ message: Text(errorMessage),
+ dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
+ )
+ }
+ }
+ .onTapGesture {
+ hideKeyboard()
+ }
+ }
+ .onTapGesture {
+ hideKeyboard()
+ }
+ }
+
+ private func registerUser() {
+ isLoading = true
+ errorMessage = ""
+ viewModel.registerUser(username: username, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
+ isLoading = false
+ if success {
+ presentationMode.wrappedValue.dismiss()
+ } else {
+ errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "")
+ showError = true
+ }
+ }
+ }
+
+ private func hideKeyboard() {
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+}
+
+
+struct RegistrationView_Previews: PreviewProvider {
+ static var previews: some View {
+ let viewModel = LoginViewModel()
+ viewModel.isLoading = false // чтобы убрать спиннер
+ return RegistrationView(viewModel: viewModel)
+ }
+}
diff --git a/yobble/Views/SplashScreenView.swift b/yobble/Views/SplashScreenView.swift
new file mode 100644
index 0000000..658316a
--- /dev/null
+++ b/yobble/Views/SplashScreenView.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+struct SplashScreenView: View {
+ var body: some View {
+ VStack {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle())
+ .scaleEffect(1.5)
+ Text(NSLocalizedString("loading_placeholder", comment: ""))
+ .padding(.top, 10)
+ }
+ }
+}
diff --git a/yobble/config.swift b/yobble/config.swift
new file mode 100644
index 0000000..5cf05d8
--- /dev/null
+++ b/yobble/config.swift
@@ -0,0 +1,18 @@
+import SwiftUI
+
+struct AppConfig {
+ static var DEBUG: Bool = true
+ //static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
+ static let PROTOCOL = "https"
+ static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
+
+ static let USER_AGENT = "yobble ios"
+ static let APP_BUILD = "appstore" // appstore / freestore
+ static let APP_VERSION = "0.1"
+}
+
+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"
+}
diff --git a/yobble/yobbleApp.swift b/yobble/yobbleApp.swift
index 736e0ff..d9486c7 100644
--- a/yobble/yobbleApp.swift
+++ b/yobble/yobbleApp.swift
@@ -9,9 +9,23 @@ import SwiftUI
@main
struct yobbleApp: App {
+ @StateObject private var themeManager = ThemeManager()
+ @StateObject private var viewModel = LoginViewModel()
+
var body: some Scene {
WindowGroup {
- ContentView()
+ Group {
+ if viewModel.isLoading {
+ SplashScreenView()
+ } else if viewModel.isLoggedIn {
+// MainView(viewModel: viewModel)
+ LoginView(viewModel: viewModel)
+ } else {
+ LoginView(viewModel: viewModel)
+ }
+ }
+ .environmentObject(themeManager)
+ .preferredColorScheme(themeManager.theme.colorScheme)
}
}
}