global update v2 победа z

This commit is contained in:
Lain Iwakura 2025-08-07 13:27:12 +03:00
parent e27e343e53
commit 7d2eb1fd19
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
16 changed files with 1421 additions and 428 deletions

14
LICENSE Normal file
View 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.

View File

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

View File

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

View File

@ -6,9 +6,9 @@ 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 {
NavigationView {
List { List {
if isLoading { if isLoading {
HStack { HStack {
@ -43,24 +43,12 @@ struct BoardsView: View {
} }
} }
.navigationTitle("Доски mkch") .navigationTitle("Доски mkch")
.navigationBarTitleDisplayMode(.large)
.onAppear { .onAppear {
if boards.isEmpty { if boards.isEmpty {
loadBoards() loadBoards()
} }
} }
.sheet(isPresented: $showingSettings) {
SettingsView()
.environmentObject(settings)
.environmentObject(apiClient)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingSettings = true
}) {
Image(systemName: "gearshape")
}
}
} }
} }

View File

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

View 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()
}
}
}
}

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

View File

@ -28,12 +28,10 @@ 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)
} }
} }

View File

@ -87,3 +87,19 @@ struct APIError: Error {
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()
}
}

View File

@ -9,6 +9,44 @@ struct NotificationSettingsView: View {
@State private var isLoadingBoards = false @State private var isLoadingBoards = false
var body: some View { var body: some View {
VStack {
if !settings.enableUnstableFeatures {
VStack(spacing: 16) {
Image(systemName: "lock.fill")
.font(.system(size: 50))
.foregroundColor(.gray)
Text("Функция заблокирована")
.font(.title2)
.fontWeight(.bold)
Text("Для использования уведомлений необходимо включить нестабильные функции в настройках.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("BETA Функция")
.font(.headline)
.foregroundColor(.orange)
}
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
.padding(.horizontal)
Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
.padding(.bottom)
Form { Form {
Section(header: Text("Уведомления")) { Section(header: Text("Уведомления")) {
Toggle("Включить уведомления", isOn: $settings.notificationsEnabled) Toggle("Включить уведомления", isOn: $settings.notificationsEnabled)
@ -34,6 +72,11 @@ struct NotificationSettingsView: View {
.onChange(of: settings.notificationInterval) { _ in .onChange(of: settings.notificationInterval) { _ in
settings.saveSettings() settings.saveSettings()
} }
Button("Проверить новые треды сейчас") {
checkNewThreadsNow()
}
.foregroundColor(.blue)
} }
} }
@ -76,6 +119,8 @@ struct NotificationSettingsView: View {
} }
} }
} }
}
}
.navigationTitle("Уведомления") .navigationTitle("Уведомления")
.onAppear { .onAppear {
if boards.isEmpty { if boards.isEmpty {
@ -95,24 +140,9 @@ struct NotificationSettingsView: View {
Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках") Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках")
} }
} }
private func loadBoards() {
isLoadingBoards = true
apiClient.getBoards { result in
DispatchQueue.main.async {
self.isLoadingBoards = false
switch result {
case .success(let loadedBoards):
self.boards = loadedBoards
case .failure:
self.boards = []
}
}
}
} }
extension NotificationSettingsView {
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 = []
}
}
}
}
} }

View File

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

View File

@ -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,57 +14,119 @@ 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 {
Image(systemName: "moon.fill")
.foregroundColor(.purple)
.frame(width: 24)
Text("Тема")
Spacer()
Picker("", selection: $settings.theme) {
Text("Темная").tag("dark") Text("Темная").tag("dark")
Text("Светлая").tag("light") Text("Светлая").tag("light")
} }
.pickerStyle(SegmentedPickerStyle())
.frame(width: 120)
}
.onReceive(Just(settings.theme)) { _ in .onReceive(Just(settings.theme)) { _ in
settings.saveSettings() settings.saveSettings()
} }
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.green)
.frame(width: 24)
Toggle("Авторефреш", isOn: $settings.autoRefresh) Toggle("Авторефреш", isOn: $settings.autoRefresh)
}
.onReceive(Just(settings.autoRefresh)) { _ in .onReceive(Just(settings.autoRefresh)) { _ in
settings.saveSettings() settings.saveSettings()
} }
HStack {
Image(systemName: "paperclip")
.foregroundColor(.blue)
.frame(width: 24)
Toggle("Показывать файлы", isOn: $settings.showFiles) Toggle("Показывать файлы", isOn: $settings.showFiles)
}
.onReceive(Just(settings.showFiles)) { _ in .onReceive(Just(settings.showFiles)) { _ in
settings.saveSettings() settings.saveSettings()
} }
HStack {
Image(systemName: "rectangle.compress.vertical")
.foregroundColor(.orange)
.frame(width: 24)
Toggle("Компактный режим", isOn: $settings.compactMode) Toggle("Компактный режим", isOn: $settings.compactMode)
}
.onReceive(Just(settings.compactMode)) { _ in .onReceive(Just(settings.compactMode)) { _ in
settings.saveSettings() settings.saveSettings()
} }
Picker("Размер страницы", selection: $settings.pageSize) { HStack {
Image(systemName: "list.bullet")
.foregroundColor(.indigo)
.frame(width: 24)
Text("Размер страницы")
Spacer()
Picker("", selection: $settings.pageSize) {
Text("5").tag(5) Text("5").tag(5)
Text("10").tag(10) Text("10").tag(10)
Text("15").tag(15) Text("15").tag(15)
Text("20").tag(20) Text("20").tag(20)
} }
.pickerStyle(MenuPickerStyle())
}
.onReceive(Just(settings.pageSize)) { _ in .onReceive(Just(settings.pageSize)) { _ in
settings.saveSettings() settings.saveSettings()
} }
HStack {
Image(systemName: "rectangle.split.2x1")
.foregroundColor(.teal)
.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("Последняя доска") {
Text(settings.lastBoard.isEmpty ? "Не выбрана" : settings.lastBoard)
.foregroundColor(.secondary)
}
Section("Аутентификация") { Section("Аутентификация") {
HStack {
Image(systemName: "lock.shield")
.foregroundColor(.orange)
.frame(width: 24)
SecureField("Passcode для постинга", text: $settings.passcode) SecureField("Passcode для постинга", text: $settings.passcode)
}
.onReceive(Just(settings.passcode)) { _ in .onReceive(Just(settings.passcode)) { _ in
settings.saveSettings() settings.saveSettings()
} }
HStack {
Image(systemName: "key")
.foregroundColor(.blue)
.frame(width: 24)
SecureField("Ключ аутентификации", text: $settings.key) SecureField("Ключ аутентификации", text: $settings.key)
}
.onReceive(Just(settings.key)) { _ in .onReceive(Just(settings.key)) { _ in
settings.saveSettings() settings.saveSettings()
} }
@ -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 {
Section("Сброс") { Image(systemName: "trash")
Button("Сбросить настройки") {
settings.resetSettings()
}
.foregroundColor(.red) .foregroundColor(.red)
.frame(width: 24)
Text("Очистить весь кэш")
Spacer()
}
}
} }
Section { Section {
Button("Об аппке") { Button(action: {
showingAbout = true settings.resetSettings()
}) {
HStack {
Image(systemName: "arrow.counterclockwise")
.foregroundColor(.red)
.frame(width: 24)
Text("Сбросить настройки")
.foregroundColor(.red)
Spacer()
}
}
} }
Button("Я думаю тебя направили сюда") { Section {
Button(action: {
showingAbout = true
}) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
.frame(width: 24)
Text("Об аппке")
Spacer()
}
}
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())

View File

@ -145,8 +145,46 @@ 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 {
if settings.compactMode {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("ID: \(comment.id)")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text(comment.creationDate, style: .date)
.font(.caption2)
.foregroundColor(.secondary)
}
if !comment.text.isEmpty {
Text(comment.formattedText)
.font(.caption)
.lineLimit(2)
}
if showFiles && !comment.files.isEmpty {
HStack {
Image(systemName: "paperclip")
.foregroundColor(.blue)
.font(.caption2)
Text("\(comment.files.count)")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
}
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color(.systemGray6))
.cornerRadius(6)
} else {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("ID: \(comment.id)") Text("ID: \(comment.id)")
@ -174,72 +212,6 @@ struct CommentView: View {
.cornerRadius(8) .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 { #Preview {

View File

@ -45,18 +45,18 @@ struct ThreadsView: View {
.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 { if settings.enablePagination && totalPages > 1 {
HStack { HStack {
Button("") { Button("<-") {
if currentPage > 0 { if currentPage > 0 {
currentPage -= 1 currentPage -= 1
} }
@ -71,7 +71,7 @@ struct ThreadsView: View {
Spacer() Spacer()
Button("") { Button("->") {
if currentPage < totalPages - 1 { if currentPage < totalPages - 1 {
currentPage += 1 currentPage += 1
} }
@ -129,9 +129,76 @@ 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 {
if settings.compactMode {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text("#\(thread.id)")
.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()
}
}
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)
.font(.caption)
}
.buttonStyle(PlainButtonStyle())
}
.padding(.vertical, 2)
} else {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
Text("#\(thread.id): \(thread.title)") Text("#\(thread.id): \(thread.title)")
@ -140,6 +207,18 @@ struct ThreadRow: View {
Spacer() 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 { if thread.isPinned {
Image(systemName: "pin.fill") Image(systemName: "pin.fill")
.foregroundColor(.orange) .foregroundColor(.orange)
@ -182,6 +261,7 @@ struct ThreadRow: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} }
} }
}
#Preview { #Preview {
NavigationView { NavigationView {

322
README.md
View File

@ -1,145 +1,259 @@
# MobileMkch iOS # MobileMkch iOS
Мобильный клиент для борды mkch.pooziqo.xyz для iOS Нативный iOS клиент для борды mkch.pooziqo.xyz
![Version](https://img.shields.io/badge/версия-2.0.0--ios--alpha-blue)
![iOS](https://img.shields.io/badge/iOS-15.0%2B-green)
![Swift](https://img.shields.io/badge/Swift-5-orange)
## Скриншоты ## Скриншоты
![screenshots](https://git.fuckyougoogle.xyz/MKFun/MobileMkch-iOS/raw/branch/main/screenshot/screenshot.png) ![screenshots](https://git.fuckyougoogle.xyz/MKFun/MobileMkch-iOS/raw/branch/main/screenshot/screenshot.png)
## Возможности ## Основные возможности
- Просмотр всех досок Мкача ### Нативный 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*