From e27e343e537eb6622a75b92d01987ae310cf1e31 Mon Sep 17 00:00:00 2001 From: Lain Iwakura Date: Thu, 7 Aug 2025 02:10:59 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=83=D0=B2=D0=B5=D0=B4=20=D1=83=D1=80=D0=B0?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MobileMkch.xcodeproj/project.pbxproj | 15 +++ MobileMkch/APIClient.swift | 32 ++++++ MobileMkch/BackgroundTaskManager.swift | 80 ++++++++++++++ MobileMkch/BoardsView.swift | 3 +- MobileMkch/Info.plist | 15 +++ MobileMkch/MobileMkchApp.swift | 14 +++ MobileMkch/NotificationManager.swift | 80 ++++++++++++++ MobileMkch/NotificationSettingsView.swift | 123 ++++++++++++++++++++++ MobileMkch/Settings.swift | 12 ++- MobileMkch/SettingsView.swift | 15 +++ MobileMkch/ThreadsView.swift | 1 + README.md | 39 ++++++- 12 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 MobileMkch/BackgroundTaskManager.swift create mode 100644 MobileMkch/Info.plist create mode 100644 MobileMkch/NotificationManager.swift create mode 100644 MobileMkch/NotificationSettingsView.swift diff --git a/MobileMkch.xcodeproj/project.pbxproj b/MobileMkch.xcodeproj/project.pbxproj index 855ea76..e6c6f6e 100644 --- a/MobileMkch.xcodeproj/project.pbxproj +++ b/MobileMkch.xcodeproj/project.pbxproj @@ -10,9 +10,22 @@ 1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 1D8926E82E43CE4C00C5590A /* MobileMkch */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 1D8926EB2E43CE4C00C5590A /* MobileMkch */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */, + ); path = MobileMkch; sourceTree = ""; }; @@ -255,6 +268,7 @@ DEVELOPMENT_TEAM = 9U88M9D595; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MobileMkch/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MobileMkch; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -290,6 +304,7 @@ DEVELOPMENT_TEAM = 9U88M9D595; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MobileMkch/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MobileMkch; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/MobileMkch/APIClient.swift b/MobileMkch/APIClient.swift index 4862aa7..cfc0788 100644 --- a/MobileMkch/APIClient.swift +++ b/MobileMkch/APIClient.swift @@ -509,4 +509,36 @@ class APIClient: ObservableObject { return String(html[range]) } + + func checkNewThreads(forBoard boardCode: String, lastKnownThreadId: Int, completion: @escaping (Result<[Thread], Error>) -> Void) { + let url = URL(string: "\(apiURL)/board/\(boardCode)")! + + session.dataTask(with: url) { data, response, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + completion(.failure(APIError(message: "Ошибка получения тредов", code: 0))) + return + } + + guard let data = data else { + completion(.failure(APIError(message: "Нет данных", code: 0))) + return + } + + do { + let threads = try JSONDecoder().decode([Thread].self, from: data) + let newThreads = threads.filter { $0.id > lastKnownThreadId } + completion(.success(newThreads)) + } catch { + completion(.failure(error)) + } + } + }.resume() + } } \ No newline at end of file diff --git a/MobileMkch/BackgroundTaskManager.swift b/MobileMkch/BackgroundTaskManager.swift new file mode 100644 index 0000000..9f70c19 --- /dev/null +++ b/MobileMkch/BackgroundTaskManager.swift @@ -0,0 +1,80 @@ +import Foundation +import BackgroundTasks +import UIKit + +class BackgroundTaskManager { + static let shared = BackgroundTaskManager() + + private var backgroundTaskIdentifier: String { + if let savedIdentifier = UserDefaults.standard.string(forKey: "BackgroundTaskIdentifier") { + return savedIdentifier + } + return "com.mkch.MobileMkch.backgroundrefresh" + } + private let notificationManager = NotificationManager.shared + + private init() {} + + func registerBackgroundTasks() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: backgroundTaskIdentifier, using: nil) { task in + self.handleBackgroundTask(task as! BGAppRefreshTask) + } + } + + func scheduleBackgroundTask() { + let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier) + let settings = Settings() + let interval = TimeInterval(settings.notificationInterval) + request.earliestBeginDate = Date(timeIntervalSinceNow: interval) + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + print("Не удалось запланировать фоновую задачу: \(error)") + } + } + + private func handleBackgroundTask(_ task: BGAppRefreshTask) { + task.expirationHandler = { + task.setTaskCompleted(success: false) + } + + let settings = Settings() + guard settings.notificationsEnabled else { + task.setTaskCompleted(success: true) + return + } + + let group = DispatchGroup() + var hasNewThreads = false + + for boardCode in notificationManager.subscribedBoards { + group.enter() + + let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)") + + APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in + switch result { + case .success(let newThreads): + if !newThreads.isEmpty { + hasNewThreads = true + for thread in newThreads { + self.notificationManager.scheduleNotification(for: thread, boardCode: boardCode) + } + UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)") + } + case .failure: + break + } + group.leave() + } + } + + group.notify(queue: .main) { + task.setTaskCompleted(success: true) + if hasNewThreads { + self.scheduleBackgroundTask() + } + } + } +} \ No newline at end of file diff --git a/MobileMkch/BoardsView.swift b/MobileMkch/BoardsView.swift index fbaec6e..4d5694c 100644 --- a/MobileMkch/BoardsView.swift +++ b/MobileMkch/BoardsView.swift @@ -35,7 +35,8 @@ struct BoardsView: View { ForEach(boards) { board in NavigationLink(destination: ThreadsView(board: board) .environmentObject(settings) - .environmentObject(apiClient)) { + .environmentObject(apiClient) + .environmentObject(NotificationManager.shared)) { BoardRow(board: board) } } diff --git a/MobileMkch/Info.plist b/MobileMkch/Info.plist new file mode 100644 index 0000000..2016c55 --- /dev/null +++ b/MobileMkch/Info.plist @@ -0,0 +1,15 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + com.mkch.MobileMkch.backgroundrefresh + + UIBackgroundModes + + background-processing + background-fetch + + + diff --git a/MobileMkch/MobileMkchApp.swift b/MobileMkch/MobileMkchApp.swift index 9fc20ea..81e479a 100644 --- a/MobileMkch/MobileMkchApp.swift +++ b/MobileMkch/MobileMkchApp.swift @@ -12,6 +12,15 @@ struct MobileMkchApp: App { @StateObject private var settings = Settings() @StateObject private var apiClient = APIClient() @StateObject private var crashHandler = CrashHandler.shared + @StateObject private var notificationManager = NotificationManager.shared + + private func setupBackgroundTasks() { + if let bundleIdentifier = Bundle.main.bundleIdentifier { + let backgroundTaskIdentifier = "\(bundleIdentifier).backgroundrefresh" + UserDefaults.standard.set(backgroundTaskIdentifier, forKey: "BackgroundTaskIdentifier") + print("Background task identifier: \(backgroundTaskIdentifier)") + } + } var body: some Scene { WindowGroup { @@ -23,10 +32,15 @@ struct MobileMkchApp: App { BoardsView() .environmentObject(settings) .environmentObject(apiClient) + .environmentObject(notificationManager) } .preferredColorScheme(settings.theme == "dark" ? .dark : .light) } } + .onAppear { + BackgroundTaskManager.shared.registerBackgroundTasks() + setupBackgroundTasks() + } } } } diff --git a/MobileMkch/NotificationManager.swift b/MobileMkch/NotificationManager.swift new file mode 100644 index 0000000..5e46b4d --- /dev/null +++ b/MobileMkch/NotificationManager.swift @@ -0,0 +1,80 @@ +import Foundation +import UserNotifications +import UIKit + +class NotificationManager: ObservableObject { + static let shared = NotificationManager() + + @Published var isNotificationsEnabled = false + @Published var subscribedBoards: Set = [] + + private init() { + checkNotificationStatus() + loadSubscribedBoards() + } + + func requestPermission(completion: @escaping (Bool) -> Void) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + DispatchQueue.main.async { + self.isNotificationsEnabled = granted + completion(granted) + } + } + } + + func checkNotificationStatus() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + self.isNotificationsEnabled = settings.authorizationStatus == .authorized + } + } + } + + func subscribeToBoard(_ boardCode: String) { + subscribedBoards.insert(boardCode) + saveSubscribedBoards() + BackgroundTaskManager.shared.scheduleBackgroundTask() + } + + func unsubscribeFromBoard(_ boardCode: String) { + subscribedBoards.remove(boardCode) + saveSubscribedBoards() + } + + func scheduleNotification(for thread: Thread, boardCode: String) { + let content = UNMutableNotificationContent() + content.title = "Новый тред" + content.body = "\(thread.title) в /\(boardCode)/" + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false) + let request = UNNotificationRequest(identifier: "thread_\(thread.id)", content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) + } + + func scheduleTestNotification() { + let content = UNMutableNotificationContent() + content.title = "Тестовое уведомление" + content.body = "Новый тред: Тестовый тред в /test/" + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false) + let request = UNNotificationRequest(identifier: "test_notification", content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) + } + + private func loadSubscribedBoards() { + if let data = UserDefaults.standard.data(forKey: "subscribedBoards"), + let boards = try? JSONDecoder().decode(Set.self, from: data) { + subscribedBoards = boards + } + } + + private func saveSubscribedBoards() { + if let data = try? JSONEncoder().encode(subscribedBoards) { + UserDefaults.standard.set(data, forKey: "subscribedBoards") + } + } +} \ No newline at end of file diff --git a/MobileMkch/NotificationSettingsView.swift b/MobileMkch/NotificationSettingsView.swift new file mode 100644 index 0000000..363b400 --- /dev/null +++ b/MobileMkch/NotificationSettingsView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct NotificationSettingsView: View { + @EnvironmentObject var settings: Settings + @EnvironmentObject var notificationManager: NotificationManager + @EnvironmentObject var apiClient: APIClient + @State private var showingPermissionAlert = false + @State private var boards: [Board] = [] + @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() + } + } + } + + 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) + } + } + )) + } + } + } + } + } + } + .navigationTitle("Уведомления") + .onAppear { + if boards.isEmpty { + loadBoards() + } + } + .alert("Разрешить уведомления", isPresented: $showingPermissionAlert) { + Button("Настройки") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Отмена", role: .cancel) { + settings.notificationsEnabled = false + } + } message: { + 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 = [] + } + } + } + } + + private func requestNotificationPermission() { + notificationManager.requestPermission { granted in + if !granted { + showingPermissionAlert = true + } + } + } +} \ No newline at end of file diff --git a/MobileMkch/Settings.swift b/MobileMkch/Settings.swift index f1cf70f..61204a4 100644 --- a/MobileMkch/Settings.swift +++ b/MobileMkch/Settings.swift @@ -9,6 +9,8 @@ class Settings: ObservableObject { @Published var pageSize: Int = 10 @Published var passcode: String = "" @Published var key: String = "" + @Published var notificationsEnabled: Bool = false + @Published var notificationInterval: Int = 300 private let userDefaults = UserDefaults.standard private let settingsKey = "MobileMkchSettings" @@ -28,6 +30,8 @@ class Settings: ObservableObject { self.pageSize = settings.pageSize self.passcode = settings.passcode self.key = settings.key + self.notificationsEnabled = settings.notificationsEnabled + self.notificationInterval = settings.notificationInterval } } @@ -40,7 +44,9 @@ class Settings: ObservableObject { compactMode: compactMode, pageSize: pageSize, passcode: passcode, - key: key + key: key, + notificationsEnabled: notificationsEnabled, + notificationInterval: notificationInterval ) if let data = try? JSONEncoder().encode(settingsData) { @@ -57,6 +63,8 @@ class Settings: ObservableObject { pageSize = 10 passcode = "" key = "" + notificationsEnabled = false + notificationInterval = 300 saveSettings() } } @@ -70,4 +78,6 @@ struct SettingsData: Codable { let pageSize: Int let passcode: String let key: String + let notificationsEnabled: Bool + let notificationInterval: Int } \ No newline at end of file diff --git a/MobileMkch/SettingsView.swift b/MobileMkch/SettingsView.swift index 33a41ae..71339e1 100644 --- a/MobileMkch/SettingsView.swift +++ b/MobileMkch/SettingsView.swift @@ -107,6 +107,13 @@ struct SettingsView: View { } } + Section("Уведомления") { + NavigationLink("Настройки уведомлений") { + NotificationSettingsView() + .environmentObject(apiClient) + } + } + Section("Управление кэшем") { Button("Очистить кэш досок") { Cache.shared.delete("boards") @@ -176,6 +183,7 @@ struct SettingsView: View { } .sheet(isPresented: $showingDebugMenu) { DebugMenuView() + .environmentObject(NotificationManager.shared) } .alert("Информация о НЕОЖИДАНЫХ проблемах", isPresented: $showingInfo) { Button("Закрыть") { } @@ -275,6 +283,7 @@ struct AboutView: View { struct DebugMenuView: View { @Environment(\.dismiss) private var dismiss + @EnvironmentObject var notificationManager: NotificationManager var body: some View { NavigationView { @@ -290,6 +299,12 @@ struct DebugMenuView: View { } .buttonStyle(.borderedProminent) .foregroundColor(.red) + + Button("Тест уведомления") { + notificationManager.scheduleTestNotification() + } + .buttonStyle(.borderedProminent) + .foregroundColor(.blue) } Spacer() diff --git a/MobileMkch/ThreadsView.swift b/MobileMkch/ThreadsView.swift index 8adb232..3f1e402 100644 --- a/MobileMkch/ThreadsView.swift +++ b/MobileMkch/ThreadsView.swift @@ -4,6 +4,7 @@ struct ThreadsView: View { let board: Board @EnvironmentObject var settings: Settings @EnvironmentObject var apiClient: APIClient + @EnvironmentObject var notificationManager: NotificationManager @State private var threads: [Thread] = [] @State private var isLoading = false @State private var errorMessage: String? diff --git a/README.md b/README.md index b433949..26388eb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,13 @@ - Кэширование данных - Оптимизация потребления батареи - Поддержка iOS 15.0+ +- **Push-уведомления о новых тредах:** + - Подписка на доски через тумблеры в настройках + - Настраиваемый интервал проверки (5 мин - 1 час) + - Фоновое обновление + - Задержка уведомлений 10 секунд + - Формат: "Новый тред: [название] в /boardname/" + - Тестовые уведомления в debug меню ## Аутентификация и постинг @@ -54,6 +61,33 @@ 3. Введите текст комментария 4. Нажмите "Добавить" +## Уведомления о новых тредах + +### Настройка уведомлений + +1. Откройте настройки приложения +2. Перейдите в "Настройки уведомлений" +3. Включите уведомления +4. Разрешите уведомления в системных настройках iOS +5. Настройте интервал проверки (5 мин - 1 час) + +### Подписка на доски + +1. Откройте настройки приложения +2. Перейдите в "Настройки уведомлений" +3. Включите уведомления +4. В разделе "Подписки на доски" включите тумблеры для нужных досок +5. Для отписки отключите соответствующий тумблер + +### Как это работает + +- Приложение периодически проверяет новые треды в фоне +- При обнаружении нового треда отправляется push-уведомление через 10 секунд +- Формат уведомления: "Новый тред: [название] в /boardname/" +- Подписки сохраняются между запусками приложения +- Управление подписками через тумблеры в настройках уведомлений +- Тестовые уведомления доступны в debug меню (5 нажатий на информацию об устройстве) + ## Сборка ### Требования @@ -92,6 +126,9 @@ open MobileMkch.xcodeproj - `CreateThreadView.swift` - создание тредов - `AddCommentView.swift` - добавление комментариев - `SettingsView.swift` - экран настроек +- `NotificationManager.swift` - управление уведомлениями и тестовые уведомления +- `BackgroundTaskManager.swift` - фоновые задачи +- `NotificationSettingsView.swift` - настройки уведомлений с тумблерами подписок ## Технологии @@ -105,4 +142,4 @@ open MobileMkch.xcodeproj - iOS 15.0+ - iPhone и iPad (айпад СЛЕГКА поломан, проверил, мб чет с этим сделаю, лень) - Поддержка темной/светлой темы -- Адаптивный интерфейс \ No newline at end of file +- Адаптивный интерфейс \ No newline at end of file