global update v2 победа z
This commit is contained in:
parent
e27e343e53
commit
7d2eb1fd19
14
LICENSE
Normal file
14
LICENSE
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
BSD Zero Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2025 w^x (лейн, платон)
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
@ -281,7 +281,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "1.1.0-ios";
|
MARKETING_VERSION = "2.0.0-ios";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@ -317,7 +317,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "1.1.0-ios";
|
MARKETING_VERSION = "2.0.0-ios";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@ -6,6 +6,7 @@ class APIClient: ObservableObject {
|
|||||||
private let session = URLSession.shared
|
private let session = URLSession.shared
|
||||||
private var authKey: String = ""
|
private var authKey: String = ""
|
||||||
private var passcode: String = ""
|
private var passcode: String = ""
|
||||||
|
private let userAgent = "MobileMkch/2.0.0-ios-alpha"
|
||||||
|
|
||||||
func authenticate(authKey: String, completion: @escaping (Error?) -> Void) {
|
func authenticate(authKey: String, completion: @escaping (Error?) -> Void) {
|
||||||
self.authKey = authKey
|
self.authKey = authKey
|
||||||
@ -13,6 +14,7 @@ class APIClient: ObservableObject {
|
|||||||
let url = URL(string: "\(baseURL)/key/auth/")!
|
let url = URL(string: "\(baseURL)/key/auth/")!
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: request) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -50,6 +52,7 @@ class APIClient: ObservableObject {
|
|||||||
postRequest.httpMethod = "POST"
|
postRequest.httpMethod = "POST"
|
||||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
postRequest.setValue("\(self.baseURL)/key/auth/", forHTTPHeaderField: "Referer")
|
postRequest.setValue("\(self.baseURL)/key/auth/", forHTTPHeaderField: "Referer")
|
||||||
|
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||||
|
|
||||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||||
@ -78,6 +81,7 @@ class APIClient: ObservableObject {
|
|||||||
let url = URL(string: "\(baseURL)/passcode/enter/")!
|
let url = URL(string: "\(baseURL)/passcode/enter/")!
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: request) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -144,8 +148,10 @@ class APIClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let url = URL(string: "\(apiURL)/boards/")!
|
let url = URL(string: "\(apiURL)/boards/")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: url) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
@ -181,8 +187,10 @@ class APIClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let url = URL(string: "\(apiURL)/board/\(boardCode)")!
|
let url = URL(string: "\(apiURL)/board/\(boardCode)")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: url) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
@ -218,8 +226,10 @@ class APIClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let url = URL(string: "\(apiURL)/board/\(boardCode)/thread/\(threadId)")!
|
let url = URL(string: "\(apiURL)/board/\(boardCode)/thread/\(threadId)")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: url) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
@ -255,8 +265,10 @@ class APIClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let url = URL(string: "\(apiURL)/board/\(boardCode)/thread/\(threadId)/comments")!
|
let url = URL(string: "\(apiURL)/board/\(boardCode)/thread/\(threadId)/comments")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: url) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
@ -354,6 +366,7 @@ class APIClient: ObservableObject {
|
|||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: request) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -392,6 +405,7 @@ class APIClient: ObservableObject {
|
|||||||
postRequest.httpMethod = "POST"
|
postRequest.httpMethod = "POST"
|
||||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
|
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
|
||||||
|
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||||
|
|
||||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||||
@ -435,6 +449,7 @@ class APIClient: ObservableObject {
|
|||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: request) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -472,6 +487,7 @@ class APIClient: ObservableObject {
|
|||||||
postRequest.httpMethod = "POST"
|
postRequest.httpMethod = "POST"
|
||||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
|
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
|
||||||
|
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||||
|
|
||||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||||
@ -512,8 +528,10 @@ class APIClient: ObservableObject {
|
|||||||
|
|
||||||
func checkNewThreads(forBoard boardCode: String, lastKnownThreadId: Int, completion: @escaping (Result<[Thread], Error>) -> Void) {
|
func checkNewThreads(forBoard boardCode: String, lastKnownThreadId: Int, completion: @escaping (Result<[Thread], Error>) -> Void) {
|
||||||
let url = URL(string: "\(apiURL)/board/\(boardCode)")!
|
let url = URL(string: "\(apiURL)/board/\(boardCode)")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
session.dataTask(with: url) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
|
|||||||
@ -6,10 +6,10 @@ struct BoardsView: View {
|
|||||||
@State private var boards: [Board] = []
|
@State private var boards: [Board] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showingSettings = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
NavigationView {
|
||||||
|
List {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@ -41,24 +41,12 @@ struct BoardsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.navigationTitle("Доски mkch")
|
|
||||||
.onAppear {
|
|
||||||
if boards.isEmpty {
|
|
||||||
loadBoards()
|
|
||||||
}
|
}
|
||||||
}
|
.navigationTitle("Доски mkch")
|
||||||
.sheet(isPresented: $showingSettings) {
|
.navigationBarTitleDisplayMode(.large)
|
||||||
SettingsView()
|
.onAppear {
|
||||||
.environmentObject(settings)
|
if boards.isEmpty {
|
||||||
.environmentObject(apiClient)
|
loadBoards()
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button(action: {
|
|
||||||
showingSettings = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "gearshape")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ class Cache {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = item.data as? Data else { return nil }
|
let data = item.data
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try JSONDecoder().decode(type, from: data)
|
return try JSONDecoder().decode(type, from: data)
|
||||||
|
|||||||
198
MobileMkch/FileView.swift
Normal file
198
MobileMkch/FileView.swift
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FileView: View {
|
||||||
|
let fileInfo: FileInfo
|
||||||
|
@State private var showingFullScreen = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if fileInfo.isImage {
|
||||||
|
AsyncImageView(url: fileInfo.url, contentMode: .fit)
|
||||||
|
.frame(maxHeight: 200)
|
||||||
|
.clipped()
|
||||||
|
.onTapGesture {
|
||||||
|
showingFullScreen = true
|
||||||
|
}
|
||||||
|
} else if fileInfo.isVideo {
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "play.rectangle")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
Text(fileInfo.filename)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(height: 100)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
} else {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "doc")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text(fileInfo.filename)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showingFullScreen) {
|
||||||
|
if fileInfo.isImage {
|
||||||
|
NativeFullScreenImageView(url: fileInfo.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NativeFullScreenImageView: View {
|
||||||
|
let url: String
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var scale: CGFloat = 1.0
|
||||||
|
@State private var lastScale: CGFloat = 1.0
|
||||||
|
@State private var offset = CGSize.zero
|
||||||
|
@State private var lastOffset = CGSize.zero
|
||||||
|
@State private var dragOffset = CGSize.zero
|
||||||
|
@State private var isDragging = false
|
||||||
|
@State private var showUI = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
GeometryReader { geometry in
|
||||||
|
AsyncImageView(url: url, contentMode: .fit)
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.offset(offset)
|
||||||
|
.opacity(isDragging ? 0.8 : 1.0)
|
||||||
|
.gesture(
|
||||||
|
MagnificationGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
let delta = value / lastScale
|
||||||
|
lastScale = value
|
||||||
|
scale = min(max(scale * delta, 0.5), 5)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastScale = 1.0
|
||||||
|
if scale < 1 {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
scale = 1
|
||||||
|
offset = .zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
if scale > 1 {
|
||||||
|
let delta = CGSize(
|
||||||
|
width: value.translation.width - lastOffset.width,
|
||||||
|
height: value.translation.height - lastOffset.height
|
||||||
|
)
|
||||||
|
lastOffset = value.translation
|
||||||
|
offset = CGSize(
|
||||||
|
width: offset.width + delta.width,
|
||||||
|
height: offset.height + delta.height
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dragOffset = value.translation
|
||||||
|
isDragging = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
if scale <= 1 {
|
||||||
|
if abs(dragOffset.height) > 100 || abs(dragOffset.width) > 100 {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
dragOffset = .zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isDragging = false
|
||||||
|
}
|
||||||
|
lastOffset = CGSize.zero
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
if scale > 1 {
|
||||||
|
scale = 1
|
||||||
|
offset = .zero
|
||||||
|
} else {
|
||||||
|
scale = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
showUI.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(
|
||||||
|
VStack {
|
||||||
|
if showUI {
|
||||||
|
HStack {
|
||||||
|
Button(action: { dismiss() }) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.black.opacity(0.6))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
scale = 1
|
||||||
|
offset = .zero
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.black.opacity(0.6))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: showUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FilesView: View {
|
||||||
|
let files: [String]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !files.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Файлы (\(files.count))")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
], spacing: 12) {
|
||||||
|
ForEach(files, id: \.self) { file in
|
||||||
|
FileView(fileInfo: FileInfo(filePath: file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
MobileMkch/ImageLoader.swift
Normal file
134
MobileMkch/ImageLoader.swift
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class ImageLoader: ObservableObject {
|
||||||
|
@Published var image: UIImage?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: Error?
|
||||||
|
|
||||||
|
private let url: String
|
||||||
|
private let cache = ImageCache.shared
|
||||||
|
private var cancellable: URLSessionDataTask?
|
||||||
|
|
||||||
|
init(url: String) {
|
||||||
|
self.url = url
|
||||||
|
loadImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancellable?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImage() {
|
||||||
|
guard let imageURL = URL(string: url) else {
|
||||||
|
error = NSError(domain: "Invalid URL", code: 0, userInfo: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let cachedImage = cache.getImage(for: url) {
|
||||||
|
self.image = cachedImage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
cancellable = URLSession.shared.dataTask(with: imageURL) { [weak self] data, response, error in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.isLoading = false
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
self?.error = error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data = data, let image = UIImage(data: data) else {
|
||||||
|
self?.error = NSError(domain: "Invalid image data", code: 0, userInfo: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.image = image
|
||||||
|
self?.cache.setImage(image, for: self?.url ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellable?.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reload() {
|
||||||
|
cancellable?.cancel()
|
||||||
|
image = nil
|
||||||
|
error = nil
|
||||||
|
loadImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageCache {
|
||||||
|
static let shared = ImageCache()
|
||||||
|
|
||||||
|
private var cache = NSCache<NSString, UIImage>()
|
||||||
|
private let queue = DispatchQueue(label: "image.cache.queue", attributes: .concurrent)
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
cache.countLimit = 100
|
||||||
|
cache.totalCostLimit = 50 * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
func setImage(_ image: UIImage, for key: String) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.cache.setObject(image, forKey: key as NSString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getImage(for key: String) -> UIImage? {
|
||||||
|
return queue.sync {
|
||||||
|
return cache.object(forKey: key as NSString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCache() {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.cache.removeAllObjects()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AsyncImageView: View {
|
||||||
|
let url: String
|
||||||
|
let placeholder: Image
|
||||||
|
let contentMode: ContentMode
|
||||||
|
|
||||||
|
@StateObject private var loader: ImageLoader
|
||||||
|
|
||||||
|
init(url: String, placeholder: Image = Image(systemName: "photo"), contentMode: ContentMode = .fit) {
|
||||||
|
self.url = url
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.contentMode = contentMode
|
||||||
|
self._loader = StateObject(wrappedValue: ImageLoader(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let image = loader.image {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: contentMode)
|
||||||
|
} else if loader.isLoading {
|
||||||
|
placeholder
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: contentMode)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: contentMode)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
if loader.error != nil {
|
||||||
|
loader.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
137
MobileMkch/MainTabView.swift
Normal file
137
MobileMkch/MainTabView.swift
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainTabView: View {
|
||||||
|
@EnvironmentObject var settings: Settings
|
||||||
|
@EnvironmentObject var apiClient: APIClient
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
BoardsView()
|
||||||
|
.environmentObject(settings)
|
||||||
|
.environmentObject(apiClient)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "list.bullet")
|
||||||
|
Text("Доски")
|
||||||
|
}
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
FavoritesView()
|
||||||
|
.environmentObject(settings)
|
||||||
|
.environmentObject(apiClient)
|
||||||
|
.environmentObject(NotificationManager.shared)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "heart.fill")
|
||||||
|
Text("Избранное")
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(settings)
|
||||||
|
.environmentObject(apiClient)
|
||||||
|
.tabItem {
|
||||||
|
Image(systemName: "gear")
|
||||||
|
Text("Настройки")
|
||||||
|
}
|
||||||
|
.tag(2)
|
||||||
|
}
|
||||||
|
.accentColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FavoritesView: View {
|
||||||
|
@EnvironmentObject var settings: Settings
|
||||||
|
@EnvironmentObject var apiClient: APIClient
|
||||||
|
@EnvironmentObject var notificationManager: NotificationManager
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Group {
|
||||||
|
if settings.favoriteThreads.isEmpty {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "heart")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
|
||||||
|
Text("Нет избранных тредов")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("Добавляйте треды в избранное, нажав на звездочку в списке тредов")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(settings.favoriteThreads) { favorite in
|
||||||
|
NavigationLink(destination:
|
||||||
|
ThreadDetailView(
|
||||||
|
board: Board(code: favorite.board, description: favorite.boardDescription),
|
||||||
|
thread: Thread(
|
||||||
|
id: favorite.id,
|
||||||
|
title: favorite.title,
|
||||||
|
text: "",
|
||||||
|
creation: "",
|
||||||
|
board: favorite.board,
|
||||||
|
rating: nil,
|
||||||
|
pinned: nil,
|
||||||
|
files: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.environmentObject(settings)
|
||||||
|
.environmentObject(apiClient)
|
||||||
|
) {
|
||||||
|
FavoriteThreadRow(favorite: favorite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteFavorites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Избранное")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteFavorites(offsets: IndexSet) {
|
||||||
|
for index in offsets {
|
||||||
|
let favorite = settings.favoriteThreads[index]
|
||||||
|
settings.removeFromFavorites(favorite.id, boardCode: favorite.board)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FavoriteThreadRow: View {
|
||||||
|
let favorite: FavoriteThread
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(favorite.title)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("/\(favorite.board)/")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.cornerRadius(4)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(favorite.addedDate, style: .date)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,13 +28,11 @@ struct MobileMkchApp: App {
|
|||||||
if crashHandler.hasCrashed {
|
if crashHandler.hasCrashed {
|
||||||
CrashScreen()
|
CrashScreen()
|
||||||
} else {
|
} else {
|
||||||
NavigationView {
|
MainTabView()
|
||||||
BoardsView()
|
.environmentObject(settings)
|
||||||
.environmentObject(settings)
|
.environmentObject(apiClient)
|
||||||
.environmentObject(apiClient)
|
.environmentObject(notificationManager)
|
||||||
.environmentObject(notificationManager)
|
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
|
||||||
}
|
|
||||||
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@ -86,4 +86,20 @@ struct APIError: Error {
|
|||||||
var localizedDescription: String {
|
var localizedDescription: String {
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FavoriteThread: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let title: String
|
||||||
|
let board: String
|
||||||
|
let boardDescription: String
|
||||||
|
let addedDate: Date
|
||||||
|
|
||||||
|
init(thread: Thread, board: Board) {
|
||||||
|
self.id = thread.id
|
||||||
|
self.title = thread.title
|
||||||
|
self.board = board.code
|
||||||
|
self.boardDescription = board.description
|
||||||
|
self.addedDate = Date()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -9,67 +9,112 @@ struct NotificationSettingsView: View {
|
|||||||
@State private var isLoadingBoards = false
|
@State private var isLoadingBoards = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
VStack {
|
||||||
Section(header: Text("Уведомления")) {
|
if !settings.enableUnstableFeatures {
|
||||||
Toggle("Включить уведомления", isOn: $settings.notificationsEnabled)
|
VStack(spacing: 16) {
|
||||||
.onChange(of: settings.notificationsEnabled) { newValue in
|
Image(systemName: "lock.fill")
|
||||||
if newValue {
|
.font(.system(size: 50))
|
||||||
requestNotificationPermission()
|
.foregroundColor(.gray)
|
||||||
}
|
|
||||||
settings.saveSettings()
|
Text("Функция заблокирована")
|
||||||
}
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
if settings.notificationsEnabled {
|
|
||||||
HStack {
|
Text("Для использования уведомлений необходимо включить нестабильные функции в настройках.")
|
||||||
Text("Интервал проверки")
|
.font(.body)
|
||||||
Spacer()
|
.foregroundColor(.secondary)
|
||||||
Picker("", selection: $settings.notificationInterval) {
|
.multilineTextAlignment(.center)
|
||||||
Text("5 мин").tag(300)
|
.padding(.horizontal)
|
||||||
Text("15 мин").tag(900)
|
|
||||||
Text("30 мин").tag(1800)
|
|
||||||
Text("1 час").tag(3600)
|
|
||||||
}
|
|
||||||
.pickerStyle(MenuPickerStyle())
|
|
||||||
}
|
|
||||||
.onChange(of: settings.notificationInterval) { _ in
|
|
||||||
settings.saveSettings()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
if settings.notificationsEnabled {
|
HStack {
|
||||||
Section(header: Text("Подписки на доски")) {
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
if isLoadingBoards {
|
.foregroundColor(.orange)
|
||||||
HStack {
|
Text("BETA Функция")
|
||||||
ProgressView()
|
.font(.headline)
|
||||||
.scaleEffect(0.8)
|
.foregroundColor(.orange)
|
||||||
Text("Загрузка досок...")
|
}
|
||||||
.foregroundColor(.secondary)
|
.padding()
|
||||||
}
|
.background(Color.orange.opacity(0.1))
|
||||||
} else {
|
.cornerRadius(8)
|
||||||
ForEach(boards) { board in
|
.padding(.horizontal)
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.")
|
||||||
Text("/\(board.code)/")
|
.font(.caption)
|
||||||
.font(.headline)
|
.foregroundColor(.secondary)
|
||||||
Text(board.description.isEmpty ? "Без описания" : board.description)
|
.multilineTextAlignment(.center)
|
||||||
.font(.caption)
|
.padding(.horizontal)
|
||||||
.foregroundColor(.secondary)
|
.padding(.bottom)
|
||||||
.lineLimit(1)
|
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Уведомления")) {
|
||||||
|
Toggle("Включить уведомления", isOn: $settings.notificationsEnabled)
|
||||||
|
.onChange(of: settings.notificationsEnabled) { newValue in
|
||||||
|
if newValue {
|
||||||
|
requestNotificationPermission()
|
||||||
}
|
}
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.notificationsEnabled {
|
||||||
|
HStack {
|
||||||
|
Text("Интервал проверки")
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Picker("", selection: $settings.notificationInterval) {
|
||||||
Toggle("", isOn: Binding(
|
Text("5 мин").tag(300)
|
||||||
get: { notificationManager.subscribedBoards.contains(board.code) },
|
Text("15 мин").tag(900)
|
||||||
set: { isSubscribed in
|
Text("30 мин").tag(1800)
|
||||||
if isSubscribed {
|
Text("1 час").tag(3600)
|
||||||
notificationManager.subscribeToBoard(board.code)
|
}
|
||||||
} else {
|
.pickerStyle(MenuPickerStyle())
|
||||||
notificationManager.unsubscribeFromBoard(board.code)
|
}
|
||||||
|
.onChange(of: settings.notificationInterval) { _ in
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Проверить новые треды сейчас") {
|
||||||
|
checkNewThreadsNow()
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.notificationsEnabled {
|
||||||
|
Section(header: Text("Подписки на доски")) {
|
||||||
|
if isLoadingBoards {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
Text("Загрузка досок...")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(boards) { board in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("/\(board.code)/")
|
||||||
|
.font(.headline)
|
||||||
|
Text(board.description.isEmpty ? "Без описания" : board.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { notificationManager.subscribedBoards.contains(board.code) },
|
||||||
|
set: { isSubscribed in
|
||||||
|
if isSubscribed {
|
||||||
|
notificationManager.subscribeToBoard(board.code)
|
||||||
|
} else {
|
||||||
|
notificationManager.unsubscribeFromBoard(board.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
}
|
}
|
||||||
))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -95,24 +140,9 @@ struct NotificationSettingsView: View {
|
|||||||
Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках")
|
Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private func loadBoards() {
|
|
||||||
isLoadingBoards = true
|
extension NotificationSettingsView {
|
||||||
|
|
||||||
apiClient.getBoards { result in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.isLoadingBoards = false
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case .success(let loadedBoards):
|
|
||||||
self.boards = loadedBoards
|
|
||||||
case .failure:
|
|
||||||
self.boards = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestNotificationPermission() {
|
private func requestNotificationPermission() {
|
||||||
notificationManager.requestPermission { granted in
|
notificationManager.requestPermission { granted in
|
||||||
if !granted {
|
if !granted {
|
||||||
@ -120,4 +150,45 @@ struct NotificationSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func checkNewThreadsNow() {
|
||||||
|
guard !notificationManager.subscribedBoards.isEmpty else { return }
|
||||||
|
|
||||||
|
for boardCode in notificationManager.subscribedBoards {
|
||||||
|
let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)")
|
||||||
|
|
||||||
|
apiClient.checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch result {
|
||||||
|
case .success(let newThreads):
|
||||||
|
if !newThreads.isEmpty {
|
||||||
|
for thread in newThreads {
|
||||||
|
notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
|
||||||
|
}
|
||||||
|
case .failure:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadBoards() {
|
||||||
|
isLoadingBoards = true
|
||||||
|
|
||||||
|
apiClient.getBoards { result in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
isLoadingBoards = false
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let loadedBoards):
|
||||||
|
boards = loadedBoards
|
||||||
|
case .failure:
|
||||||
|
boards = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -7,10 +7,13 @@ class Settings: ObservableObject {
|
|||||||
@Published var showFiles: Bool = true
|
@Published var showFiles: Bool = true
|
||||||
@Published var compactMode: Bool = false
|
@Published var compactMode: Bool = false
|
||||||
@Published var pageSize: Int = 10
|
@Published var pageSize: Int = 10
|
||||||
|
@Published var enablePagination: Bool = false
|
||||||
|
@Published var enableUnstableFeatures: Bool = false
|
||||||
@Published var passcode: String = ""
|
@Published var passcode: String = ""
|
||||||
@Published var key: String = ""
|
@Published var key: String = ""
|
||||||
@Published var notificationsEnabled: Bool = false
|
@Published var notificationsEnabled: Bool = false
|
||||||
@Published var notificationInterval: Int = 300
|
@Published var notificationInterval: Int = 300
|
||||||
|
@Published var favoriteThreads: [FavoriteThread] = []
|
||||||
|
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let settingsKey = "MobileMkchSettings"
|
private let settingsKey = "MobileMkchSettings"
|
||||||
@ -28,10 +31,13 @@ class Settings: ObservableObject {
|
|||||||
self.showFiles = settings.showFiles
|
self.showFiles = settings.showFiles
|
||||||
self.compactMode = settings.compactMode
|
self.compactMode = settings.compactMode
|
||||||
self.pageSize = settings.pageSize
|
self.pageSize = settings.pageSize
|
||||||
|
self.enablePagination = settings.enablePagination
|
||||||
|
self.enableUnstableFeatures = settings.enableUnstableFeatures
|
||||||
self.passcode = settings.passcode
|
self.passcode = settings.passcode
|
||||||
self.key = settings.key
|
self.key = settings.key
|
||||||
self.notificationsEnabled = settings.notificationsEnabled
|
self.notificationsEnabled = settings.notificationsEnabled
|
||||||
self.notificationInterval = settings.notificationInterval
|
self.notificationInterval = settings.notificationInterval
|
||||||
|
self.favoriteThreads = settings.favoriteThreads
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,10 +49,13 @@ class Settings: ObservableObject {
|
|||||||
showFiles: showFiles,
|
showFiles: showFiles,
|
||||||
compactMode: compactMode,
|
compactMode: compactMode,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
|
enablePagination: enablePagination,
|
||||||
|
enableUnstableFeatures: enableUnstableFeatures,
|
||||||
passcode: passcode,
|
passcode: passcode,
|
||||||
key: key,
|
key: key,
|
||||||
notificationsEnabled: notificationsEnabled,
|
notificationsEnabled: notificationsEnabled,
|
||||||
notificationInterval: notificationInterval
|
notificationInterval: notificationInterval,
|
||||||
|
favoriteThreads: favoriteThreads
|
||||||
)
|
)
|
||||||
|
|
||||||
if let data = try? JSONEncoder().encode(settingsData) {
|
if let data = try? JSONEncoder().encode(settingsData) {
|
||||||
@ -61,12 +70,36 @@ class Settings: ObservableObject {
|
|||||||
showFiles = true
|
showFiles = true
|
||||||
compactMode = false
|
compactMode = false
|
||||||
pageSize = 10
|
pageSize = 10
|
||||||
|
enablePagination = false
|
||||||
|
enableUnstableFeatures = false
|
||||||
passcode = ""
|
passcode = ""
|
||||||
key = ""
|
key = ""
|
||||||
notificationsEnabled = false
|
notificationsEnabled = false
|
||||||
notificationInterval = 300
|
notificationInterval = 300
|
||||||
|
favoriteThreads = []
|
||||||
saveSettings()
|
saveSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearImageCache() {
|
||||||
|
ImageCache.shared.clearCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addToFavorites(_ thread: Thread, board: Board) {
|
||||||
|
let favorite = FavoriteThread(thread: thread, board: board)
|
||||||
|
if !favoriteThreads.contains(where: { $0.id == thread.id && $0.board == board.code }) {
|
||||||
|
favoriteThreads.append(favorite)
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeFromFavorites(_ threadId: Int, boardCode: String) {
|
||||||
|
favoriteThreads.removeAll { $0.id == threadId && $0.board == boardCode }
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFavorite(_ threadId: Int, boardCode: String) -> Bool {
|
||||||
|
return favoriteThreads.contains { $0.id == threadId && $0.board == boardCode }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsData: Codable {
|
struct SettingsData: Codable {
|
||||||
@ -76,8 +109,11 @@ struct SettingsData: Codable {
|
|||||||
let showFiles: Bool
|
let showFiles: Bool
|
||||||
let compactMode: Bool
|
let compactMode: Bool
|
||||||
let pageSize: Int
|
let pageSize: Int
|
||||||
|
let enablePagination: Bool
|
||||||
|
let enableUnstableFeatures: Bool
|
||||||
let passcode: String
|
let passcode: String
|
||||||
let key: String
|
let key: String
|
||||||
let notificationsEnabled: Bool
|
let notificationsEnabled: Bool
|
||||||
let notificationInterval: Int
|
let notificationInterval: Int
|
||||||
|
let favoriteThreads: [FavoriteThread]
|
||||||
}
|
}
|
||||||
@ -5,7 +5,6 @@ import Darwin
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var settings: Settings
|
@EnvironmentObject var settings: Settings
|
||||||
@EnvironmentObject var apiClient: APIClient
|
@EnvironmentObject var apiClient: APIClient
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
@State private var showingAbout = false
|
@State private var showingAbout = false
|
||||||
@State private var showingInfo = false
|
@State private var showingInfo = false
|
||||||
@ -15,60 +14,122 @@ struct SettingsView: View {
|
|||||||
@State private var isTestingPasscode = false
|
@State private var isTestingPasscode = false
|
||||||
@State private var debugTapCount = 0
|
@State private var debugTapCount = 0
|
||||||
@State private var showingDebugMenu = false
|
@State private var showingDebugMenu = false
|
||||||
|
@State private var showingUnstableWarning = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Form {
|
Form {
|
||||||
Section("Внешний вид") {
|
Section("Внешний вид") {
|
||||||
Picker("Тема", selection: $settings.theme) {
|
HStack {
|
||||||
Text("Темная").tag("dark")
|
Image(systemName: "moon.fill")
|
||||||
Text("Светлая").tag("light")
|
.foregroundColor(.purple)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("Тема")
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $settings.theme) {
|
||||||
|
Text("Темная").tag("dark")
|
||||||
|
Text("Светлая").tag("light")
|
||||||
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
.frame(width: 120)
|
||||||
}
|
}
|
||||||
.onReceive(Just(settings.theme)) { _ in
|
.onReceive(Just(settings.theme)) { _ in
|
||||||
settings.saveSettings()
|
settings.saveSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
Toggle("Авторефреш", isOn: $settings.autoRefresh)
|
HStack {
|
||||||
.onReceive(Just(settings.autoRefresh)) { _ in
|
Image(systemName: "arrow.clockwise")
|
||||||
settings.saveSettings()
|
.foregroundColor(.green)
|
||||||
}
|
.frame(width: 24)
|
||||||
|
Toggle("Авторефреш", isOn: $settings.autoRefresh)
|
||||||
|
}
|
||||||
|
.onReceive(Just(settings.autoRefresh)) { _ in
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
Toggle("Показывать файлы", isOn: $settings.showFiles)
|
HStack {
|
||||||
.onReceive(Just(settings.showFiles)) { _ in
|
Image(systemName: "paperclip")
|
||||||
settings.saveSettings()
|
.foregroundColor(.blue)
|
||||||
}
|
.frame(width: 24)
|
||||||
|
Toggle("Показывать файлы", isOn: $settings.showFiles)
|
||||||
|
}
|
||||||
|
.onReceive(Just(settings.showFiles)) { _ in
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
Toggle("Компактный режим", isOn: $settings.compactMode)
|
HStack {
|
||||||
.onReceive(Just(settings.compactMode)) { _ in
|
Image(systemName: "rectangle.compress.vertical")
|
||||||
settings.saveSettings()
|
.foregroundColor(.orange)
|
||||||
}
|
.frame(width: 24)
|
||||||
|
Toggle("Компактный режим", isOn: $settings.compactMode)
|
||||||
|
}
|
||||||
|
.onReceive(Just(settings.compactMode)) { _ in
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
Picker("Размер страницы", selection: $settings.pageSize) {
|
HStack {
|
||||||
Text("5").tag(5)
|
Image(systemName: "list.bullet")
|
||||||
Text("10").tag(10)
|
.foregroundColor(.indigo)
|
||||||
Text("15").tag(15)
|
.frame(width: 24)
|
||||||
Text("20").tag(20)
|
Text("Размер страницы")
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $settings.pageSize) {
|
||||||
|
Text("5").tag(5)
|
||||||
|
Text("10").tag(10)
|
||||||
|
Text("15").tag(15)
|
||||||
|
Text("20").tag(20)
|
||||||
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
}
|
}
|
||||||
.onReceive(Just(settings.pageSize)) { _ in
|
.onReceive(Just(settings.pageSize)) { _ in
|
||||||
settings.saveSettings()
|
settings.saveSettings()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
HStack {
|
||||||
Section("Последняя доска") {
|
Image(systemName: "rectangle.split.2x1")
|
||||||
Text(settings.lastBoard.isEmpty ? "Не выбрана" : settings.lastBoard)
|
.foregroundColor(.teal)
|
||||||
.foregroundColor(.secondary)
|
.frame(width: 24)
|
||||||
|
Toggle("Разстраничивание", isOn: $settings.enablePagination)
|
||||||
|
}
|
||||||
|
.onReceive(Just(settings.enablePagination)) { _ in
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.frame(width: 24)
|
||||||
|
Toggle("Нестабильные функции", isOn: $settings.enableUnstableFeatures)
|
||||||
|
}
|
||||||
|
.onReceive(Just(settings.enableUnstableFeatures)) { newValue in
|
||||||
|
if newValue && !UserDefaults.standard.bool(forKey: "hasShownUnstableWarning") {
|
||||||
|
showingUnstableWarning = true
|
||||||
|
UserDefaults.standard.set(true, forKey: "hasShownUnstableWarning")
|
||||||
|
}
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Аутентификация") {
|
Section("Аутентификация") {
|
||||||
SecureField("Passcode для постинга", text: $settings.passcode)
|
HStack {
|
||||||
.onReceive(Just(settings.passcode)) { _ in
|
Image(systemName: "lock.shield")
|
||||||
settings.saveSettings()
|
.foregroundColor(.orange)
|
||||||
}
|
.frame(width: 24)
|
||||||
|
SecureField("Passcode для постинга", text: $settings.passcode)
|
||||||
|
}
|
||||||
|
.onReceive(Just(settings.passcode)) { _ in
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
SecureField("Ключ аутентификации", text: $settings.key)
|
HStack {
|
||||||
.onReceive(Just(settings.key)) { _ in
|
Image(systemName: "key")
|
||||||
settings.saveSettings()
|
.foregroundColor(.blue)
|
||||||
}
|
.frame(width: 24)
|
||||||
|
SecureField("Ключ аутентификации", text: $settings.key)
|
||||||
|
}
|
||||||
|
.onReceive(Just(settings.key)) { _ in
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Тест ключа") {
|
Button("Тест ключа") {
|
||||||
@ -112,14 +173,31 @@ struct SettingsView: View {
|
|||||||
NotificationSettingsView()
|
NotificationSettingsView()
|
||||||
.environmentObject(apiClient)
|
.environmentObject(apiClient)
|
||||||
}
|
}
|
||||||
|
.overlay(
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Управление кэшем") {
|
Section("Управление кэшем") {
|
||||||
Button("Очистить кэш досок") {
|
Button(action: {
|
||||||
Cache.shared.delete("boards")
|
Cache.shared.delete("boards")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "list.bullet")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("Очистить кэш досок")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Очистить кэш тредов") {
|
Button(action: {
|
||||||
apiClient.getBoards { result in
|
apiClient.getBoards { result in
|
||||||
if case .success(let boards) = result {
|
if case .success(let boards) = result {
|
||||||
for board in boards {
|
for board in boards {
|
||||||
@ -127,27 +205,80 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("Очистить кэш тредов")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Очистить весь кэш") {
|
Button(action: {
|
||||||
|
settings.clearImageCache()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("Очистить кэш изображений")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
Cache.shared.clear()
|
Cache.shared.clear()
|
||||||
|
settings.clearImageCache()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("Очистить весь кэш")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Сброс") {
|
|
||||||
Button("Сбросить настройки") {
|
|
||||||
settings.resetSettings()
|
|
||||||
}
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("Об аппке") {
|
Button(action: {
|
||||||
|
settings.resetSettings()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("Сбросить настройки")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(action: {
|
||||||
showingAbout = true
|
showingAbout = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("Об аппке")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Я думаю тебя направили сюда") {
|
Button(action: {
|
||||||
showingInfo = true
|
showingInfo = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text("Я думаю тебя направили сюда")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,14 +301,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Настройки")
|
.navigationTitle("Настройки")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Готово") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingAbout) {
|
.sheet(isPresented: $showingAbout) {
|
||||||
AboutView()
|
AboutView()
|
||||||
}
|
}
|
||||||
@ -185,6 +309,9 @@ struct SettingsView: View {
|
|||||||
DebugMenuView()
|
DebugMenuView()
|
||||||
.environmentObject(NotificationManager.shared)
|
.environmentObject(NotificationManager.shared)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingUnstableWarning) {
|
||||||
|
UnstableFeaturesWarningView(isPresented: $showingUnstableWarning)
|
||||||
|
}
|
||||||
.alert("Информация о НЕОЖИДАНЫХ проблемах", isPresented: $showingInfo) {
|
.alert("Информация о НЕОЖИДАНЫХ проблемах", isPresented: $showingInfo) {
|
||||||
Button("Закрыть") { }
|
Button("Закрыть") { }
|
||||||
} message: {
|
} message: {
|
||||||
@ -192,7 +319,9 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SettingsView {
|
||||||
private func testKey() {
|
private func testKey() {
|
||||||
guard !settings.key.isEmpty else { return }
|
guard !settings.key.isEmpty else { return }
|
||||||
|
|
||||||
@ -261,9 +390,9 @@ struct AboutView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Версия: 1.1.0-ios-alpha (Always in alpha lol)")
|
Text("Версия: 2.0.0-ios-alpha (Always in alpha lol)")
|
||||||
Text("Автор: w^x (лейн, платон, а похуй как угодно)")
|
Text("Автор: w^x (лейн, платон, а похуй как угодно)")
|
||||||
Text("Разработано с ❤️ на Свифт")
|
Text("Разработано с <3 на Свифт")
|
||||||
}
|
}
|
||||||
.font(.body)
|
.font(.body)
|
||||||
|
|
||||||
@ -321,6 +450,94 @@ struct DebugMenuView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct UnstableFeaturesWarningView: View {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@State private var timeRemaining = 10
|
||||||
|
@State private var canConfirm = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
|
||||||
|
Text("ВНИМАНИЕ!")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Вы собираетесь включить нестабильные функции")
|
||||||
|
.font(.headline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text("Эти функции находятся в разработке и могут:")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Работать нестабильно или не работать вовсе")
|
||||||
|
Text("Вызывать краши приложения")
|
||||||
|
Text("Потреблять больше ресурсов")
|
||||||
|
Text("Иметь неожиданное поведение")
|
||||||
|
}
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("НИКАКИЕ ЖАЛОБЫ на нестабильный функционал НЕ ПРИНИМАЮТСЯ!")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if !canConfirm {
|
||||||
|
Text("Подтверждение будет доступно через: \(timeRemaining)")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
isPresented = false
|
||||||
|
}) {
|
||||||
|
Text(canConfirm ? "Я уверен!" : "Отмена")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(canConfirm ? Color.red : Color.gray)
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
.disabled(!canConfirm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Предупреждение")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
startTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
|
||||||
|
if timeRemaining > 0 {
|
||||||
|
timeRemaining -= 1
|
||||||
|
} else {
|
||||||
|
canConfirm = true
|
||||||
|
timer.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(Settings())
|
.environmentObject(Settings())
|
||||||
|
|||||||
@ -145,99 +145,71 @@ struct ThreadContentView: View {
|
|||||||
struct CommentView: View {
|
struct CommentView: View {
|
||||||
let comment: Comment
|
let comment: Comment
|
||||||
let showFiles: Bool
|
let showFiles: Bool
|
||||||
|
@EnvironmentObject var settings: Settings
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
if settings.compactMode {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("ID: \(comment.id)")
|
HStack {
|
||||||
.font(.caption)
|
Text("ID: \(comment.id)")
|
||||||
.foregroundColor(.secondary)
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(comment.creationDate, style: .date)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
if !comment.text.isEmpty {
|
||||||
|
Text(comment.formattedText)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
Text(comment.creationDate, style: .date)
|
if showFiles && !comment.files.isEmpty {
|
||||||
.font(.caption)
|
HStack {
|
||||||
.foregroundColor(.secondary)
|
Image(systemName: "paperclip")
|
||||||
}
|
.foregroundColor(.blue)
|
||||||
|
.font(.caption2)
|
||||||
if showFiles && !comment.files.isEmpty {
|
Text("\(comment.files.count)")
|
||||||
FilesView(files: comment.files)
|
.font(.caption2)
|
||||||
}
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal, 8)
|
||||||
}
|
.padding(.vertical, 6)
|
||||||
}
|
.background(Color(.systemGray6))
|
||||||
|
|
||||||
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)
|
.cornerRadius(6)
|
||||||
}
|
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fileIcon: String {
|
|
||||||
if fileInfo.isImage {
|
|
||||||
return "photo"
|
|
||||||
} else if fileInfo.isVideo {
|
|
||||||
return "video"
|
|
||||||
} else {
|
} else {
|
||||||
return "doc"
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
}
|
HStack {
|
||||||
}
|
Text("ID: \(comment.id)")
|
||||||
|
.font(.caption)
|
||||||
private var fileColor: Color {
|
.foregroundColor(.secondary)
|
||||||
if fileInfo.isImage {
|
|
||||||
return .green
|
Spacer()
|
||||||
} else if fileInfo.isVideo {
|
|
||||||
return .red
|
Text(comment.creationDate, style: .date)
|
||||||
} else {
|
.font(.caption)
|
||||||
return .blue
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,45 +43,45 @@ struct ThreadsView: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(currentThreads) { thread in
|
ForEach(settings.enablePagination ? currentThreads : threads) { thread in
|
||||||
NavigationLink(destination: ThreadDetailView(board: board, thread: thread)
|
NavigationLink(destination: ThreadDetailView(board: board, thread: thread)
|
||||||
.environmentObject(settings)
|
.environmentObject(settings)
|
||||||
.environmentObject(apiClient)) {
|
.environmentObject(apiClient)) {
|
||||||
ThreadRow(thread: thread, showFiles: settings.showFiles)
|
ThreadRow(thread: thread, board: board, 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)
|
if settings.enablePagination && 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)/")
|
.navigationTitle("/\(board.code)/")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@ -129,57 +129,137 @@ struct ThreadsView: View {
|
|||||||
|
|
||||||
struct ThreadRow: View {
|
struct ThreadRow: View {
|
||||||
let thread: Thread
|
let thread: Thread
|
||||||
|
let board: Board
|
||||||
let showFiles: Bool
|
let showFiles: Bool
|
||||||
|
@EnvironmentObject var settings: Settings
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
if settings.compactMode {
|
||||||
HStack {
|
HStack {
|
||||||
Text("#\(thread.id): \(thread.title)")
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.font(.headline)
|
HStack {
|
||||||
.lineLimit(2)
|
Text("#\(thread.id)")
|
||||||
|
|
||||||
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)
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(thread.title)
|
||||||
|
.font(.body)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(thread.creationDate, style: .date)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
if thread.ratingValue > 0 {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(thread.ratingValue)")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showFiles && !thread.files.isEmpty {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "paperclip")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(thread.files.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if thread.isPinned {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if showFiles && !thread.files.isEmpty {
|
Button(action: {
|
||||||
HStack(spacing: 2) {
|
if settings.isFavorite(thread.id, boardCode: board.code) {
|
||||||
Image(systemName: "paperclip")
|
settings.removeFromFavorites(thread.id, boardCode: board.code)
|
||||||
.foregroundColor(.blue)
|
} else {
|
||||||
Text("\(thread.files.count)")
|
settings.addToFavorites(thread, board: board)
|
||||||
.font(.caption)
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: settings.isFavorite(thread.id, boardCode: board.code) ? "heart.fill" : "heart")
|
||||||
|
.foregroundColor(settings.isFavorite(thread.id, boardCode: board.code) ? .red : .gray)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text("#\(thread.id): \(thread.title)")
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if settings.isFavorite(thread.id, boardCode: board.code) {
|
||||||
|
settings.removeFromFavorites(thread.id, boardCode: board.code)
|
||||||
|
} else {
|
||||||
|
settings.addToFavorites(thread, board: board)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: settings.isFavorite(thread.id, boardCode: board.code) ? "heart.fill" : "heart")
|
||||||
|
.foregroundColor(settings.isFavorite(thread.id, boardCode: board.code) ? .red : .gray)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
||||||
|
if thread.isPinned {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
HStack {
|
||||||
}
|
Text(thread.creationDate, style: .date)
|
||||||
|
.font(.caption)
|
||||||
if !thread.text.isEmpty {
|
.foregroundColor(.secondary)
|
||||||
Text(thread.text)
|
|
||||||
.font(.body)
|
if thread.ratingValue > 0 {
|
||||||
.foregroundColor(.secondary)
|
HStack(spacing: 2) {
|
||||||
.lineLimit(3)
|
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)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
322
README.md
322
README.md
@ -1,145 +1,259 @@
|
|||||||
# MobileMkch iOS
|
# MobileMkch iOS
|
||||||
|
|
||||||
Мобильный клиент для борды mkch.pooziqo.xyz для iOS
|
Нативный iOS клиент для борды mkch.pooziqo.xyz
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## Скриншоты
|
## Скриншоты
|
||||||

|

|
||||||
|
|
||||||
## Возможности
|
## Основные возможности
|
||||||
|
|
||||||
- Просмотр всех досок Мкача
|
### Нативный iOS интерфейс
|
||||||
- Просмотр тредов в каждой доске с пагинацией
|
- **Tab Bar навигация** с тремя вкладками: Доски, Избранное, Настройки
|
||||||
- Просмотр деталей треда и комментариев
|
- **Адаптивный дизайн** для iPhone и iPad
|
||||||
- Поддержка изображений и видео
|
- **Темная/светлая тема** с автопереключением
|
||||||
- Темная/светлая тема
|
- **Нативные анимации** и жесты iOS
|
||||||
- Система настроек с сохранением:
|
- **SwiftUI интерфейс** для современного внешнего вида
|
||||||
- Тема (темная/светлая)
|
|
||||||
- Последняя посещенная доска
|
|
||||||
- Автообновление
|
|
||||||
- Показ файлов
|
|
||||||
- Компактный режим
|
|
||||||
- Размер страницы (5-20 тредов)
|
|
||||||
- **Полная поддержка постинга:**
|
|
||||||
- Аутентификация по ключу
|
|
||||||
- Аутентификация по passcode
|
|
||||||
- Создание тредов
|
|
||||||
- Добавление комментариев
|
|
||||||
- Автоматическое обновление после постинга
|
|
||||||
- **Оптимизации для iOS:**
|
|
||||||
- Нативная SwiftUI интерфейс
|
|
||||||
- Кэширование данных
|
|
||||||
- Оптимизация потребления батареи
|
|
||||||
- Поддержка iOS 15.0+
|
|
||||||
- **Push-уведомления о новых тредах:**
|
|
||||||
- Подписка на доски через тумблеры в настройках
|
|
||||||
- Настраиваемый интервал проверки (5 мин - 1 час)
|
|
||||||
- Фоновое обновление
|
|
||||||
- Задержка уведомлений 10 секунд
|
|
||||||
- Формат: "Новый тред: [название] в /boardname/"
|
|
||||||
- Тестовые уведомления в debug меню
|
|
||||||
|
|
||||||
## Аутентификация и постинг
|
### росмотр контента
|
||||||
|
- **Просмотр всех досок** mkch с описаниями
|
||||||
|
- **Список тредов** с поддержкой сортировки по рейтингу и закрепленным
|
||||||
|
- **Детальный просмотр тредов** с комментариями
|
||||||
|
- **Система файлов** с поддержкой изображений и видео
|
||||||
|
- **Полноэкранный просмотр изображений** с зумом и жестами
|
||||||
|
- **Компактный/обычный режим** отображения для экономии места
|
||||||
|
- **Пагинация** с настраиваемым размером страницы (5-20 элементов)
|
||||||
|
|
||||||
### Настройка аутентификации
|
### Система избранного
|
||||||
|
- **Добавление тредов в избранное** одним тапом
|
||||||
|
- **Отдельная вкладка избранного** с быстрым доступом
|
||||||
|
- **Локальное сохранение** избранного между запусками
|
||||||
|
- **Управление избранным** с возможностью удаления
|
||||||
|
|
||||||
1. Откройте настройки в приложении
|
### Гибкие настройки
|
||||||
2. Введите ключ аутентификации
|
- **Темы**: темная/светлая
|
||||||
3. Введите passcode для постинга (По наличию) (P.S. Увы, но пока-что не доработал пасскод, уважте, первая версия.)
|
- **Автообновление** контента
|
||||||
4. Используйте кнопки "Тест ключа" и "Тест passcode" для проверки
|
- **Показ файлов** (включение/отключение)
|
||||||
|
- **Компактный режим** для экономии пространства
|
||||||
|
- **Размер страницы**: от 5 до 20 элементов
|
||||||
|
- **Пагинация**: включение/отключение
|
||||||
|
- **Нестабильные функции** с предупреждением
|
||||||
|
|
||||||
### Создание тредов
|
### Полная поддержка постинга
|
||||||
|
- **Аутентификация по ключу** с тестированием подключения
|
||||||
|
- **Аутентификация по passcode** для обхода капчи
|
||||||
|
- **Создание новых тредов** с заголовком и текстом
|
||||||
|
- **Добавление комментариев** в существующие треды
|
||||||
|
- **CSRF защита** и автоматическая обработка форм
|
||||||
|
- **Автоочистка кэша** после постинга
|
||||||
|
|
||||||
1. Перейдите в нужную доску
|
### Push-уведомления (BETA)
|
||||||
2. Нажмите "Создать"
|
- **Подписка на доски** через интуитивные переключатели
|
||||||
3. Заполните заголовок и текст
|
- **Настраиваемый интервал проверки**: 5 мин - 1 час
|
||||||
4. Нажмите "Создать"
|
- **Фоновое обновление** даже при закрытом приложении
|
||||||
|
- **Принудительная проверка** новых тредов
|
||||||
|
- **Умные уведомления**: "Новый тред: [название] в /доска/"
|
||||||
|
- **Тестовые уведомления** в debug режиме
|
||||||
|
|
||||||
### Добавление комментариев
|
### Оптимизации и производительность
|
||||||
|
- **Многоуровневое кэширование**:
|
||||||
|
- Доски (TTL: 10 мин)
|
||||||
|
- Треды (TTL: 5 мин)
|
||||||
|
- Детали тредов и комментарии (TTL: 3 мин)
|
||||||
|
- Изображения с NSCache
|
||||||
|
- **Автоочистка кэша** по таймеру
|
||||||
|
- **Оптимизация батареи** для фоновых задач
|
||||||
|
- **Ленивая загрузка** контента
|
||||||
|
- **Graceful error handling** с retry логикой
|
||||||
|
|
||||||
1. Откройте тред
|
### Дополнительные функции
|
||||||
2. Нажмите "Добавить"
|
- **Crash Handler** с детальной диагностикой
|
||||||
3. Введите текст комментария
|
- **Debug меню** (5 тапов по информации об устройстве):
|
||||||
4. Нажмите "Добавить"
|
- Тест краша приложения
|
||||||
|
- Тестовые уведомления
|
||||||
|
- **Управление кэшем**:
|
||||||
|
- Очистка кэша досок
|
||||||
|
- Очистка кэша тредов
|
||||||
|
- Очистка кэша изображений
|
||||||
|
- Полная очистка
|
||||||
|
- **Информация об устройстве** с детальной диагностикой
|
||||||
|
- **Сброс настроек** до заводских
|
||||||
|
|
||||||
## Уведомления о новых тредах
|
## Установка и настройка
|
||||||
|
|
||||||
|
### Системные требования
|
||||||
|
- iOS 15.0 или новее
|
||||||
|
- iPhone/iPad с поддержкой SwiftUI
|
||||||
|
- Разрешение на уведомления (для push-уведомлений)
|
||||||
|
|
||||||
|
### Первоначальная настройка
|
||||||
|
|
||||||
|
1. **Скачайте и установите** приложение
|
||||||
|
2. **Откройте вкладку "Настройки"**
|
||||||
|
3. **Настройте аутентификацию** (при необходимости):
|
||||||
|
- Введите ключ аутентификации
|
||||||
|
- Введите passcode для постинга
|
||||||
|
- Протестируйте подключение кнопками "Тест ключа" и "Тест passcode"
|
||||||
|
|
||||||
### Настройка уведомлений
|
### Настройка уведомлений
|
||||||
|
|
||||||
1. Откройте настройки приложения
|
1. **Включите нестабильные функции** в настройках
|
||||||
2. Перейдите в "Настройки уведомлений"
|
2. **Перейдите в "Настройки уведомлений"**
|
||||||
3. Включите уведомления
|
3. **Включите уведомления** и разрешите их в системных настройках
|
||||||
4. Разрешите уведомления в системных настройках iOS
|
4. **Выберите интервал проверки** (рекомендуется 15-30 мин)
|
||||||
5. Настройте интервал проверки (5 мин - 1 час)
|
5. **Подпишитесь на нужные доски** переключателями
|
||||||
|
6. **Протестируйте** кнопкой "Проверить новые треды сейчас"
|
||||||
|
|
||||||
### Подписка на доски
|
## Архитектура приложения
|
||||||
|
|
||||||
1. Откройте настройки приложения
|
### Основные компоненты
|
||||||
2. Перейдите в "Настройки уведомлений"
|
|
||||||
3. Включите уведомления
|
|
||||||
4. В разделе "Подписки на доски" включите тумблеры для нужных досок
|
|
||||||
5. Для отписки отключите соответствующий тумблер
|
|
||||||
|
|
||||||
### Как это работает
|
| Файл | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| `MobileMkchApp.swift` | Точка входа приложения с crash handler |
|
||||||
|
| `MainTabView.swift` | Tab Bar с навигацией и избранным |
|
||||||
|
| `Models.swift` | Структуры данных (Board, Thread, Comment, etc.) |
|
||||||
|
| `APIClient.swift` | HTTP клиент с CSRF и аутентификацией |
|
||||||
|
| `Settings.swift` | Система настроек с JSON сериализацией |
|
||||||
|
| `Cache.swift` | Многоуровневое кэширование с TTL |
|
||||||
|
|
||||||
- Приложение периодически проверяет новые треды в фоне
|
### UI компоненты
|
||||||
- При обнаружении нового треда отправляется push-уведомление через 10 секунд
|
|
||||||
- Формат уведомления: "Новый тред: [название] в /boardname/"
|
|
||||||
- Подписки сохраняются между запусками приложения
|
|
||||||
- Управление подписками через тумблеры в настройках уведомлений
|
|
||||||
- Тестовые уведомления доступны в debug меню (5 нажатий на информацию об устройстве)
|
|
||||||
|
|
||||||
## Сборка
|
| Файл | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| `BoardsView.swift` | Список досок с ошибками и загрузкой |
|
||||||
|
| `ThreadsView.swift` | Треды с пагинацией и избранным |
|
||||||
|
| `ThreadDetailView.swift` | Детали треда с комментариями |
|
||||||
|
| `CreateThreadView.swift` | Форма создания треда |
|
||||||
|
| `AddCommentView.swift` | Форма добавления комментария |
|
||||||
|
| `SettingsView.swift` | Настройки с debug меню |
|
||||||
|
| `FileView.swift` | Просмотр файлов с полноэкранным режимом |
|
||||||
|
| `NotificationSettingsView.swift` | BETA настройки уведомлений |
|
||||||
|
|
||||||
### Требования
|
### Системные сервисы
|
||||||
|
|
||||||
|
| Файл | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| `NotificationManager.swift` | Push-уведомления и подписки |
|
||||||
|
| `BackgroundTaskManager.swift` | Фоновое обновление |
|
||||||
|
| `CrashHandler.swift` | Обработка крашей |
|
||||||
|
| `ImageLoader.swift` | Асинхронная загрузка изображений |
|
||||||
|
|
||||||
|
## API интеграция
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
- `GET /api/boards/` - список досок
|
||||||
|
- `GET /api/board/{code}` - треды доски
|
||||||
|
- `GET /api/board/{code}/thread/{id}` - детали треда
|
||||||
|
- `GET /api/board/{code}/thread/{id}/comments` - комментарии
|
||||||
|
- `POST /boards/board/{code}/new` - создание треда
|
||||||
|
- `POST /boards/board/{code}/thread/{id}/comment` - комментарий
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
- **Key auth**: `/key/auth/` с CSRF токенами
|
||||||
|
- **Passcode auth**: `/passcode/enter/` для обхода капчи
|
||||||
|
- **User-Agent**: `MobileMkch/[VERSION]`
|
||||||
|
|
||||||
|
### Кэширование стратегии
|
||||||
|
- **Доски**: 10 минут (редко меняются)
|
||||||
|
- **Треды**: 5 минут (часто обновляются)
|
||||||
|
- **Детали**: 3 минуты (могут изменяться)
|
||||||
|
- **Изображения**: NSCache с лимитами памяти
|
||||||
|
|
||||||
|
## Сборка проекта
|
||||||
|
|
||||||
|
### Требования разработчика
|
||||||
- Xcode 15.0+
|
- Xcode 15.0+
|
||||||
- iOS 15.0+
|
|
||||||
- macOS 13.0+
|
- macOS 13.0+
|
||||||
|
- Apple Developer Account (для распространения) (я все равно не использую)
|
||||||
|
|
||||||
### Сборка
|
### Локальная сборка
|
||||||
|
|
||||||
1. Откройте проект в Xcode:
|
|
||||||
```bash
|
```bash
|
||||||
|
# Клонируйте репозиторий
|
||||||
|
git clone <repository-url>
|
||||||
|
cd MobileMkch-iOS
|
||||||
|
|
||||||
|
# Откройте в Xcode
|
||||||
open MobileMkch.xcodeproj
|
open MobileMkch.xcodeproj
|
||||||
|
|
||||||
|
# Выберите устройство и запустите
|
||||||
|
# Cmd+R для сборки и запуска
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Выберите устройство или симулятор
|
|
||||||
|
|
||||||
3. Нажмите Cmd+R для сборки и запуска
|
|
||||||
|
|
||||||
### Распространение
|
### Распространение
|
||||||
|
```bash
|
||||||
|
# 1. Выберите "Any iOS Device"
|
||||||
|
# 2. Product -> Archive
|
||||||
|
# 3. Distribute App:
|
||||||
|
# - App Store Connect (для App Store)
|
||||||
|
# - Ad Hoc (для тестирования)
|
||||||
|
# - Development (для разработки)
|
||||||
|
```
|
||||||
|
|
||||||
1. Выберите "Any iOS Device" в схеме сборки
|
P.S. Костыль через Payload/MobileMkch.app в зипе и переименовании ее в .ipa будет работать почти всегда
|
||||||
2. Product и Archive
|
|
||||||
3. Distribute App через App Store Connect или Ad Hoc (Еще можно открыть архиве в файндер и там найти .app и закинув в Payload сжать папку в .ipa, но это слегка попердолинг увы)
|
|
||||||
|
|
||||||
## Структура проекта
|
## Версии и обновления
|
||||||
|
|
||||||
- `MobileMkchApp.swift` - точка входа приложения
|
### Версия 2.0.0-ios-alpha (Текущая)
|
||||||
- `Models.swift` - структуры данных
|
- Полная переработка UI на SwiftUI
|
||||||
- `APIClient.swift` - HTTP клиент для mkch API
|
- Система избранного с локальным сохранением
|
||||||
- `Settings.swift` - система настроек
|
- Push-уведомления с фоновым обновлением
|
||||||
- `Cache.swift` - система кэширования
|
- Полноэкранный просмотр изображений с жестами
|
||||||
- `BoardsView.swift` - список досок
|
- Crash handler с детальной диагностикой
|
||||||
- `ThreadsView.swift` - треды доски с пагинацией
|
- Многоуровневое кэширование с TTL
|
||||||
- `ThreadDetailView.swift` - детали треда
|
- Debug меню для разработчиков
|
||||||
- `CreateThreadView.swift` - создание тредов
|
- Компактный режим интерфейса
|
||||||
- `AddCommentView.swift` - добавление комментариев
|
- Нестабильные функции с предупреждениями
|
||||||
- `SettingsView.swift` - экран настроек
|
|
||||||
- `NotificationManager.swift` - управление уведомлениями и тестовые уведомления
|
### Планы развития
|
||||||
- `BackgroundTaskManager.swift` - фоновые задачи
|
- Поддержка загрузки файлов при постинге
|
||||||
- `NotificationSettingsView.swift` - настройки уведомлений с тумблерами подписок
|
- Офлайн режим чтения
|
||||||
|
- Поиск по тредам и комментариям
|
||||||
|
- Темы оформления (кастомные цвета)
|
||||||
|
- Статистика использования (мб и не будет, я не знаю мне лень)
|
||||||
|
- Экспорт/импорт настроек
|
||||||
|
|
||||||
## Технологии
|
## Технологии
|
||||||
|
|
||||||
- SwiftUI
|
### Основной стек
|
||||||
- Combine
|
- **SwiftUI** - современный UI фреймворк
|
||||||
- Foundation
|
- **Combine** - реактивное программирование
|
||||||
- UIKit (для совместимости)
|
- **Foundation** - базовые возможности
|
||||||
|
- **UserNotifications** - push-уведомления
|
||||||
|
- **BackgroundTasks** - фоновое обновление
|
||||||
|
|
||||||
## Совместимость
|
### Архитектурные паттерны
|
||||||
|
- **MVVM** с ObservableObject
|
||||||
|
- **Dependency Injection** через EnvironmentObject
|
||||||
|
- **Repository Pattern** для API и кэша
|
||||||
|
- **Observer Pattern** для уведомлений
|
||||||
|
|
||||||
- iOS 15.0+
|
## Поддержка и вклад
|
||||||
- iPhone и iPad (айпад СЛЕГКА поломан, проверил, мб чет с этим сделаю, лень)
|
|
||||||
- Поддержка темной/светлой темы
|
### Сообщение об ошибках
|
||||||
- Адаптивный интерфейс
|
1. **Debug информация**: 5 тапов по информации об устройстве в настройках
|
||||||
|
2. **Скриншот краша** (если произошел)
|
||||||
|
3. **Шаги воспроизведения** ошибки
|
||||||
|
4. **Информация об устройстве** из настроек
|
||||||
|
|
||||||
|
### Известные ограничения
|
||||||
|
- iPad интерфейс требует доработки
|
||||||
|
- Постинг файлов пока не поддерживается
|
||||||
|
- Push-уведомления в beta статусе
|
||||||
|
- Требуется passcode для стабильного постинга
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Это приложение разработано для сообщества mkch и распространяется на условиях открытого использования. (0BSD btw)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Автор**: w^x (лейн, платон)
|
||||||
|
**Контакт**: mkch.pooziqo.xyz
|
||||||
|
**Версия**: 2.0.0-ios-alpha (Always in alpha lol)
|
||||||
|
**Дата**: Январь 2025
|
||||||
|
|
||||||
|
*Разработано с <3 на Swift*
|
||||||
Loading…
x
Reference in New Issue
Block a user