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)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "1.1.0-ios";
MARKETING_VERSION = "2.0.0-ios";
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@ -317,7 +317,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "1.1.0-ios";
MARKETING_VERSION = "2.0.0-ios";
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";

View File

@ -6,6 +6,7 @@ class APIClient: ObservableObject {
private let session = URLSession.shared
private var authKey: String = ""
private var passcode: String = ""
private let userAgent = "MobileMkch/2.0.0-ios-alpha"
func authenticate(authKey: String, completion: @escaping (Error?) -> Void) {
self.authKey = authKey
@ -13,6 +14,7 @@ class APIClient: ObservableObject {
let url = URL(string: "\(baseURL)/key/auth/")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
@ -50,6 +52,7 @@ class APIClient: ObservableObject {
postRequest.httpMethod = "POST"
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue("\(self.baseURL)/key/auth/", forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
postRequest.httpBody = formData.query?.data(using: .utf8)
self.session.dataTask(with: postRequest) { _, postResponse, postError in
@ -78,6 +81,7 @@ class APIClient: ObservableObject {
let url = URL(string: "\(baseURL)/passcode/enter/")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
@ -144,8 +148,10 @@ class APIClient: ObservableObject {
}
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 {
if let error = error {
completion(.failure(error))
@ -181,8 +187,10 @@ class APIClient: ObservableObject {
}
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 {
if let error = error {
completion(.failure(error))
@ -218,8 +226,10 @@ class APIClient: ObservableObject {
}
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 {
if let error = error {
completion(.failure(error))
@ -255,8 +265,10 @@ class APIClient: ObservableObject {
}
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 {
if let error = error {
completion(.failure(error))
@ -354,6 +366,7 @@ class APIClient: ObservableObject {
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
@ -392,6 +405,7 @@ class APIClient: ObservableObject {
postRequest.httpMethod = "POST"
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
postRequest.httpBody = formData.query?.data(using: .utf8)
self.session.dataTask(with: postRequest) { _, postResponse, postError in
@ -435,6 +449,7 @@ class APIClient: ObservableObject {
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
@ -472,6 +487,7 @@ class APIClient: ObservableObject {
postRequest.httpMethod = "POST"
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
postRequest.httpBody = formData.query?.data(using: .utf8)
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) {
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 {
if let error = error {
completion(.failure(error))

View File

@ -6,10 +6,10 @@ struct BoardsView: View {
@State private var boards: [Board] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showingSettings = false
var body: some View {
List {
NavigationView {
List {
if isLoading {
HStack {
ProgressView()
@ -41,24 +41,12 @@ struct BoardsView: View {
}
}
}
}
.navigationTitle("Доски mkch")
.onAppear {
if boards.isEmpty {
loadBoards()
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
.environmentObject(settings)
.environmentObject(apiClient)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingSettings = true
}) {
Image(systemName: "gearshape")
.navigationTitle("Доски mkch")
.navigationBarTitleDisplayMode(.large)
.onAppear {
if boards.isEmpty {
loadBoards()
}
}
}

View File

@ -34,7 +34,7 @@ class Cache {
return nil
}
guard let data = item.data as? Data else { return nil }
let data = item.data
do {
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,13 +28,11 @@ struct MobileMkchApp: App {
if crashHandler.hasCrashed {
CrashScreen()
} else {
NavigationView {
BoardsView()
.environmentObject(settings)
.environmentObject(apiClient)
.environmentObject(notificationManager)
}
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
MainTabView()
.environmentObject(settings)
.environmentObject(apiClient)
.environmentObject(notificationManager)
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
}
}
.onAppear {

View File

@ -87,3 +87,19 @@ struct APIError: Error {
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,67 +9,112 @@ struct NotificationSettingsView: View {
@State private var isLoadingBoards = false
var body: some View {
Form {
Section(header: Text("Уведомления")) {
Toggle("Включить уведомления", isOn: $settings.notificationsEnabled)
.onChange(of: settings.notificationsEnabled) { newValue in
if newValue {
requestNotificationPermission()
}
settings.saveSettings()
}
VStack {
if !settings.enableUnstableFeatures {
VStack(spacing: 16) {
Image(systemName: "lock.fill")
.font(.system(size: 50))
.foregroundColor(.gray)
if settings.notificationsEnabled {
HStack {
Text("Интервал проверки")
Spacer()
Picker("", selection: $settings.notificationInterval) {
Text("5 мин").tag(300)
Text("15 мин").tag(900)
Text("30 мин").tag(1800)
Text("1 час").tag(3600)
}
.pickerStyle(MenuPickerStyle())
}
.onChange(of: settings.notificationInterval) { _ in
settings.saveSettings()
}
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)
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)
Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
.padding(.bottom)
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()
Picker("", selection: $settings.notificationInterval) {
Text("5 мин").tag(300)
Text("15 мин").tag(900)
Text("30 мин").tag(1800)
Text("1 час").tag(3600)
}
.pickerStyle(MenuPickerStyle())
}
.onChange(of: settings.notificationInterval) { _ in
settings.saveSettings()
}
Toggle("", isOn: Binding(
get: { notificationManager.subscribedBoards.contains(board.code) },
set: { isSubscribed in
if isSubscribed {
notificationManager.subscribeToBoard(board.code)
} else {
notificationManager.unsubscribeFromBoard(board.code)
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("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках")
}
}
}
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() {
notificationManager.requestPermission { granted in
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 compactMode: Bool = false
@Published var pageSize: Int = 10
@Published var enablePagination: Bool = false
@Published var enableUnstableFeatures: Bool = false
@Published var passcode: String = ""
@Published var key: String = ""
@Published var notificationsEnabled: Bool = false
@Published var notificationInterval: Int = 300
@Published var favoriteThreads: [FavoriteThread] = []
private let userDefaults = UserDefaults.standard
private let settingsKey = "MobileMkchSettings"
@ -28,10 +31,13 @@ class Settings: ObservableObject {
self.showFiles = settings.showFiles
self.compactMode = settings.compactMode
self.pageSize = settings.pageSize
self.enablePagination = settings.enablePagination
self.enableUnstableFeatures = settings.enableUnstableFeatures
self.passcode = settings.passcode
self.key = settings.key
self.notificationsEnabled = settings.notificationsEnabled
self.notificationInterval = settings.notificationInterval
self.favoriteThreads = settings.favoriteThreads
}
}
@ -43,10 +49,13 @@ class Settings: ObservableObject {
showFiles: showFiles,
compactMode: compactMode,
pageSize: pageSize,
enablePagination: enablePagination,
enableUnstableFeatures: enableUnstableFeatures,
passcode: passcode,
key: key,
notificationsEnabled: notificationsEnabled,
notificationInterval: notificationInterval
notificationInterval: notificationInterval,
favoriteThreads: favoriteThreads
)
if let data = try? JSONEncoder().encode(settingsData) {
@ -61,12 +70,36 @@ class Settings: ObservableObject {
showFiles = true
compactMode = false
pageSize = 10
enablePagination = false
enableUnstableFeatures = false
passcode = ""
key = ""
notificationsEnabled = false
notificationInterval = 300
favoriteThreads = []
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 {
@ -76,8 +109,11 @@ struct SettingsData: Codable {
let showFiles: Bool
let compactMode: Bool
let pageSize: Int
let enablePagination: Bool
let enableUnstableFeatures: Bool
let passcode: String
let key: String
let notificationsEnabled: Bool
let notificationInterval: Int
let favoriteThreads: [FavoriteThread]
}

View File

@ -5,7 +5,6 @@ import Darwin
struct SettingsView: View {
@EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient
@Environment(\.dismiss) private var dismiss
@State private var showingAbout = false
@State private var showingInfo = false
@ -15,60 +14,122 @@ struct SettingsView: View {
@State private var isTestingPasscode = false
@State private var debugTapCount = 0
@State private var showingDebugMenu = false
@State private var showingUnstableWarning = false
var body: some View {
NavigationView {
Form {
Section("Внешний вид") {
Picker("Тема", selection: $settings.theme) {
Text("Темная").tag("dark")
Text("Светлая").tag("light")
HStack {
Image(systemName: "moon.fill")
.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
settings.saveSettings()
}
Toggle("Авторефреш", isOn: $settings.autoRefresh)
.onReceive(Just(settings.autoRefresh)) { _ in
settings.saveSettings()
}
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.green)
.frame(width: 24)
Toggle("Авторефреш", isOn: $settings.autoRefresh)
}
.onReceive(Just(settings.autoRefresh)) { _ in
settings.saveSettings()
}
Toggle("Показывать файлы", isOn: $settings.showFiles)
.onReceive(Just(settings.showFiles)) { _ in
settings.saveSettings()
}
HStack {
Image(systemName: "paperclip")
.foregroundColor(.blue)
.frame(width: 24)
Toggle("Показывать файлы", isOn: $settings.showFiles)
}
.onReceive(Just(settings.showFiles)) { _ in
settings.saveSettings()
}
Toggle("Компактный режим", isOn: $settings.compactMode)
.onReceive(Just(settings.compactMode)) { _ in
settings.saveSettings()
}
HStack {
Image(systemName: "rectangle.compress.vertical")
.foregroundColor(.orange)
.frame(width: 24)
Toggle("Компактный режим", isOn: $settings.compactMode)
}
.onReceive(Just(settings.compactMode)) { _ in
settings.saveSettings()
}
Picker("Размер страницы", selection: $settings.pageSize) {
Text("5").tag(5)
Text("10").tag(10)
Text("15").tag(15)
Text("20").tag(20)
HStack {
Image(systemName: "list.bullet")
.foregroundColor(.indigo)
.frame(width: 24)
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
settings.saveSettings()
}
}
Section("Последняя доска") {
Text(settings.lastBoard.isEmpty ? "Не выбрана" : settings.lastBoard)
.foregroundColor(.secondary)
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("Аутентификация") {
SecureField("Passcode для постинга", text: $settings.passcode)
.onReceive(Just(settings.passcode)) { _ in
settings.saveSettings()
}
HStack {
Image(systemName: "lock.shield")
.foregroundColor(.orange)
.frame(width: 24)
SecureField("Passcode для постинга", text: $settings.passcode)
}
.onReceive(Just(settings.passcode)) { _ in
settings.saveSettings()
}
SecureField("Ключ аутентификации", text: $settings.key)
.onReceive(Just(settings.key)) { _ in
settings.saveSettings()
}
HStack {
Image(systemName: "key")
.foregroundColor(.blue)
.frame(width: 24)
SecureField("Ключ аутентификации", text: $settings.key)
}
.onReceive(Just(settings.key)) { _ in
settings.saveSettings()
}
HStack {
Button("Тест ключа") {
@ -112,14 +173,31 @@ struct SettingsView: View {
NotificationSettingsView()
.environmentObject(apiClient)
}
.overlay(
HStack {
Spacer()
Image(systemName: "sparkles")
.font(.caption2)
.foregroundColor(.orange)
}
.padding(.trailing, 8)
)
}
Section("Управление кэшем") {
Button("Очистить кэш досок") {
Button(action: {
Cache.shared.delete("boards")
}) {
HStack {
Image(systemName: "list.bullet")
.foregroundColor(.blue)
.frame(width: 24)
Text("Очистить кэш досок")
Spacer()
}
}
Button("Очистить кэш тредов") {
Button(action: {
apiClient.getBoards { result in
if case .success(let boards) = result {
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()
settings.clearImageCache()
}) {
HStack {
Image(systemName: "trash")
.foregroundColor(.red)
.frame(width: 24)
Text("Очистить весь кэш")
Spacer()
}
}
}
Section("Сброс") {
Button("Сбросить настройки") {
settings.resetSettings()
}
.foregroundColor(.red)
}
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
}) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
.frame(width: 24)
Text("Об аппке")
Spacer()
}
}
Button("Я думаю тебя направили сюда") {
Button(action: {
showingInfo = true
}) {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
.frame(width: 24)
Text("Я думаю тебя направили сюда")
Spacer()
}
}
}
@ -170,14 +301,7 @@ struct SettingsView: View {
}
}
.navigationTitle("Настройки")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Готово") {
dismiss()
}
}
}
.navigationBarTitleDisplayMode(.large)
.sheet(isPresented: $showingAbout) {
AboutView()
}
@ -185,6 +309,9 @@ struct SettingsView: View {
DebugMenuView()
.environmentObject(NotificationManager.shared)
}
.sheet(isPresented: $showingUnstableWarning) {
UnstableFeaturesWarningView(isPresented: $showingUnstableWarning)
}
.alert("Информация о НЕОЖИДАНЫХ проблемах", isPresented: $showingInfo) {
Button("Закрыть") { }
} message: {
@ -192,7 +319,9 @@ struct SettingsView: View {
}
}
}
}
extension SettingsView {
private func testKey() {
guard !settings.key.isEmpty else { return }
@ -261,9 +390,9 @@ struct AboutView: View {
Divider()
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("Разработано с ❤️ на Свифт")
Text("Разработано с <3 на Свифт")
}
.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 {
SettingsView()
.environmentObject(Settings())

View File

@ -145,99 +145,71 @@ struct ThreadContentView: View {
struct CommentView: View {
let comment: Comment
let showFiles: Bool
@EnvironmentObject var settings: Settings
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("ID: \(comment.id)")
.font(.caption)
.foregroundColor(.secondary)
if settings.compactMode {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("ID: \(comment.id)")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Spacer()
Text(comment.creationDate, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
Text(comment.creationDate, style: .date)
.font(.caption2)
.foregroundColor(.secondary)
}
if showFiles && !comment.files.isEmpty {
FilesView(files: comment.files)
}
if !comment.text.isEmpty {
Text(comment.formattedText)
.font(.caption)
.lineLimit(2)
}
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))
if showFiles && !comment.files.isEmpty {
HStack {
Image(systemName: "paperclip")
.foregroundColor(.blue)
.font(.caption2)
Text("\(comment.files.count)")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
}
}
}
}
}
}
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))
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color(.systemGray6))
.cornerRadius(6)
}
.buttonStyle(PlainButtonStyle())
}
private var fileIcon: String {
if fileInfo.isImage {
return "photo"
} else if fileInfo.isVideo {
return "video"
} else {
return "doc"
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("ID: \(comment.id)")
.font(.caption)
.foregroundColor(.secondary)
private var fileColor: Color {
if fileInfo.isImage {
return .green
} else if fileInfo.isVideo {
return .red
} else {
return .blue
Spacer()
Text(comment.creationDate, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
if showFiles && !comment.files.isEmpty {
FilesView(files: comment.files)
}
if !comment.text.isEmpty {
Text(comment.formattedText)
.font(.body)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}

View File

@ -43,45 +43,45 @@ struct ThreadsView: View {
.buttonStyle(.bordered)
}
.padding()
} else {
List {
ForEach(currentThreads) { thread in
NavigationLink(destination: ThreadDetailView(board: board, thread: thread)
.environmentObject(settings)
.environmentObject(apiClient)) {
ThreadRow(thread: thread, showFiles: settings.showFiles)
}
}
}
if totalPages > 1 {
HStack {
Button("") {
if currentPage > 0 {
currentPage -= 1
} else {
List {
ForEach(settings.enablePagination ? currentThreads : threads) { thread in
NavigationLink(destination: ThreadDetailView(board: board, thread: thread)
.environmentObject(settings)
.environmentObject(apiClient)) {
ThreadRow(thread: thread, board: board, showFiles: settings.showFiles)
}
}
.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)/")
.navigationBarTitleDisplayMode(.inline)
@ -129,57 +129,137 @@ struct ThreadsView: View {
struct ThreadRow: View {
let thread: Thread
let board: Board
let showFiles: Bool
@EnvironmentObject var settings: Settings
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if settings.compactMode {
HStack {
Text("#\(thread.id): \(thread.title)")
.font(.headline)
.lineLimit(2)
Spacer()
if thread.isPinned {
Image(systemName: "pin.fill")
.foregroundColor(.orange)
}
}
HStack {
Text(thread.creationDate, style: .date)
.font(.caption)
.foregroundColor(.secondary)
if thread.ratingValue > 0 {
HStack(spacing: 2) {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text("\(thread.ratingValue)")
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()
}
}
if showFiles && !thread.files.isEmpty {
HStack(spacing: 2) {
Image(systemName: "paperclip")
.foregroundColor(.blue)
Text("\(thread.files.count)")
.font(.caption)
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) {
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)
.foregroundColor(.secondary)
if !thread.text.isEmpty {
Text(thread.text)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(3)
if thread.ratingValue > 0 {
HStack(spacing: 2) {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text("\(thread.ratingValue)")
.font(.caption)
}
}
if showFiles && !thread.files.isEmpty {
HStack(spacing: 2) {
Image(systemName: "paperclip")
.foregroundColor(.blue)
Text("\(thread.files.count)")
.font(.caption)
}
}
Spacer()
}
if !thread.text.isEmpty {
Text(thread.text)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(3)
}
}
.padding(.vertical, 4)
}
.padding(.vertical, 4)
}
}

322
README.md
View File

@ -1,145 +1,259 @@
# 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)
## Возможности
## Основные возможности
- Просмотр всех досок Мкача
- Просмотр тредов в каждой доске с пагинацией
- Просмотр деталей треда и комментариев
- Поддержка изображений и видео
- Темная/светлая тема
- Система настроек с сохранением:
- Тема (темная/светлая)
- Последняя посещенная доска
- Автообновление
- Показ файлов
- Компактный режим
- Размер страницы (5-20 тредов)
- **Полная поддержка постинга:**
- Аутентификация по ключу
- Аутентификация по passcode
- Создание тредов
- Добавление комментариев
- Автоматическое обновление после постинга
- **Оптимизации для iOS:**
- Нативная SwiftUI интерфейс
- Кэширование данных
- Оптимизация потребления батареи
- Поддержка iOS 15.0+
- **Push-уведомления о новых тредах:**
- Подписка на доски через тумблеры в настройках
- Настраиваемый интервал проверки (5 мин - 1 час)
- Фоновое обновление
- Задержка уведомлений 10 секунд
- Формат: "Новый тред: [название] в /boardname/"
- Тестовые уведомления в debug меню
### Нативный iOS интерфейс
- **Tab Bar навигация** с тремя вкладками: Доски, Избранное, Настройки
- **Адаптивный дизайн** для iPhone и iPad
- **Темная/светлая тема** с автопереключением
- **Нативные анимации** и жесты iOS
- **SwiftUI интерфейс** для современного внешнего вида
## Аутентификация и постинг
### росмотр контента
- **Просмотр всех досок** mkch с описаниями
- **Список тредов** с поддержкой сортировки по рейтингу и закрепленным
- **Детальный просмотр тредов** с комментариями
- **Система файлов** с поддержкой изображений и видео
- **Полноэкранный просмотр изображений** с зумом и жестами
- **Компактный/обычный режим** отображения для экономии места
- **Пагинация** с настраиваемым размером страницы (5-20 элементов)
### Настройка аутентификации
### Система избранного
- **Добавление тредов в избранное** одним тапом
- **Отдельная вкладка избранного** с быстрым доступом
- **Локальное сохранение** избранного между запусками
- **Управление избранным** с возможностью удаления
1. Откройте настройки в приложении
2. Введите ключ аутентификации
3. Введите passcode для постинга (По наличию) (P.S. Увы, но пока-что не доработал пасскод, уважте, первая версия.)
4. Используйте кнопки "Тест ключа" и "Тест passcode" для проверки
### Гибкие настройки
- **Темы**: темная/светлая
- **Автообновление** контента
- **Показ файлов** (включение/отключение)
- **Компактный режим** для экономии пространства
- **Размер страницы**: от 5 до 20 элементов
- **Пагинация**: включение/отключение
- **Нестабильные функции** с предупреждением
### Создание тредов
### Полная поддержка постинга
- **Аутентификация по ключу** с тестированием подключения
- **Аутентификация по passcode** для обхода капчи
- **Создание новых тредов** с заголовком и текстом
- **Добавление комментариев** в существующие треды
- **CSRF защита** и автоматическая обработка форм
- **Автоочистка кэша** после постинга
1. Перейдите в нужную доску
2. Нажмите "Создать"
3. Заполните заголовок и текст
4. Нажмите "Создать"
### Push-уведомления (BETA)
- **Подписка на доски** через интуитивные переключатели
- **Настраиваемый интервал проверки**: 5 мин - 1 час
- **Фоновое обновление** даже при закрытом приложении
- **Принудительная проверка** новых тредов
- **Умные уведомления**: "Новый тред: [название] в /доска/"
- **Тестовые уведомления** в debug режиме
### Добавление комментариев
### Оптимизации и производительность
- **Многоуровневое кэширование**:
- Доски (TTL: 10 мин)
- Треды (TTL: 5 мин)
- Детали тредов и комментарии (TTL: 3 мин)
- Изображения с NSCache
- **Автоочистка кэша** по таймеру
- **Оптимизация батареи** для фоновых задач
- **Ленивая загрузка** контента
- **Graceful error handling** с retry логикой
1. Откройте тред
2. Нажмите "Добавить"
3. Введите текст комментария
4. Нажмите "Добавить"
### Дополнительные функции
- **Crash Handler** с детальной диагностикой
- **Debug меню** (5 тапов по информации об устройстве):
- Тест краша приложения
- Тестовые уведомления
- **Управление кэшем**:
- Очистка кэша досок
- Очистка кэша тредов
- Очистка кэша изображений
- Полная очистка
- **Информация об устройстве** с детальной диагностикой
- **Сброс настроек** до заводских
## Уведомления о новых тредах
## Установка и настройка
### Системные требования
- iOS 15.0 или новее
- iPhone/iPad с поддержкой SwiftUI
- Разрешение на уведомления (для push-уведомлений)
### Первоначальная настройка
1. **Скачайте и установите** приложение
2. **Откройте вкладку "Настройки"**
3. **Настройте аутентификацию** (при необходимости):
- Введите ключ аутентификации
- Введите passcode для постинга
- Протестируйте подключение кнопками "Тест ключа" и "Тест passcode"
### Настройка уведомлений
1. Откройте настройки приложения
2. Перейдите в "Настройки уведомлений"
3. Включите уведомления
4. Разрешите уведомления в системных настройках iOS
5. Настройте интервал проверки (5 мин - 1 час)
1. **Включите нестабильные функции** в настройках
2. **Перейдите в "Настройки уведомлений"**
3. **Включите уведомления** и разрешите их в системных настройках
4. **Выберите интервал проверки** (рекомендуется 15-30 мин)
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 |
- Приложение периодически проверяет новые треды в фоне
- При обнаружении нового треда отправляется push-уведомление через 10 секунд
- Формат уведомления: "Новый тред: [название] в /boardname/"
- Подписки сохраняются между запусками приложения
- Управление подписками через тумблеры в настройках уведомлений
- Тестовые уведомления доступны в debug меню (5 нажатий на информацию об устройстве)
### UI компоненты
## Сборка
| Файл | Описание |
|------|----------|
| `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+
- iOS 15.0+
- macOS 13.0+
- Apple Developer Account (для распространения) (я все равно не использую)
### Сборка
1. Откройте проект в Xcode:
### Локальная сборка
```bash
# Клонируйте репозиторий
git clone <repository-url>
cd MobileMkch-iOS
# Откройте в Xcode
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" в схеме сборки
2. Product и Archive
3. Distribute App через App Store Connect или Ad Hoc (Еще можно открыть архиве в файндер и там найти .app и закинув в Payload сжать папку в .ipa, но это слегка попердолинг увы)
P.S. Костыль через Payload/MobileMkch.app в зипе и переименовании ее в .ipa будет работать почти всегда
## Структура проекта
## Версии и обновления
- `MobileMkchApp.swift` - точка входа приложения
- `Models.swift` - структуры данных
- `APIClient.swift` - HTTP клиент для mkch API
- `Settings.swift` - система настроек
- `Cache.swift` - система кэширования
- `BoardsView.swift` - список досок
- `ThreadsView.swift` - треды доски с пагинацией
- `ThreadDetailView.swift` - детали треда
- `CreateThreadView.swift` - создание тредов
- `AddCommentView.swift` - добавление комментариев
- `SettingsView.swift` - экран настроек
- `NotificationManager.swift` - управление уведомлениями и тестовые уведомления
- `BackgroundTaskManager.swift` - фоновые задачи
- `NotificationSettingsView.swift` - настройки уведомлений с тумблерами подписок
### Версия 2.0.0-ios-alpha (Текущая)
- Полная переработка UI на SwiftUI
- Система избранного с локальным сохранением
- Push-уведомления с фоновым обновлением
- Полноэкранный просмотр изображений с жестами
- Crash handler с детальной диагностикой
- Многоуровневое кэширование с TTL
- Debug меню для разработчиков
- Компактный режим интерфейса
- Нестабильные функции с предупреждениями
### Планы развития
- Поддержка загрузки файлов при постинге
- Офлайн режим чтения
- Поиск по тредам и комментариям
- Темы оформления (кастомные цвета)
- Статистика использования (мб и не будет, я не знаю мне лень)
- Экспорт/импорт настроек
## Технологии
- SwiftUI
- Combine
- Foundation
- UIKit (для совместимости)
### Основной стек
- **SwiftUI** - современный UI фреймворк
- **Combine** - реактивное программирование
- **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*