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) } } }