first commit
This commit is contained in:
commit
ab93bda284
339
MobileMkch.xcodeproj/project.pbxproj
Normal file
339
MobileMkch.xcodeproj/project.pbxproj
Normal file
@ -0,0 +1,339 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = MobileMkch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
1D8926E62E43CE4C00C5590A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1D8926E02E43CE4C00C5590A = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
||||
1D8926EA2E43CE4C00C5590A /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1D8926EA2E43CE4C00C5590A /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D8926E92E43CE4C00C5590A /* MobileMkch.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1D8926E82E43CE4C00C5590A /* MobileMkch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */;
|
||||
buildPhases = (
|
||||
1D8926E52E43CE4C00C5590A /* Sources */,
|
||||
1D8926E62E43CE4C00C5590A /* Frameworks */,
|
||||
1D8926E72E43CE4C00C5590A /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
||||
);
|
||||
name = MobileMkch;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = MobileMkch;
|
||||
productReference = 1D8926E92E43CE4C00C5590A /* MobileMkch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
1D8926E12E43CE4C00C5590A /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1620;
|
||||
TargetAttributes = {
|
||||
1D8926E82E43CE4C00C5590A = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 1D8926E02E43CE4C00C5590A;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 1D8926EA2E43CE4C00C5590A /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1D8926E82E43CE4C00C5590A /* MobileMkch */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
1D8926E72E43CE4C00C5590A /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1D8926E52E43CE4C00C5590A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1D8926F52E43CE4D00C5590A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1D8926F62E43CE4D00C5590A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1D8926F82E43CE4D00C5590A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "1-alpha";
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 9U88M9D595;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = MobileMkch;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.0.0-ios";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1D8926F92E43CE4D00C5590A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "1-alpha";
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 9U88M9D595;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = MobileMkch;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.0.0-ios";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1D8926F52E43CE4D00C5590A /* Debug */,
|
||||
1D8926F62E43CE4D00C5590A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1D8926F82E43CE4D00C5590A /* Debug */,
|
||||
1D8926F92E43CE4D00C5590A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 1D8926E12E43CE4C00C5590A /* Project object */;
|
||||
}
|
||||
7
MobileMkch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
MobileMkch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>MobileMkch.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
512
MobileMkch/APIClient.swift
Normal file
512
MobileMkch/APIClient.swift
Normal file
@ -0,0 +1,512 @@
|
||||
import Foundation
|
||||
|
||||
class APIClient: ObservableObject {
|
||||
private let baseURL = "https://mkch.pooziqo.xyz"
|
||||
private let apiURL = "https://mkch.pooziqo.xyz/api"
|
||||
private let session = URLSession.shared
|
||||
private var authKey: String = ""
|
||||
private var passcode: String = ""
|
||||
|
||||
func authenticate(authKey: String, completion: @escaping (Error?) -> Void) {
|
||||
self.authKey = authKey
|
||||
|
||||
let url = URL(string: "\(baseURL)/key/auth/")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completion(APIError(message: "Ошибка получения формы аутентификации", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data,
|
||||
let html = String(data: data, encoding: .utf8) else {
|
||||
completion(APIError(message: "Ошибка чтения формы аутентификации", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
let csrfToken = self.extractCSRFToken(from: html)
|
||||
|
||||
guard !csrfToken.isEmpty else {
|
||||
completion(APIError(message: "Не удалось извлечь CSRF токен", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
var formData = URLComponents()
|
||||
formData.queryItems = [
|
||||
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
|
||||
URLQueryItem(name: "key", value: authKey)
|
||||
]
|
||||
|
||||
var postRequest = URLRequest(url: url)
|
||||
postRequest.httpMethod = "POST"
|
||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
postRequest.setValue("\(self.baseURL)/key/auth/", forHTTPHeaderField: "Referer")
|
||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||
|
||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||
DispatchQueue.main.async {
|
||||
if let postError = postError {
|
||||
completion(postError)
|
||||
return
|
||||
}
|
||||
|
||||
guard let postHttpResponse = postResponse as? HTTPURLResponse,
|
||||
(postHttpResponse.statusCode == 200 || postHttpResponse.statusCode == 302) else {
|
||||
completion(APIError(message: "Ошибка аутентификации", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
completion(nil)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func loginWithPasscode(passcode: String, completion: @escaping (Error?) -> Void) {
|
||||
self.passcode = passcode
|
||||
|
||||
let url = URL(string: "\(baseURL)/passcode/enter/")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completion(APIError(message: "Ошибка получения формы passcode", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data,
|
||||
let html = String(data: data, encoding: .utf8) else {
|
||||
completion(APIError(message: "Ошибка чтения формы passcode", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
let csrfToken = self.extractCSRFToken(from: html)
|
||||
|
||||
guard !csrfToken.isEmpty else {
|
||||
completion(APIError(message: "Не удалось извлечь CSRF токен для passcode", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
var formData = URLComponents()
|
||||
formData.queryItems = [
|
||||
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
|
||||
URLQueryItem(name: "passcode", value: passcode)
|
||||
]
|
||||
|
||||
var postRequest = URLRequest(url: url)
|
||||
postRequest.httpMethod = "POST"
|
||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
postRequest.setValue("\(self.baseURL)/passcode/enter/", forHTTPHeaderField: "Referer")
|
||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||
|
||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||
DispatchQueue.main.async {
|
||||
if let postError = postError {
|
||||
completion(postError)
|
||||
return
|
||||
}
|
||||
|
||||
guard let postHttpResponse = postResponse as? HTTPURLResponse,
|
||||
(postHttpResponse.statusCode == 200 || postHttpResponse.statusCode == 302) else {
|
||||
completion(APIError(message: "Ошибка входа с passcode", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
completion(nil)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) {
|
||||
if let cachedBoards = Cache.shared.getBoards() {
|
||||
completion(.success(cachedBoards))
|
||||
return
|
||||
}
|
||||
|
||||
let url = URL(string: "\(apiURL)/boards/")!
|
||||
|
||||
session.dataTask(with: url) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completion(.failure(APIError(message: "Ошибка получения досок", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let boards = try JSONDecoder().decode([Board].self, from: data)
|
||||
Cache.shared.setBoards(boards)
|
||||
completion(.success(boards))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) {
|
||||
if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) {
|
||||
completion(.success(cachedThreads))
|
||||
return
|
||||
}
|
||||
|
||||
let url = URL(string: "\(apiURL)/board/\(boardCode)")!
|
||||
|
||||
session.dataTask(with: url) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completion(.failure(APIError(message: "Ошибка получения тредов", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let threads = try JSONDecoder().decode([Thread].self, from: data)
|
||||
Cache.shared.setThreads(threads, forBoard: boardCode)
|
||||
completion(.success(threads))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) {
|
||||
if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) {
|
||||
completion(.success(cachedThread))
|
||||
return
|
||||
}
|
||||
|
||||
let url = URL(string: "\(apiURL)/board/\(boardCode)/thread/\(threadId)")!
|
||||
|
||||
session.dataTask(with: url) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completion(.failure(APIError(message: "Ошибка получения треда", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let thread = try JSONDecoder().decode(ThreadDetail.self, from: data)
|
||||
Cache.shared.setThreadDetail(thread, forThreadId: threadId)
|
||||
completion(.success(thread))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) {
|
||||
if let cachedComments = Cache.shared.getComments(forThreadId: threadId) {
|
||||
completion(.success(cachedComments))
|
||||
return
|
||||
}
|
||||
|
||||
let url = URL(string: "\(apiURL)/board/\(boardCode)/thread/\(threadId)/comments")!
|
||||
|
||||
session.dataTask(with: url) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completion(.failure(APIError(message: "Ошибка получения комментариев", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let comments = try JSONDecoder().decode([Comment].self, from: data)
|
||||
Cache.shared.setComments(comments, forThreadId: threadId)
|
||||
completion(.success(comments))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func getFullThread(boardCode: String, threadId: Int, completion: @escaping (Result<(ThreadDetail, [Comment]), Error>) -> Void) {
|
||||
let group = DispatchGroup()
|
||||
var threadDetail: ThreadDetail?
|
||||
var comments: [Comment]?
|
||||
var threadError: Error?
|
||||
var commentsError: Error?
|
||||
|
||||
group.enter()
|
||||
getThreadDetail(boardCode: boardCode, threadId: threadId) { result in
|
||||
switch result {
|
||||
case .success(let detail):
|
||||
threadDetail = detail
|
||||
case .failure(let error):
|
||||
threadError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
getComments(boardCode: boardCode, threadId: threadId) { result in
|
||||
switch result {
|
||||
case .success(let commentList):
|
||||
comments = commentList
|
||||
case .failure(let error):
|
||||
commentsError = error
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
if let threadError = threadError {
|
||||
completion(.failure(threadError))
|
||||
return
|
||||
}
|
||||
|
||||
if let commentsError = commentsError {
|
||||
completion(.failure(commentsError))
|
||||
return
|
||||
}
|
||||
|
||||
guard let detail = threadDetail, let commentList = comments else {
|
||||
completion(.failure(APIError(message: "Не удалось загрузить данные", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
completion(.success((detail, commentList)))
|
||||
}
|
||||
}
|
||||
|
||||
func createThread(boardCode: String, title: String, text: String, passcode: String, completion: @escaping (Error?) -> Void) {
|
||||
if !passcode.isEmpty {
|
||||
loginWithPasscode(passcode: passcode) { error in
|
||||
if let error = error {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
self.performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion)
|
||||
}
|
||||
} else {
|
||||
performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private func performCreateThread(boardCode: String, title: String, text: String, completion: @escaping (Error?) -> Void) {
|
||||
let formURL = "\(baseURL)/boards/board/\(boardCode)/new"
|
||||
let url = URL(string: formURL)!
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completion(APIError(message: "Ошибка получения формы", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data,
|
||||
let html = String(data: data, encoding: .utf8) else {
|
||||
completion(APIError(message: "Ошибка чтения формы", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
let csrfToken = self.extractCSRFToken(from: html)
|
||||
|
||||
guard !csrfToken.isEmpty else {
|
||||
completion(APIError(message: "Не удалось извлечь CSRF токен", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
var formData = URLComponents()
|
||||
formData.queryItems = [
|
||||
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
|
||||
URLQueryItem(name: "title", value: title),
|
||||
URLQueryItem(name: "text", value: text)
|
||||
]
|
||||
|
||||
var postRequest = URLRequest(url: url)
|
||||
postRequest.httpMethod = "POST"
|
||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
|
||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||
|
||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||
DispatchQueue.main.async {
|
||||
if let postError = postError {
|
||||
completion(postError)
|
||||
return
|
||||
}
|
||||
|
||||
guard let postHttpResponse = postResponse as? HTTPURLResponse,
|
||||
(postHttpResponse.statusCode == 200 || postHttpResponse.statusCode == 302) else {
|
||||
completion(APIError(message: "Ошибка создания треда", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
Cache.shared.delete("threads_\(boardCode)")
|
||||
completion(nil)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func addComment(boardCode: String, threadId: Int, text: String, passcode: String, completion: @escaping (Error?) -> Void) {
|
||||
if !passcode.isEmpty {
|
||||
loginWithPasscode(passcode: passcode) { error in
|
||||
if let error = error {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion)
|
||||
}
|
||||
} else {
|
||||
performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private func performAddComment(boardCode: String, threadId: Int, text: String, completion: @escaping (Error?) -> Void) {
|
||||
let formURL = "\(baseURL)/boards/board/\(boardCode)/thread/\(threadId)/comment"
|
||||
let url = URL(string: formURL)!
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completion(APIError(message: "Ошибка получения формы", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data,
|
||||
let html = String(data: data, encoding: .utf8) else {
|
||||
completion(APIError(message: "Ошибка чтения формы", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
let csrfToken = self.extractCSRFToken(from: html)
|
||||
|
||||
guard !csrfToken.isEmpty else {
|
||||
completion(APIError(message: "Не удалось извлечь CSRF токен", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
var formData = URLComponents()
|
||||
formData.queryItems = [
|
||||
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
|
||||
URLQueryItem(name: "text", value: text)
|
||||
]
|
||||
|
||||
var postRequest = URLRequest(url: url)
|
||||
postRequest.httpMethod = "POST"
|
||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
|
||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||
|
||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||
DispatchQueue.main.async {
|
||||
if let postError = postError {
|
||||
completion(postError)
|
||||
return
|
||||
}
|
||||
|
||||
guard let postHttpResponse = postResponse as? HTTPURLResponse,
|
||||
(postHttpResponse.statusCode == 200 || postHttpResponse.statusCode == 302) else {
|
||||
completion(APIError(message: "Ошибка добавления комментария", code: 0))
|
||||
return
|
||||
}
|
||||
|
||||
Cache.shared.delete("comments_\(threadId)")
|
||||
completion(nil)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func extractCSRFToken(from html: String) -> String {
|
||||
let pattern = #"name=['"]csrfmiddlewaretoken['"]\s+value=['"]([^'"]+)['"]"#
|
||||
let regex = try? NSRegularExpression(pattern: pattern)
|
||||
|
||||
guard let match = regex?.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let range = Range(match.range(at: 1), in: html) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return String(html[range])
|
||||
}
|
||||
}
|
||||
118
MobileMkch/AddCommentView.swift
Normal file
118
MobileMkch/AddCommentView.swift
Normal file
@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddCommentView: View {
|
||||
let boardCode: String
|
||||
let threadId: Int
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var text = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingSuccess = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Текст комментария", text: $text)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(minHeight: 100)
|
||||
}
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
|
||||
Section {
|
||||
if !settings.passcode.isEmpty {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Passcode настроен")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Passcode не настроен")
|
||||
.foregroundColor(.orange)
|
||||
Text("Постинг может быть ограничен")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Комментарий в тред \(threadId)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(false)
|
||||
.navigationBarItems(
|
||||
leading: Button("Отмена") {
|
||||
dismiss()
|
||||
},
|
||||
trailing: Button("Добавить") {
|
||||
addComment()
|
||||
}
|
||||
.disabled(text.isEmpty || isLoading)
|
||||
)
|
||||
.overlay {
|
||||
if isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Text("Добавление комментария...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
.alert("Комментарий добавлен", isPresented: $showingSuccess) {
|
||||
Button("OK") {
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("Комментарий успешно добавлен")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addComment() {
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
apiClient.addComment(
|
||||
boardCode: boardCode,
|
||||
threadId: threadId,
|
||||
text: text,
|
||||
passcode: settings.passcode
|
||||
) { error in
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
|
||||
if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
} else {
|
||||
self.showingSuccess = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddCommentView(boardCode: "test", threadId: 1)
|
||||
.environmentObject(Settings())
|
||||
.environmentObject(APIClient())
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
38
MobileMkch/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
38
MobileMkch/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "Icon 2.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "Icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 1.png
Normal file
BIN
MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 790 KiB |
BIN
MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 2.png
Normal file
BIN
MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon 2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 790 KiB |
BIN
MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon.png
Normal file
BIN
MobileMkch/Assets.xcassets/AppIcon.appiconset/Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 790 KiB |
6
MobileMkch/Assets.xcassets/Contents.json
Normal file
6
MobileMkch/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
109
MobileMkch/BoardsView.swift
Normal file
109
MobileMkch/BoardsView.swift
Normal file
@ -0,0 +1,109 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BoardsView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@State private var boards: [Board] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingSettings = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Text("Загрузка досок...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else if let error = errorMessage {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Ошибка загрузки")
|
||||
.font(.headline)
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
Button("Повторить") {
|
||||
loadBoards()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(boards) { board in
|
||||
NavigationLink(destination: ThreadsView(board: board)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)) {
|
||||
BoardRow(board: board)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Доски mkch")
|
||||
.onAppear {
|
||||
if boards.isEmpty {
|
||||
loadBoards()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView()
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
showingSettings = true
|
||||
}) {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadBoards() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
apiClient.getBoards { result in
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
|
||||
switch result {
|
||||
case .success(let loadedBoards):
|
||||
self.boards = loadedBoards
|
||||
case .failure(let error):
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BoardRow: View {
|
||||
let board: Board
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("/\(board.code)/")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(board.description.isEmpty ? "Без описания" : board.description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
BoardsView()
|
||||
.environmentObject(Settings())
|
||||
.environmentObject(APIClient())
|
||||
}
|
||||
}
|
||||
117
MobileMkch/Cache.swift
Normal file
117
MobileMkch/Cache.swift
Normal file
@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
|
||||
class Cache {
|
||||
static let shared = Cache()
|
||||
|
||||
private var items: [String: CacheItem] = [:]
|
||||
private let queue = DispatchQueue(label: "cache.queue", attributes: .concurrent)
|
||||
|
||||
private init() {
|
||||
startCleanupTimer()
|
||||
}
|
||||
|
||||
func set<T: Codable>(_ data: T, forKey key: String, ttl: TimeInterval = 300) {
|
||||
do {
|
||||
let encodedData = try JSONEncoder().encode(data)
|
||||
queue.async(flags: .barrier) {
|
||||
self.items[key] = CacheItem(
|
||||
data: encodedData,
|
||||
timestamp: Date(),
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
print("Ошибка кодирования данных для кэша: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
|
||||
return queue.sync {
|
||||
guard let item = items[key] else { return nil }
|
||||
|
||||
if Date().timeIntervalSince(item.timestamp) > item.ttl {
|
||||
items.removeValue(forKey: key)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let data = item.data as? Data else { return nil }
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
print("Ошибка декодирования данных из кэша: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ key: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.items.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.items.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func startCleanupTimer() {
|
||||
Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in
|
||||
self.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanup() {
|
||||
queue.async(flags: .barrier) {
|
||||
let now = Date()
|
||||
self.items = self.items.filter { key, item in
|
||||
if now.timeIntervalSince(item.timestamp) > item.ttl {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheItem {
|
||||
let data: Data
|
||||
let timestamp: Date
|
||||
let ttl: TimeInterval
|
||||
}
|
||||
|
||||
extension Cache {
|
||||
func setBoards(_ boards: [Board]) {
|
||||
set(boards, forKey: "boards", ttl: 600)
|
||||
}
|
||||
|
||||
func getBoards() -> [Board]? {
|
||||
return get([Board].self, forKey: "boards")
|
||||
}
|
||||
|
||||
func setThreads(_ threads: [Thread], forBoard boardCode: String) {
|
||||
set(threads, forKey: "threads_\(boardCode)", ttl: 300)
|
||||
}
|
||||
|
||||
func getThreads(forBoard boardCode: String) -> [Thread]? {
|
||||
return get([Thread].self, forKey: "threads_\(boardCode)")
|
||||
}
|
||||
|
||||
func setThreadDetail(_ thread: ThreadDetail, forThreadId threadId: Int) {
|
||||
set(thread, forKey: "thread_detail_\(threadId)", ttl: 180)
|
||||
}
|
||||
|
||||
func getThreadDetail(forThreadId threadId: Int) -> ThreadDetail? {
|
||||
return get(ThreadDetail.self, forKey: "thread_detail_\(threadId)")
|
||||
}
|
||||
|
||||
func setComments(_ comments: [Comment], forThreadId threadId: Int) {
|
||||
set(comments, forKey: "comments_\(threadId)", ttl: 180)
|
||||
}
|
||||
|
||||
func getComments(forThreadId threadId: Int) -> [Comment]? {
|
||||
return get([Comment].self, forKey: "comments_\(threadId)")
|
||||
}
|
||||
}
|
||||
120
MobileMkch/CreateThreadView.swift
Normal file
120
MobileMkch/CreateThreadView.swift
Normal file
@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreateThreadView: View {
|
||||
let boardCode: String
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var title = ""
|
||||
@State private var text = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingSuccess = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Заголовок треда", text: $title)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
|
||||
TextField("Текст треда", text: $text)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(minHeight: 100)
|
||||
}
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
|
||||
Section {
|
||||
if !settings.passcode.isEmpty {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Passcode настроен")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Passcode не настроен")
|
||||
.foregroundColor(.orange)
|
||||
Text("Постинг может быть ограничен")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Создать тред /\(boardCode)/")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(
|
||||
leading: Button("Отмена") {
|
||||
dismiss()
|
||||
},
|
||||
trailing: Button("Создать") {
|
||||
createThread()
|
||||
}
|
||||
.disabled(title.isEmpty || text.isEmpty || isLoading)
|
||||
)
|
||||
.overlay {
|
||||
if isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Text("Создание треда...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
}
|
||||
.alert("Тред создан", isPresented: $showingSuccess) {
|
||||
Button("OK") {
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("Тред успешно создан")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createThread() {
|
||||
guard !title.isEmpty && !text.isEmpty else { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
apiClient.createThread(
|
||||
boardCode: boardCode,
|
||||
title: title,
|
||||
text: text,
|
||||
passcode: settings.passcode
|
||||
) { error in
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
|
||||
if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
} else {
|
||||
self.showingSuccess = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CreateThreadView(boardCode: "test")
|
||||
.environmentObject(Settings())
|
||||
.environmentObject(APIClient())
|
||||
}
|
||||
25
MobileMkch/MobileMkchApp.swift
Normal file
25
MobileMkch/MobileMkchApp.swift
Normal file
@ -0,0 +1,25 @@
|
||||
//
|
||||
// MobileMkchApp.swift
|
||||
// MobileMkch
|
||||
//
|
||||
// Created by Platon on 06.08.2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MobileMkchApp: App {
|
||||
@StateObject private var settings = Settings()
|
||||
@StateObject private var apiClient = APIClient()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
NavigationView {
|
||||
BoardsView()
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
}
|
||||
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
|
||||
}
|
||||
}
|
||||
}
|
||||
89
MobileMkch/Models.swift
Normal file
89
MobileMkch/Models.swift
Normal file
@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
struct Board: Codable, Identifiable {
|
||||
let code: String
|
||||
let description: String
|
||||
|
||||
var id: String { code }
|
||||
}
|
||||
|
||||
struct Thread: Codable, Identifiable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let text: String
|
||||
let creation: String
|
||||
let board: String
|
||||
let rating: Int?
|
||||
let pinned: Bool?
|
||||
let files: [String]
|
||||
|
||||
var creationDate: Date {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.date(from: creation) ?? Date()
|
||||
}
|
||||
|
||||
var ratingValue: Int {
|
||||
return rating ?? 0
|
||||
}
|
||||
|
||||
var isPinned: Bool {
|
||||
return pinned ?? false
|
||||
}
|
||||
}
|
||||
|
||||
struct ThreadDetail: Codable, Identifiable {
|
||||
let id: Int
|
||||
let creation: String
|
||||
let title: String
|
||||
let text: String
|
||||
let board: String
|
||||
let files: [String]
|
||||
|
||||
var creationDate: Date {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.date(from: creation) ?? Date()
|
||||
}
|
||||
}
|
||||
|
||||
struct Comment: Codable, Identifiable {
|
||||
let id: Int
|
||||
let text: String
|
||||
let creation: String
|
||||
let files: [String]
|
||||
|
||||
var creationDate: Date {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.date(from: creation) ?? Date()
|
||||
}
|
||||
|
||||
var formattedText: String {
|
||||
return text.replacingOccurrences(of: "#", with: ">>")
|
||||
}
|
||||
}
|
||||
|
||||
struct FileInfo {
|
||||
let url: String
|
||||
let filename: String
|
||||
let isImage: Bool
|
||||
let isVideo: Bool
|
||||
|
||||
init(filePath: String) {
|
||||
self.url = "https://mkch.pooziqo.xyz" + filePath
|
||||
self.filename = String(filePath.split(separator: "/").last ?? "")
|
||||
|
||||
let ext = filePath.lowercased()
|
||||
self.isImage = ext.hasSuffix(".jpg") || ext.hasSuffix(".jpeg") ||
|
||||
ext.hasSuffix(".png") || ext.hasSuffix(".gif") ||
|
||||
ext.hasSuffix(".webp")
|
||||
self.isVideo = ext.hasSuffix(".mp4") || ext.hasSuffix(".webm")
|
||||
}
|
||||
}
|
||||
|
||||
struct APIError: Error {
|
||||
let message: String
|
||||
let code: Int
|
||||
|
||||
var localizedDescription: String {
|
||||
return message
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
73
MobileMkch/Settings.swift
Normal file
73
MobileMkch/Settings.swift
Normal file
@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
class Settings: ObservableObject {
|
||||
@Published var theme: String = "dark"
|
||||
@Published var lastBoard: String = ""
|
||||
@Published var autoRefresh: Bool = true
|
||||
@Published var showFiles: Bool = true
|
||||
@Published var compactMode: Bool = false
|
||||
@Published var pageSize: Int = 10
|
||||
@Published var passcode: String = ""
|
||||
@Published var key: String = ""
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let settingsKey = "MobileMkchSettings"
|
||||
|
||||
init() {
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
func loadSettings() {
|
||||
if let data = userDefaults.data(forKey: settingsKey),
|
||||
let settings = try? JSONDecoder().decode(SettingsData.self, from: data) {
|
||||
self.theme = settings.theme
|
||||
self.lastBoard = settings.lastBoard
|
||||
self.autoRefresh = settings.autoRefresh
|
||||
self.showFiles = settings.showFiles
|
||||
self.compactMode = settings.compactMode
|
||||
self.pageSize = settings.pageSize
|
||||
self.passcode = settings.passcode
|
||||
self.key = settings.key
|
||||
}
|
||||
}
|
||||
|
||||
func saveSettings() {
|
||||
let settingsData = SettingsData(
|
||||
theme: theme,
|
||||
lastBoard: lastBoard,
|
||||
autoRefresh: autoRefresh,
|
||||
showFiles: showFiles,
|
||||
compactMode: compactMode,
|
||||
pageSize: pageSize,
|
||||
passcode: passcode,
|
||||
key: key
|
||||
)
|
||||
|
||||
if let data = try? JSONEncoder().encode(settingsData) {
|
||||
userDefaults.set(data, forKey: settingsKey)
|
||||
}
|
||||
}
|
||||
|
||||
func resetSettings() {
|
||||
theme = "dark"
|
||||
lastBoard = ""
|
||||
autoRefresh = true
|
||||
showFiles = true
|
||||
compactMode = false
|
||||
pageSize = 10
|
||||
passcode = ""
|
||||
key = ""
|
||||
saveSettings()
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsData: Codable {
|
||||
let theme: String
|
||||
let lastBoard: String
|
||||
let autoRefresh: Bool
|
||||
let showFiles: Bool
|
||||
let compactMode: Bool
|
||||
let pageSize: Int
|
||||
let passcode: String
|
||||
let key: String
|
||||
}
|
||||
244
MobileMkch/SettingsView.swift
Normal file
244
MobileMkch/SettingsView.swift
Normal file
@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showingAbout = false
|
||||
@State private var showingInfo = false
|
||||
@State private var testKeyResult: String?
|
||||
@State private var testPasscodeResult: String?
|
||||
@State private var isTestingKey = false
|
||||
@State private var isTestingPasscode = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("Внешний вид") {
|
||||
Picker("Тема", selection: $settings.theme) {
|
||||
Text("Темная").tag("dark")
|
||||
Text("Светлая").tag("light")
|
||||
}
|
||||
.onReceive(Just(settings.theme)) { _ in
|
||||
settings.saveSettings()
|
||||
}
|
||||
|
||||
Toggle("Авторефреш", isOn: $settings.autoRefresh)
|
||||
.onReceive(Just(settings.autoRefresh)) { _ in
|
||||
settings.saveSettings()
|
||||
}
|
||||
|
||||
Toggle("Показывать файлы", isOn: $settings.showFiles)
|
||||
.onReceive(Just(settings.showFiles)) { _ in
|
||||
settings.saveSettings()
|
||||
}
|
||||
|
||||
Toggle("Компактный режим", isOn: $settings.compactMode)
|
||||
.onReceive(Just(settings.compactMode)) { _ in
|
||||
settings.saveSettings()
|
||||
}
|
||||
|
||||
Picker("Размер страницы", selection: $settings.pageSize) {
|
||||
Text("5").tag(5)
|
||||
Text("10").tag(10)
|
||||
Text("15").tag(15)
|
||||
Text("20").tag(20)
|
||||
}
|
||||
.onReceive(Just(settings.pageSize)) { _ in
|
||||
settings.saveSettings()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Последняя доска") {
|
||||
Text(settings.lastBoard.isEmpty ? "Не выбрана" : settings.lastBoard)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section("Аутентификация") {
|
||||
SecureField("Passcode для постинга", text: $settings.passcode)
|
||||
.onReceive(Just(settings.passcode)) { _ in
|
||||
settings.saveSettings()
|
||||
}
|
||||
|
||||
SecureField("Ключ аутентификации", text: $settings.key)
|
||||
.onReceive(Just(settings.key)) { _ in
|
||||
settings.saveSettings()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Тест ключа") {
|
||||
testKey()
|
||||
}
|
||||
.disabled(settings.key.isEmpty || isTestingKey)
|
||||
|
||||
if isTestingKey {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
|
||||
if let result = testKeyResult {
|
||||
Text(result)
|
||||
.font(.caption)
|
||||
.foregroundColor(result.contains("успешно") ? .green : .red)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Тест passcode") {
|
||||
testPasscode()
|
||||
}
|
||||
.disabled(settings.passcode.isEmpty || isTestingPasscode)
|
||||
|
||||
if isTestingPasscode {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
|
||||
if let result = testPasscodeResult {
|
||||
Text(result)
|
||||
.font(.caption)
|
||||
.foregroundColor(result.contains("успешно") ? .green : .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Управление кэшем") {
|
||||
Button("Очистить кэш досок") {
|
||||
Cache.shared.delete("boards")
|
||||
}
|
||||
|
||||
Button("Очистить кэш тредов") {
|
||||
apiClient.getBoards { result in
|
||||
if case .success(let boards) = result {
|
||||
for board in boards {
|
||||
Cache.shared.delete("threads_\(board.code)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Очистить весь кэш") {
|
||||
Cache.shared.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Сброс") {
|
||||
Button("Сбросить настройки") {
|
||||
settings.resetSettings()
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Об аппке") {
|
||||
showingAbout = true
|
||||
}
|
||||
|
||||
Button("Я думаю тебя направили сюда") {
|
||||
showingInfo = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Настройки")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Готово") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAbout) {
|
||||
AboutView()
|
||||
}
|
||||
.alert("Информация о НЕОЖИДАНЫХ проблемах", isPresented: $showingInfo) {
|
||||
Button("Закрыть") { }
|
||||
} message: {
|
||||
Text("Если тебя направили сюда, то значит ты попал на НЕИЗВЕДАННЫЕ ТЕРРИТОРИИ\n\nДА ДА, не ослышались, это не ошибка, это особенность\n\nУвы, разработчик имиджборда вставил палки в колеса\n\nИ без доната ему, например, постинг работать не будет\n\nУвы, постинг не работает без доната, а разработчик боится что на его сайте будут спам\n\nВкратце - на сайте работает капча, а наличие пасскода ее для вас отключает\n\nувы, конфет много, но на всех не хватит")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func testKey() {
|
||||
guard !settings.key.isEmpty else { return }
|
||||
|
||||
isTestingKey = true
|
||||
testKeyResult = nil
|
||||
|
||||
apiClient.authenticate(authKey: settings.key) { error in
|
||||
DispatchQueue.main.async {
|
||||
self.isTestingKey = false
|
||||
|
||||
if let error = error {
|
||||
self.testKeyResult = "Ошибка: \(error.localizedDescription)"
|
||||
} else {
|
||||
self.testKeyResult = "Аутентификация успешна"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func testPasscode() {
|
||||
guard !settings.passcode.isEmpty else { return }
|
||||
|
||||
isTestingPasscode = true
|
||||
testPasscodeResult = nil
|
||||
|
||||
apiClient.loginWithPasscode(passcode: settings.passcode) { error in
|
||||
DispatchQueue.main.async {
|
||||
self.isTestingPasscode = false
|
||||
|
||||
if let error = error {
|
||||
self.testPasscodeResult = "Ошибка: \(error.localizedDescription)"
|
||||
} else {
|
||||
self.testPasscodeResult = "Вход успешен"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 20) {
|
||||
Text("MobileMkch")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Мобильный клиент для мкача")
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Версия: 1.0.0-ios-alpha (Always in alpha lol)")
|
||||
Text("Автор: w^x (лейн, платон, а похуй как угодно)")
|
||||
Text("Разработано с ❤️ на Свифт")
|
||||
}
|
||||
.font(.body)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Закрыть") {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Об аппке")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
.environmentObject(Settings())
|
||||
.environmentObject(APIClient())
|
||||
}
|
||||
254
MobileMkch/ThreadDetailView.swift
Normal file
254
MobileMkch/ThreadDetailView.swift
Normal file
@ -0,0 +1,254 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ThreadDetailView: View {
|
||||
let board: Board
|
||||
let thread: Thread
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@State private var threadDetail: ThreadDetail?
|
||||
@State private var comments: [Comment] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingAddComment = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Text("Загрузка треда...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
} else if let error = errorMessage {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Ошибка загрузки")
|
||||
.font(.headline)
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
Button("Повторить") {
|
||||
loadThreadDetail()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
} else if let detail = threadDetail {
|
||||
ThreadContentView(thread: detail, showFiles: settings.showFiles)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Комментарии (\(comments.count))")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Добавить") {
|
||||
showingAddComment = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
if comments.isEmpty {
|
||||
Text("Комментариев пока нет")
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
} else {
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(comments) { comment in
|
||||
CommentView(comment: comment, showFiles: settings.showFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("#\(thread.id)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Обновить") {
|
||||
loadThreadDetail()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddComment) {
|
||||
AddCommentView(boardCode: board.code, threadId: thread.id)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
}
|
||||
.onAppear {
|
||||
if threadDetail == nil {
|
||||
loadThreadDetail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadThreadDetail() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
apiClient.getFullThread(boardCode: board.code, threadId: thread.id) { result in
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
|
||||
switch result {
|
||||
case .success(let (detail, loadedComments)):
|
||||
self.threadDetail = detail
|
||||
self.comments = loadedComments
|
||||
case .failure(let error):
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThreadContentView: View {
|
||||
let thread: ThreadDetail
|
||||
let showFiles: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(thread.title)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
HStack {
|
||||
Text(thread.creationDate, style: .date)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if showFiles && !thread.files.isEmpty {
|
||||
FilesView(files: thread.files)
|
||||
}
|
||||
|
||||
if !thread.text.isEmpty {
|
||||
Text(thread.text)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentView: View {
|
||||
let comment: Comment
|
||||
let showFiles: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("ID: \(comment.id)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(comment.creationDate, style: .date)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if showFiles && !comment.files.isEmpty {
|
||||
FilesView(files: comment.files)
|
||||
}
|
||||
|
||||
if !comment.text.isEmpty {
|
||||
Text(comment.formattedText)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct FilesView: View {
|
||||
let files: [String]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "paperclip")
|
||||
.foregroundColor(.blue)
|
||||
Text("Файлы (\(files.count))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 8) {
|
||||
ForEach(files, id: \.self) { filePath in
|
||||
FileButton(fileInfo: FileInfo(filePath: filePath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FileButton: View {
|
||||
let fileInfo: FileInfo
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
if let url = URL(string: fileInfo.url) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: fileIcon)
|
||||
.foregroundColor(fileColor)
|
||||
Text(fileInfo.filename)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(.systemGray5))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
private var fileIcon: String {
|
||||
if fileInfo.isImage {
|
||||
return "photo"
|
||||
} else if fileInfo.isVideo {
|
||||
return "video"
|
||||
} else {
|
||||
return "doc"
|
||||
}
|
||||
}
|
||||
|
||||
private var fileColor: Color {
|
||||
if fileInfo.isImage {
|
||||
return .green
|
||||
} else if fileInfo.isVideo {
|
||||
return .red
|
||||
} else {
|
||||
return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ThreadDetailView(
|
||||
board: Board(code: "test", description: "Test board"),
|
||||
thread: Thread(id: 1, title: "Test Thread", text: "Test content", creation: "2023-01-01T00:00:00Z", board: "test", rating: nil, pinned: nil, files: [])
|
||||
)
|
||||
.environmentObject(Settings())
|
||||
.environmentObject(APIClient())
|
||||
}
|
||||
}
|
||||
191
MobileMkch/ThreadsView.swift
Normal file
191
MobileMkch/ThreadsView.swift
Normal file
@ -0,0 +1,191 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ThreadsView: View {
|
||||
let board: Board
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@State private var threads: [Thread] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var currentPage = 0
|
||||
@State private var showingCreateThread = false
|
||||
|
||||
private var pageSize: Int { settings.pageSize }
|
||||
private var totalPages: Int { (threads.count + pageSize - 1) / pageSize }
|
||||
private var currentThreads: [Thread] {
|
||||
let start = currentPage * pageSize
|
||||
let end = min(start + pageSize, threads.count)
|
||||
return Array(threads[start..<end])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Text("Загрузка тредов...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = errorMessage {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Ошибка загрузки")
|
||||
.font(.headline)
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
Button("Повторить") {
|
||||
loadThreads()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
} else {
|
||||
List {
|
||||
ForEach(currentThreads) { thread in
|
||||
NavigationLink(destination: ThreadDetailView(board: board, thread: thread)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)) {
|
||||
ThreadRow(thread: thread, showFiles: settings.showFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalPages > 1 {
|
||||
HStack {
|
||||
Button("←") {
|
||||
if currentPage > 0 {
|
||||
currentPage -= 1
|
||||
}
|
||||
}
|
||||
.disabled(currentPage == 0)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Страница \(currentPage + 1) из \(totalPages)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("→") {
|
||||
if currentPage < totalPages - 1 {
|
||||
currentPage += 1
|
||||
}
|
||||
}
|
||||
.disabled(currentPage >= totalPages - 1)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("/\(board.code)/")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Создать") {
|
||||
showingCreateThread = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCreateThread) {
|
||||
CreateThreadView(boardCode: board.code)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
}
|
||||
.onAppear {
|
||||
settings.lastBoard = board.code
|
||||
settings.saveSettings()
|
||||
|
||||
if threads.isEmpty {
|
||||
loadThreads()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadThreads() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
apiClient.getThreads(forBoard: board.code) { result in
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
|
||||
switch result {
|
||||
case .success(let loadedThreads):
|
||||
self.threads = loadedThreads
|
||||
self.currentPage = 0
|
||||
case .failure(let error):
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThreadRow: View {
|
||||
let thread: Thread
|
||||
let showFiles: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("#\(thread.id): \(thread.title)")
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer()
|
||||
|
||||
if thread.isPinned {
|
||||
Image(systemName: "pin.fill")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(thread.creationDate, style: .date)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if thread.ratingValue > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(.yellow)
|
||||
Text("\(thread.ratingValue)")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
if showFiles && !thread.files.isEmpty {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "paperclip")
|
||||
.foregroundColor(.blue)
|
||||
Text("\(thread.files.count)")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if !thread.text.isEmpty {
|
||||
Text(thread.text)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ThreadsView(board: Board(code: "test", description: "Test board"))
|
||||
.environmentObject(Settings())
|
||||
.environmentObject(APIClient())
|
||||
}
|
||||
}
|
||||
105
README.md
Normal file
105
README.md
Normal file
@ -0,0 +1,105 @@
|
||||
# MobileMkch iOS
|
||||
|
||||
Мобильный клиент для борды mkch.pooziqo.xyz для iOS
|
||||
|
||||
## Возможности
|
||||
|
||||
- Просмотр всех досок Мкача
|
||||
- Просмотр тредов в каждой доске с пагинацией
|
||||
- Просмотр деталей треда и комментариев
|
||||
- Поддержка изображений и видео
|
||||
- Темная/светлая тема
|
||||
- Система настроек с сохранением:
|
||||
- Тема (темная/светлая)
|
||||
- Последняя посещенная доска
|
||||
- Автообновление
|
||||
- Показ файлов
|
||||
- Компактный режим
|
||||
- Размер страницы (5-20 тредов)
|
||||
- **Полная поддержка постинга:**
|
||||
- Аутентификация по ключу
|
||||
- Аутентификация по passcode
|
||||
- Создание тредов
|
||||
- Добавление комментариев
|
||||
- Автоматическое обновление после постинга
|
||||
- **Оптимизации для iOS:**
|
||||
- Нативная SwiftUI интерфейс
|
||||
- Кэширование данных
|
||||
- Оптимизация потребления батареи
|
||||
- Поддержка iOS 15.0+
|
||||
|
||||
## Аутентификация и постинг
|
||||
|
||||
### Настройка аутентификации
|
||||
|
||||
1. Откройте настройки в приложении
|
||||
2. Введите ключ аутентификации
|
||||
3. Введите passcode для постинга (По наличию) (P.S. Увы, но пока-что не доработал пасскод, уважте, первая версия.)
|
||||
4. Используйте кнопки "Тест ключа" и "Тест passcode" для проверки
|
||||
|
||||
### Создание тредов
|
||||
|
||||
1. Перейдите в нужную доску
|
||||
2. Нажмите "Создать"
|
||||
3. Заполните заголовок и текст
|
||||
4. Нажмите "Создать"
|
||||
|
||||
### Добавление комментариев
|
||||
|
||||
1. Откройте тред
|
||||
2. Нажмите "Добавить"
|
||||
3. Введите текст комментария
|
||||
4. Нажмите "Добавить"
|
||||
|
||||
## Сборка
|
||||
|
||||
### Требования
|
||||
|
||||
- Xcode 15.0+
|
||||
- iOS 15.0+
|
||||
- macOS 13.0+
|
||||
|
||||
### Сборка
|
||||
|
||||
1. Откройте проект в Xcode:
|
||||
```bash
|
||||
open MobileMkch.xcodeproj
|
||||
```
|
||||
|
||||
2. Выберите устройство или симулятор
|
||||
|
||||
3. Нажмите Cmd+R для сборки и запуска
|
||||
|
||||
### Распространение
|
||||
|
||||
1. Выберите "Any iOS Device" в схеме сборки
|
||||
2. Product и Archive
|
||||
3. Distribute App через App Store Connect или Ad Hoc (Еще можно открыть архиве в файндер и там найти .app и закинув в Payload сжать папку в .ipa, но это слегка попердолинг увы)
|
||||
|
||||
## Структура проекта
|
||||
|
||||
- `MobileMkchApp.swift` - точка входа приложения
|
||||
- `Models.swift` - структуры данных
|
||||
- `APIClient.swift` - HTTP клиент для mkch API
|
||||
- `Settings.swift` - система настроек
|
||||
- `Cache.swift` - система кэширования
|
||||
- `BoardsView.swift` - список досок
|
||||
- `ThreadsView.swift` - треды доски с пагинацией
|
||||
- `ThreadDetailView.swift` - детали треда
|
||||
- `CreateThreadView.swift` - создание тредов
|
||||
- `AddCommentView.swift` - добавление комментариев
|
||||
- `SettingsView.swift` - экран настроек
|
||||
|
||||
## Технологии
|
||||
|
||||
- SwiftUI
|
||||
- Combine
|
||||
- Foundation
|
||||
- UIKit (для совместимости)
|
||||
|
||||
## Совместимость
|
||||
|
||||
- iOS 15.0+
|
||||
- iPhone и iPad (айпад СЛЕГКА поломан, проверил, мб чет с этим сделаю, лень)
|
||||
- Поддержка темной/светлой темы
|
||||
- Адаптивный интерфейс
|
||||
Loading…
x
Reference in New Issue
Block a user