diff --git a/MobileMkch/APIClient.swift b/MobileMkch/APIClient.swift index 229a980..d802082 100644 --- a/MobileMkch/APIClient.swift +++ b/MobileMkch/APIClient.swift @@ -550,9 +550,31 @@ class APIClient: ObservableObject { } do { - let threads = try JSONDecoder().decode([Thread].self, from: data) - let newThreads = threads.filter { $0.id > lastKnownThreadId } - completion(.success(newThreads)) + let currentThreads = try JSONDecoder().decode([Thread].self, from: data) + + let savedThreadsKey = "savedThreads_\(boardCode)" + let savedThreadsData = UserDefaults.standard.data(forKey: savedThreadsKey) + + if let savedThreadsData = savedThreadsData, + let savedThreads = try? JSONDecoder().decode([Thread].self, from: savedThreadsData) { + + let savedThreadIds = Set(savedThreads.map { $0.id }) + let newThreads = currentThreads.filter { !savedThreadIds.contains($0.id) } + + if !newThreads.isEmpty { + print("Найдено \(newThreads.count) новых тредов в /\(boardCode)/") + } + + completion(.success(newThreads)) + } else { + print("Первая синхронизация для /\(boardCode)/ - сохраняем \(currentThreads.count) тредов") + completion(.success([])) + } + + if let encodedData = try? JSONEncoder().encode(currentThreads) { + UserDefaults.standard.set(encodedData, forKey: savedThreadsKey) + } + } catch { completion(.failure(error)) } diff --git a/MobileMkch/BackgroundTaskManager.swift b/MobileMkch/BackgroundTaskManager.swift index 9f70c19..f09835e 100644 --- a/MobileMkch/BackgroundTaskManager.swift +++ b/MobileMkch/BackgroundTaskManager.swift @@ -24,11 +24,12 @@ class BackgroundTaskManager { func scheduleBackgroundTask() { let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier) let settings = Settings() - let interval = TimeInterval(settings.notificationInterval) + let interval = TimeInterval(settings.notificationInterval * 60) request.earliestBeginDate = Date(timeIntervalSinceNow: interval) do { try BGTaskScheduler.shared.submit(request) + print("Фоновая задача запланирована на \(interval) секунд") } catch { print("Не удалось запланировать фоновую задачу: \(error)") } @@ -51,20 +52,18 @@ class BackgroundTaskManager { for boardCode in notificationManager.subscribedBoards { group.enter() - let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)") - - APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in + APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: 0) { result in switch result { case .success(let newThreads): if !newThreads.isEmpty { hasNewThreads = true + print("Найдено \(newThreads.count) новых тредов в /\(boardCode)/") for thread in newThreads { self.notificationManager.scheduleNotification(for: thread, boardCode: boardCode) } - UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)") } - case .failure: - break + case .failure(let error): + print("Ошибка проверки новых тредов для /\(boardCode)/: \(error)") } group.leave() } diff --git a/MobileMkch/MobileMkchApp.swift b/MobileMkch/MobileMkchApp.swift index d949be4..1e8fea0 100644 --- a/MobileMkch/MobileMkchApp.swift +++ b/MobileMkch/MobileMkchApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UserNotifications @main struct MobileMkchApp: App { @@ -22,6 +23,25 @@ struct MobileMkchApp: App { } } + private func setupNotifications() { + UNUserNotificationCenter.current().delegate = NotificationDelegate.shared + notificationManager.requestPermission { granted in + if granted { + print("Разрешения на уведомления получены") + } else { + print("Разрешения на уведомления отклонены") + } + } + } + + private func handleNotificationLaunch() { + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let userInfo = scene.session.userInfo { + print("Приложение запущено из уведомления") + } + notificationManager.clearBadge() + } + var body: some Scene { WindowGroup { Group { @@ -38,6 +58,8 @@ struct MobileMkchApp: App { .onAppear { BackgroundTaskManager.shared.registerBackgroundTasks() setupBackgroundTasks() + setupNotifications() + handleNotificationLaunch() } } } diff --git a/MobileMkch/NotificationManager.swift b/MobileMkch/NotificationManager.swift index 5e46b4d..aa495d3 100644 --- a/MobileMkch/NotificationManager.swift +++ b/MobileMkch/NotificationManager.swift @@ -2,6 +2,18 @@ import Foundation import UserNotifications import UIKit +class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { + static let shared = NotificationDelegate() + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .sound, .badge]) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + completionHandler() + } +} + class NotificationManager: ObservableObject { static let shared = NotificationManager() @@ -17,11 +29,20 @@ class NotificationManager: ObservableObject { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in DispatchQueue.main.async { self.isNotificationsEnabled = granted + if granted { + self.registerForRemoteNotifications() + } completion(granted) } } } + private func registerForRemoteNotifications() { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + func checkNotificationStatus() { UNUserNotificationCenter.current().getNotificationSettings { settings in DispatchQueue.main.async { @@ -33,36 +54,90 @@ class NotificationManager: ObservableObject { func subscribeToBoard(_ boardCode: String) { subscribedBoards.insert(boardCode) saveSubscribedBoards() + BackgroundTaskManager.shared.scheduleBackgroundTask() } + private func syncThreadsForBoard(_ boardCode: String) { + let url = URL(string: "https://mkch.pooziqo.xyz/api/board/\(boardCode)")! + var request = URLRequest(url: url) + request.setValue("MobileMkch/2.0.0-ios-alpha", forHTTPHeaderField: "User-Agent") + + URLSession.shared.dataTask(with: request) { data, response, error in + if let data = data, + let threads = try? JSONDecoder().decode([Thread].self, from: data) { + let savedThreadsKey = "savedThreads_\(boardCode)" + if let encodedData = try? JSONEncoder().encode(threads) { + UserDefaults.standard.set(encodedData, forKey: savedThreadsKey) + print("Синхронизировано \(threads.count) тредов для /\(boardCode)/") + } + } + }.resume() + } + func unsubscribeFromBoard(_ boardCode: String) { subscribedBoards.remove(boardCode) saveSubscribedBoards() + + let savedThreadsKey = "savedThreads_\(boardCode)" + UserDefaults.standard.removeObject(forKey: savedThreadsKey) } func scheduleNotification(for thread: Thread, boardCode: String) { + guard isNotificationsEnabled else { return } + let content = UNMutableNotificationContent() content.title = "Новый тред" content.body = "\(thread.title) в /\(boardCode)/" content.sound = .default + content.badge = 1 - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false) - let request = UNNotificationRequest(identifier: "thread_\(thread.id)", content: content, trigger: trigger) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest(identifier: "thread_\(thread.id)_\(boardCode)", content: content, trigger: trigger) - UNUserNotificationCenter.current().add(request) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Ошибка планирования уведомления: \(error)") + } + } } func scheduleTestNotification() { + guard isNotificationsEnabled else { return } + let content = UNMutableNotificationContent() content.title = "Тестовое уведомление" content.body = "Новый тред: Тестовый тред в /test/" content.sound = .default + content.badge = 1 - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) let request = UNNotificationRequest(identifier: "test_notification", content: content, trigger: trigger) - UNUserNotificationCenter.current().add(request) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Ошибка планирования тестового уведомления: \(error)") + } + } + } + + func clearAllNotifications() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + + func clearBadge() { + DispatchQueue.main.async { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + } + + func clearAllSavedThreads() { + for boardCode in subscribedBoards { + let savedThreadsKey = "savedThreads_\(boardCode)" + UserDefaults.standard.removeObject(forKey: savedThreadsKey) + } + print("Очищены все сохраненные треды") } private func loadSubscribedBoards() { diff --git a/MobileMkch/NotificationSettingsView.swift b/MobileMkch/NotificationSettingsView.swift index e0d29aa..23025d1 100644 --- a/MobileMkch/NotificationSettingsView.swift +++ b/MobileMkch/NotificationSettingsView.swift @@ -7,6 +7,8 @@ struct NotificationSettingsView: View { @State private var showingPermissionAlert = false @State private var boards: [Board] = [] @State private var isLoadingBoards = false + @State private var isCheckingThreads = false + @State private var showingTestNotification = false var body: some View { VStack { @@ -40,7 +42,7 @@ struct NotificationSettingsView: View { .cornerRadius(8) .padding(.horizontal) - Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.") + Text("Уведомления находятся в бета-версии и могут работать нестабильно.") .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) @@ -71,12 +73,59 @@ struct NotificationSettingsView: View { } .onChange(of: settings.notificationInterval) { _ in settings.saveSettings() + BackgroundTaskManager.shared.scheduleBackgroundTask() } - Button("Проверить новые треды сейчас") { - checkNewThreadsNow() + Button(action: { + showingTestNotification = true + notificationManager.scheduleTestNotification() + }) { + HStack { + Image(systemName: "bell.badge") + Text("Отправить тестовое уведомление") + } } .foregroundColor(.blue) + .disabled(!notificationManager.isNotificationsEnabled) + + Button(action: { + isCheckingThreads = true + checkNewThreadsNow() + }) { + HStack { + if isCheckingThreads { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.clockwise") + } + Text("Проверить новые треды сейчас") + } + } + .foregroundColor(.blue) + .disabled(isCheckingThreads || notificationManager.subscribedBoards.isEmpty) + + Button(action: { + syncAllBoards() + }) { + HStack { + Image(systemName: "arrow.triangle.2.circlepath") + Text("Синхронизировать все доски") + } + } + .foregroundColor(.orange) + .disabled(notificationManager.subscribedBoards.isEmpty) + + Button(action: { + notificationManager.clearAllSavedThreads() + }) { + HStack { + Image(systemName: "trash") + Text("Очистить все сохраненные треды") + } + } + .foregroundColor(.red) + .disabled(notificationManager.subscribedBoards.isEmpty) } } @@ -89,6 +138,9 @@ struct NotificationSettingsView: View { Text("Загрузка досок...") .foregroundColor(.secondary) } + } else if boards.isEmpty { + Text("Не удалось загрузить доски") + .foregroundColor(.secondary) } else { ForEach(boards) { board in HStack { @@ -117,6 +169,22 @@ struct NotificationSettingsView: View { } } } + + Section(header: Text("Статус")) { + HStack { + Text("Разрешения") + Spacer() + Text(notificationManager.isNotificationsEnabled ? "Включены" : "Отключены") + .foregroundColor(notificationManager.isNotificationsEnabled ? .green : .red) + } + + HStack { + Text("Подписки") + Spacer() + Text("\(notificationManager.subscribedBoards.count) досок") + .foregroundColor(.secondary) + } + } } } } @@ -139,6 +207,11 @@ struct NotificationSettingsView: View { } message: { Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках") } + .alert("Тестовое уведомление", isPresented: $showingTestNotification) { + Button("OK") { } + } message: { + Text("Тестовое уведомление отправлено. Проверьте, получили ли вы его.") + } } } @@ -154,25 +227,57 @@ extension NotificationSettingsView { private func checkNewThreadsNow() { guard !notificationManager.subscribedBoards.isEmpty else { return } + let group = DispatchGroup() + var foundNewThreads = false + for boardCode in notificationManager.subscribedBoards { - let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)") + group.enter() - apiClient.checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in + apiClient.checkNewThreads(forBoard: boardCode, lastKnownThreadId: 0) { result in DispatchQueue.main.async { switch result { case .success(let newThreads): if !newThreads.isEmpty { + foundNewThreads = true for thread in newThreads { notificationManager.scheduleNotification(for: thread, boardCode: boardCode) } - UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)") } - case .failure: - break + case .failure(let error): + print("Ошибка проверки тредов для /\(boardCode)/: \(error)") } + group.leave() } } } + + group.notify(queue: .main) { + isCheckingThreads = false + if !foundNewThreads { + print("Новых тредов не найдено") + } + } + } + + private func syncAllBoards() { + for boardCode in notificationManager.subscribedBoards { + let savedThreadsKey = "savedThreads_\(boardCode)" + UserDefaults.standard.removeObject(forKey: savedThreadsKey) + + let url = URL(string: "https://mkch.pooziqo.xyz/api/board/\(boardCode)")! + var request = URLRequest(url: url) + request.setValue("MobileMkch/2.0.0-ios-alpha", forHTTPHeaderField: "User-Agent") + + URLSession.shared.dataTask(with: request) { data, response, error in + if let data = data, + let threads = try? JSONDecoder().decode([Thread].self, from: data) { + if let encodedData = try? JSONEncoder().encode(threads) { + UserDefaults.standard.set(encodedData, forKey: savedThreadsKey) + print("Синхронизировано \(threads.count) тредов для /\(boardCode)/") + } + } + }.resume() + } } private func loadBoards() {