global update v2 победа z
This commit is contained in:
parent
e27e343e53
commit
7d2eb1fd19
14
LICENSE
Normal file
14
LICENSE
Normal file
@ -0,0 +1,14 @@
|
||||
BSD Zero Clause License
|
||||
|
||||
Copyright (c) 2025 w^x (лейн, платон)
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
@ -281,7 +281,7 @@
|
||||
"$(inherited)",
|
||||
"@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";
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
198
MobileMkch/FileView.swift
Normal file
@ -0,0 +1,198 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FileView: View {
|
||||
let fileInfo: FileInfo
|
||||
@State private var showingFullScreen = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if fileInfo.isImage {
|
||||
AsyncImageView(url: fileInfo.url, contentMode: .fit)
|
||||
.frame(maxHeight: 200)
|
||||
.clipped()
|
||||
.onTapGesture {
|
||||
showingFullScreen = true
|
||||
}
|
||||
} else if fileInfo.isVideo {
|
||||
VStack {
|
||||
Image(systemName: "play.rectangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.gray)
|
||||
Text(fileInfo.filename)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(height: 100)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "doc")
|
||||
.foregroundColor(.blue)
|
||||
Text(fileInfo.filename)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showingFullScreen) {
|
||||
if fileInfo.isImage {
|
||||
NativeFullScreenImageView(url: fileInfo.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeFullScreenImageView: View {
|
||||
let url: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var lastScale: CGFloat = 1.0
|
||||
@State private var offset = CGSize.zero
|
||||
@State private var lastOffset = CGSize.zero
|
||||
@State private var dragOffset = CGSize.zero
|
||||
@State private var isDragging = false
|
||||
@State private var showUI = true
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
GeometryReader { geometry in
|
||||
AsyncImageView(url: url, contentMode: .fit)
|
||||
.scaleEffect(scale)
|
||||
.offset(offset)
|
||||
.opacity(isDragging ? 0.8 : 1.0)
|
||||
.gesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let delta = value / lastScale
|
||||
lastScale = value
|
||||
scale = min(max(scale * delta, 0.5), 5)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = 1.0
|
||||
if scale < 1 {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
if scale > 1 {
|
||||
let delta = CGSize(
|
||||
width: value.translation.width - lastOffset.width,
|
||||
height: value.translation.height - lastOffset.height
|
||||
)
|
||||
lastOffset = value.translation
|
||||
offset = CGSize(
|
||||
width: offset.width + delta.width,
|
||||
height: offset.height + delta.height
|
||||
)
|
||||
} else {
|
||||
dragOffset = value.translation
|
||||
isDragging = true
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
if scale <= 1 {
|
||||
if abs(dragOffset.height) > 100 || abs(dragOffset.width) > 100 {
|
||||
dismiss()
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
dragOffset = .zero
|
||||
}
|
||||
}
|
||||
isDragging = false
|
||||
}
|
||||
lastOffset = CGSize.zero
|
||||
}
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
if scale > 1 {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
} else {
|
||||
scale = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showUI.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
VStack {
|
||||
if showUI {
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.padding(12)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.padding(12)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.2), value: showUI)
|
||||
}
|
||||
}
|
||||
|
||||
struct FilesView: View {
|
||||
let files: [String]
|
||||
|
||||
var body: some View {
|
||||
if !files.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Файлы (\(files.count))")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: 12) {
|
||||
ForEach(files, id: \.self) { file in
|
||||
FileView(fileInfo: FileInfo(filePath: file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
MobileMkch/ImageLoader.swift
Normal file
134
MobileMkch/ImageLoader.swift
Normal file
@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
class ImageLoader: ObservableObject {
|
||||
@Published var image: UIImage?
|
||||
@Published var isLoading = false
|
||||
@Published var error: Error?
|
||||
|
||||
private let url: String
|
||||
private let cache = ImageCache.shared
|
||||
private var cancellable: URLSessionDataTask?
|
||||
|
||||
init(url: String) {
|
||||
self.url = url
|
||||
loadImage()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellable?.cancel()
|
||||
}
|
||||
|
||||
func loadImage() {
|
||||
guard let imageURL = URL(string: url) else {
|
||||
error = NSError(domain: "Invalid URL", code: 0, userInfo: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let cachedImage = cache.getImage(for: url) {
|
||||
self.image = cachedImage
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
cancellable = URLSession.shared.dataTask(with: imageURL) { [weak self] data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
self?.isLoading = false
|
||||
|
||||
if let error = error {
|
||||
self?.error = error
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let image = UIImage(data: data) else {
|
||||
self?.error = NSError(domain: "Invalid image data", code: 0, userInfo: nil)
|
||||
return
|
||||
}
|
||||
|
||||
self?.image = image
|
||||
self?.cache.setImage(image, for: self?.url ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
cancellable?.resume()
|
||||
}
|
||||
|
||||
func reload() {
|
||||
cancellable?.cancel()
|
||||
image = nil
|
||||
error = nil
|
||||
loadImage()
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCache {
|
||||
static let shared = ImageCache()
|
||||
|
||||
private var cache = NSCache<NSString, UIImage>()
|
||||
private let queue = DispatchQueue(label: "image.cache.queue", attributes: .concurrent)
|
||||
|
||||
private init() {
|
||||
cache.countLimit = 100
|
||||
cache.totalCostLimit = 50 * 1024 * 1024
|
||||
}
|
||||
|
||||
func setImage(_ image: UIImage, for key: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.cache.setObject(image, forKey: key as NSString)
|
||||
}
|
||||
}
|
||||
|
||||
func getImage(for key: String) -> UIImage? {
|
||||
return queue.sync {
|
||||
return cache.object(forKey: key as NSString)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCache() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.cache.removeAllObjects()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AsyncImageView: View {
|
||||
let url: String
|
||||
let placeholder: Image
|
||||
let contentMode: ContentMode
|
||||
|
||||
@StateObject private var loader: ImageLoader
|
||||
|
||||
init(url: String, placeholder: Image = Image(systemName: "photo"), contentMode: ContentMode = .fit) {
|
||||
self.url = url
|
||||
self.placeholder = placeholder
|
||||
self.contentMode = contentMode
|
||||
self._loader = StateObject(wrappedValue: ImageLoader(url: url))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = loader.image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: contentMode)
|
||||
} else if loader.isLoading {
|
||||
placeholder
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: contentMode)
|
||||
.foregroundColor(.gray)
|
||||
} else {
|
||||
placeholder
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: contentMode)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
if loader.error != nil {
|
||||
loader.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
MobileMkch/MainTabView.swift
Normal file
137
MobileMkch/MainTabView.swift
Normal file
@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
BoardsView()
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
.tabItem {
|
||||
Image(systemName: "list.bullet")
|
||||
Text("Доски")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
FavoritesView()
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
.environmentObject(NotificationManager.shared)
|
||||
.tabItem {
|
||||
Image(systemName: "heart.fill")
|
||||
Text("Избранное")
|
||||
}
|
||||
|
||||
.tag(1)
|
||||
|
||||
SettingsView()
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
.tabItem {
|
||||
Image(systemName: "gear")
|
||||
Text("Настройки")
|
||||
}
|
||||
.tag(2)
|
||||
}
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
struct FavoritesView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var notificationManager: NotificationManager
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
if settings.favoriteThreads.isEmpty {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "heart")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.gray)
|
||||
|
||||
Text("Нет избранных тредов")
|
||||
.font(.title2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Добавляйте треды в избранное, нажав на звездочку в списке тредов")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List {
|
||||
ForEach(settings.favoriteThreads) { favorite in
|
||||
NavigationLink(destination:
|
||||
ThreadDetailView(
|
||||
board: Board(code: favorite.board, description: favorite.boardDescription),
|
||||
thread: Thread(
|
||||
id: favorite.id,
|
||||
title: favorite.title,
|
||||
text: "",
|
||||
creation: "",
|
||||
board: favorite.board,
|
||||
rating: nil,
|
||||
pinned: nil,
|
||||
files: []
|
||||
)
|
||||
)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
) {
|
||||
FavoriteThreadRow(favorite: favorite)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteFavorites)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Избранное")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteFavorites(offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
let favorite = settings.favoriteThreads[index]
|
||||
settings.removeFromFavorites(favorite.id, boardCode: favorite.board)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FavoriteThreadRow: View {
|
||||
let favorite: FavoriteThread
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(favorite.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack {
|
||||
Text("/\(favorite.board)/")
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(favorite.addedDate, style: .date)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@ -28,13 +28,11 @@ struct MobileMkchApp: App {
|
||||
if crashHandler.hasCrashed {
|
||||
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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
322
README.md
@ -1,145 +1,259 @@
|
||||
# MobileMkch iOS
|
||||
|
||||
Мобильный клиент для борды mkch.pooziqo.xyz для iOS
|
||||
Нативный iOS клиент для борды mkch.pooziqo.xyz
|
||||
|
||||

|
||||

|
||||

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

|
||||
|
||||
## Возможности
|
||||
## Основные возможности
|
||||
|
||||
- Просмотр всех досок Мкача
|
||||
- Просмотр тредов в каждой доске с пагинацией
|
||||
- Просмотр деталей треда и комментариев
|
||||
- Поддержка изображений и видео
|
||||
- Темная/светлая тема
|
||||
- Система настроек с сохранением:
|
||||
- Тема (темная/светлая)
|
||||
- Последняя посещенная доска
|
||||
- Автообновление
|
||||
- Показ файлов
|
||||
- Компактный режим
|
||||
- Размер страницы (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*
|
||||
Loading…
x
Reference in New Issue
Block a user