login and register screen
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
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>
|
<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>
|
||||||
|
|||||||
@ -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
|
@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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||