login and register screen
@ -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;
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										102
									
								
								yobble.xcodeproj/xcshareddata/xcschemes/yobble.xcscheme
									
									
									
									
									
										Normal 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>
 | 
			
		||||
@ -10,5 +10,23 @@
 | 
			
		||||
			<integer>0</integer>
 | 
			
		||||
		</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>
 | 
			
		||||
</plist>
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										294
									
								
								yobble/Network/AuthService.swift
									
									
									
									
									
										Normal 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?
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/100.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 11 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/102.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 11 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/1024.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.6 MiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/108.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 13 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/114.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 14 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/120.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 15 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/128.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 16 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/144.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 21 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/152.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 24 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/16.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 720 B  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/167.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 28 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/172.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 30 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/180.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 32 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/196.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 39 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/20.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1018 B  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/216.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 48 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/234.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 56 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/256.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 68 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/258.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 70 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/29.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.6 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/32.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.9 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/40.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 2.7 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/48.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 3.4 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/50.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 3.6 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 324 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/55.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 4.3 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/57.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 4.5 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/58.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 4.6 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/60.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 4.8 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/64.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 5.5 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/66.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 5.6 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/72.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 6.4 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/76.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 7.0 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/80.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 7.8 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/87.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 8.7 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/88.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 9.0 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								yobble/Resources/Assets.xcassets/AppIcon.appiconset/92.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 9.6 KiB  | 
							
								
								
									
										154
									
								
								yobble/Resources/Localizable.xcstrings
									
									
									
									
									
										Normal 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"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										113
									
								
								yobble/Services/KeychainService.swift
									
									
									
									
									
										Normal 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)")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										53
									
								
								yobble/Services/ThemeManager.swift
									
									
									
									
									
										Normal 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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										103
									
								
								yobble/ViewModels/LoginViewModel.swift
									
									
									
									
									
										Normal 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 = ""
 | 
			
		||||
//    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										172
									
								
								yobble/Views/Login/LoginView.swift
									
									
									
									
									
										Normal 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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										213
									
								
								yobble/Views/Login/RegistrationView.swift
									
									
									
									
									
										Normal 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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								yobble/Views/SplashScreenView.swift
									
									
									
									
									
										Normal 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
									
								
							
							
						
						@ -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"
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||