login and register screen

This commit is contained in:
cheykrym 2025-10-05 02:48:09 +03:00
parent a8af446dd7
commit 01756710a3
54 changed files with 1282 additions and 94 deletions

View File

@ -192,6 +192,7 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
ru,
); );
mainGroup = 1A6D61C32E7CD03E00B9F736; mainGroup = 1A6D61C32E7CD03E00B9F736;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
@ -327,6 +328,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
name = Debug; name = Debug;
@ -382,6 +384,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
}; };
name = Release; name = Release;
}; };
@ -397,6 +400,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = 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=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -407,11 +411,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.5; IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.5; MACOSX_DEPLOYMENT_TARGET = 11.5;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = org.yobble.yobble; PRODUCT_BUNDLE_IDENTIFIER = org.yobble.yobble;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@ -420,7 +424,7 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 2.5; XROS_DEPLOYMENT_TARGET = 1.3;
}; };
name = Debug; name = Debug;
}; };
@ -436,6 +440,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = 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=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -446,11 +451,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.5; IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.5; MACOSX_DEPLOYMENT_TARGET = 11.5;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = org.yobble.yobble; PRODUCT_BUNDLE_IDENTIFIER = org.yobble.yobble;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@ -459,7 +464,7 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2,7";
XROS_DEPLOYMENT_TARGET = 2.5; XROS_DEPLOYMENT_TARGET = 1.3;
}; };
name = Release; name = Release;
}; };

View File

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A6D61CB2E7CD03E00B9F736"
BuildableName = "yobble.app"
BlueprintName = "yobble"
ReferencedContainer = "container:yobble.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A6D61D92E7CD04000B9F736"
BuildableName = "yobbleTests.xctest"
BlueprintName = "yobbleTests"
ReferencedContainer = "container:yobble.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A6D61E32E7CD04100B9F736"
BuildableName = "yobbleUITests.xctest"
BlueprintName = "yobbleUITests"
ReferencedContainer = "container:yobble.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A6D61CB2E7CD03E00B9F736"
BuildableName = "yobble.app"
BlueprintName = "yobble"
ReferencedContainer = "container:yobble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1A6D61CB2E7CD03E00B9F736"
BuildableName = "yobble.app"
BlueprintName = "yobble"
ReferencedContainer = "container:yobble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -10,5 +10,23 @@
<integer>0</integer> <integer>0</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>1A6D61CB2E7CD03E00B9F736</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>1A6D61D92E7CD04000B9F736</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>1A6D61E32E7CD04100B9F736</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View File

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

View File

@ -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?
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

View File

@ -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"
}

View File

@ -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<String>()
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)")
}
}
}

View File

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

View File

@ -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 = ""
// }
}

View File

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

View File

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

View File

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

18
yobble/config.swift Normal file
View File

@ -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"
}

View File

@ -9,9 +9,23 @@ import SwiftUI
@main @main
struct yobbleApp: App { struct yobbleApp: App {
@StateObject private var themeManager = ThemeManager()
@StateObject private var viewModel = LoginViewModel()
var body: some Scene { var body: some Scene {
WindowGroup { 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)
} }
} }
} }