From 7d2eb1fd1934ea42114b93c1f32d79f6687266a8 Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Thu, 7 Aug 2025 13:27:12 +0300 Subject: [PATCH] =?UTF-8?q?global=20update=20v2=20=D0=BF=D0=BE=D0=B1=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=20z?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 14 + MobileMkch.xcodeproj/project.pbxproj | 4 +- MobileMkch/APIClient.swift | 28 +- MobileMkch/BoardsView.swift | 26 +- MobileMkch/Cache.swift | 2 +- MobileMkch/FileView.swift | 198 +++++++++++++ MobileMkch/ImageLoader.swift | 134 +++++++++ MobileMkch/MainTabView.swift | 137 +++++++++ MobileMkch/MobileMkchApp.swift | 12 +- MobileMkch/Models.swift | 16 ++ MobileMkch/NotificationSettingsView.swift | 219 +++++++++----- MobileMkch/Settings.swift | 38 ++- MobileMkch/SettingsView.swift | 331 ++++++++++++++++++---- MobileMkch/ThreadDetailView.swift | 142 ++++------ MobileMkch/ThreadsView.swift | 226 ++++++++++----- README.md | 322 ++++++++++++++------- 16 files changed, 1421 insertions(+), 428 deletions(-) create mode 100644 LICENSE create mode 100644 MobileMkch/FileView.swift create mode 100644 MobileMkch/ImageLoader.swift create mode 100644 MobileMkch/MainTabView.swift diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..db7be7b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MobileMkch.xcodeproj/project.pbxproj b/MobileMkch.xcodeproj/project.pbxproj index e6c6f6e..518ab04 100644 --- a/MobileMkch.xcodeproj/project.pbxproj +++ b/MobileMkch.xcodeproj/project.pbxproj @@ -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"; diff --git a/MobileMkch/APIClient.swift b/MobileMkch/APIClient.swift index cfc0788..229a980 100644 --- a/MobileMkch/APIClient.swift +++ b/MobileMkch/APIClient.swift @@ -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)) diff --git a/MobileMkch/BoardsView.swift b/MobileMkch/BoardsView.swift index 4d5694c..156b8b5 100644 --- a/MobileMkch/BoardsView.swift +++ b/MobileMkch/BoardsView.swift @@ -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() } } } diff --git a/MobileMkch/Cache.swift b/MobileMkch/Cache.swift index 629cc56..6a9aa68 100644 --- a/MobileMkch/Cache.swift +++ b/MobileMkch/Cache.swift @@ -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) diff --git a/MobileMkch/FileView.swift b/MobileMkch/FileView.swift new file mode 100644 index 0000000..9890109 --- /dev/null +++ b/MobileMkch/FileView.swift @@ -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)) + } + } + } + } + } +} diff --git a/MobileMkch/ImageLoader.swift b/MobileMkch/ImageLoader.swift new file mode 100644 index 0000000..1afdaad --- /dev/null +++ b/MobileMkch/ImageLoader.swift @@ -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() + 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() + } + } + } +} diff --git a/MobileMkch/MainTabView.swift b/MobileMkch/MainTabView.swift new file mode 100644 index 0000000..60326f7 --- /dev/null +++ b/MobileMkch/MainTabView.swift @@ -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) + } +} diff --git a/MobileMkch/MobileMkchApp.swift b/MobileMkch/MobileMkchApp.swift index 81e479a..d949be4 100644 --- a/MobileMkch/MobileMkchApp.swift +++ b/MobileMkch/MobileMkchApp.swift @@ -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 { diff --git a/MobileMkch/Models.swift b/MobileMkch/Models.swift index 181f48d..00f749c 100644 --- a/MobileMkch/Models.swift +++ b/MobileMkch/Models.swift @@ -86,4 +86,20 @@ struct APIError: Error { var localizedDescription: String { 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() + } } \ No newline at end of file diff --git a/MobileMkch/NotificationSettingsView.swift b/MobileMkch/NotificationSettingsView.swift index 363b400..e0d29aa 100644 --- a/MobileMkch/NotificationSettingsView.swift +++ b/MobileMkch/NotificationSettingsView.swift @@ -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() - } - - 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() - } + VStack { + if !settings.enableUnstableFeatures { + VStack(spacing: 16) { + Image(systemName: "lock.fill") + .font(.system(size: 50)) + .foregroundColor(.gray) + + Text("Функция заблокирована") + .font(.title2) + .fontWeight(.bold) + + Text("Для использования уведомлений необходимо включить нестабильные функции в настройках.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } - } - - 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) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("BETA Функция") + .font(.headline) + .foregroundColor(.orange) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(8) + .padding(.horizontal) + + Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.bottom) + + Form { + Section(header: Text("Уведомления")) { + Toggle("Включить уведомления", isOn: $settings.notificationsEnabled) + .onChange(of: settings.notificationsEnabled) { newValue in + if newValue { + requestNotificationPermission() } - + settings.saveSettings() + } + + if settings.notificationsEnabled { + HStack { + Text("Интервал проверки") Spacer() - - Toggle("", isOn: Binding( - get: { notificationManager.subscribedBoards.contains(board.code) }, - set: { isSubscribed in - if isSubscribed { - notificationManager.subscribeToBoard(board.code) - } else { - notificationManager.unsubscribeFromBoard(board.code) + 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() + } + + 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 = [] + } + } + } + } } \ No newline at end of file diff --git a/MobileMkch/Settings.swift b/MobileMkch/Settings.swift index 61204a4..fff30f4 100644 --- a/MobileMkch/Settings.swift +++ b/MobileMkch/Settings.swift @@ -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] } \ No newline at end of file diff --git a/MobileMkch/SettingsView.swift b/MobileMkch/SettingsView.swift index 71339e1..3ba3d43 100644 --- a/MobileMkch/SettingsView.swift +++ b/MobileMkch/SettingsView.swift @@ -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()) diff --git a/MobileMkch/ThreadDetailView.swift b/MobileMkch/ThreadDetailView.swift index c7c800e..156bad0 100644 --- a/MobileMkch/ThreadDetailView.swift +++ b/MobileMkch/ThreadDetailView.swift @@ -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() + + Text(comment.creationDate, style: .date) + .font(.caption2) + .foregroundColor(.secondary) + } - Spacer() + if !comment.text.isEmpty { + Text(comment.formattedText) + .font(.caption) + .lineLimit(2) + } - Text(comment.creationDate, style: .date) - .font(.caption) - .foregroundColor(.secondary) - } - - if showFiles && !comment.files.isEmpty { - FilesView(files: comment.files) - } - - if !comment.text.isEmpty { - Text(comment.formattedText) - .font(.body) - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } -} - -struct FilesView: View { - let files: [String] - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: "paperclip") - .foregroundColor(.blue) - Text("Файлы (\(files.count))") - .font(.caption) - .foregroundColor(.secondary) - } - - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 8) { - ForEach(files, id: \.self) { filePath in - FileButton(fileInfo: FileInfo(filePath: filePath)) + 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" - } - } - - private var fileColor: Color { - if fileInfo.isImage { - return .green - } else if fileInfo.isVideo { - return .red - } else { - return .blue + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("ID: \(comment.id)") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Text(comment.creationDate, style: .date) + .font(.caption) + .foregroundColor(.secondary) + } + + if showFiles && !comment.files.isEmpty { + FilesView(files: comment.files) + } + + if !comment.text.isEmpty { + Text(comment.formattedText) + .font(.body) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) } } } diff --git a/MobileMkch/ThreadsView.swift b/MobileMkch/ThreadsView.swift index 3f1e402..096de8c 100644 --- a/MobileMkch/ThreadsView.swift +++ b/MobileMkch/ThreadsView.swift @@ -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() - } - - if !thread.text.isEmpty { - Text(thread.text) - .font(.body) - .foregroundColor(.secondary) - .lineLimit(3) + HStack { + Text(thread.creationDate, style: .date) + .font(.caption) + .foregroundColor(.secondary) + + if thread.ratingValue > 0 { + HStack(spacing: 2) { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + Text("\(thread.ratingValue)") + .font(.caption) + } + } + + if showFiles && !thread.files.isEmpty { + HStack(spacing: 2) { + Image(systemName: "paperclip") + .foregroundColor(.blue) + Text("\(thread.files.count)") + .font(.caption) + } + } + + Spacer() + } + + if !thread.text.isEmpty { + Text(thread.text) + .font(.body) + .foregroundColor(.secondary) + .lineLimit(3) + } } + .padding(.vertical, 4) } - .padding(.vertical, 4) } } diff --git a/README.md b/README.md index 26388eb..f6ef0ca 100644 --- a/README.md +++ b/README.md @@ -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 +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 (айпад СЛЕГКА поломан, проверил, мб чет с этим сделаю, лень) -- Поддержка темной/светлой темы -- Адаптивный интерфейс \ No newline at end of file +## Поддержка и вклад + +### Сообщение об ошибках +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* \ No newline at end of file