завел уведы вроде как ура

This commit is contained in:
Lain Iwakura 2025-08-07 21:06:50 +03:00
parent d053ffccc6
commit e78c66b0c8
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
5 changed files with 246 additions and 23 deletions

View File

@ -550,9 +550,31 @@ class APIClient: ObservableObject {
} }
do { do {
let threads = try JSONDecoder().decode([Thread].self, from: data) let currentThreads = try JSONDecoder().decode([Thread].self, from: data)
let newThreads = threads.filter { $0.id > lastKnownThreadId }
completion(.success(newThreads)) 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 { } catch {
completion(.failure(error)) completion(.failure(error))
} }

View File

@ -24,11 +24,12 @@ class BackgroundTaskManager {
func scheduleBackgroundTask() { func scheduleBackgroundTask() {
let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier) let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier)
let settings = Settings() let settings = Settings()
let interval = TimeInterval(settings.notificationInterval) let interval = TimeInterval(settings.notificationInterval * 60)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval) request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
do { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
print("Фоновая задача запланирована на \(interval) секунд")
} catch { } catch {
print("Не удалось запланировать фоновую задачу: \(error)") print("Не удалось запланировать фоновую задачу: \(error)")
} }
@ -51,20 +52,18 @@ class BackgroundTaskManager {
for boardCode in notificationManager.subscribedBoards { for boardCode in notificationManager.subscribedBoards {
group.enter() group.enter()
let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)") APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: 0) { result in
APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in
switch result { switch result {
case .success(let newThreads): case .success(let newThreads):
if !newThreads.isEmpty { if !newThreads.isEmpty {
hasNewThreads = true hasNewThreads = true
print("Найдено \(newThreads.count) новых тредов в /\(boardCode)/")
for thread in newThreads { for thread in newThreads {
self.notificationManager.scheduleNotification(for: thread, boardCode: boardCode) self.notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
} }
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
} }
case .failure: case .failure(let error):
break print("Ошибка проверки новых тредов для /\(boardCode)/: \(error)")
} }
group.leave() group.leave()
} }

View File

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import UserNotifications
@main @main
struct MobileMkchApp: App { 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 { var body: some Scene {
WindowGroup { WindowGroup {
Group { Group {
@ -38,6 +58,8 @@ struct MobileMkchApp: App {
.onAppear { .onAppear {
BackgroundTaskManager.shared.registerBackgroundTasks() BackgroundTaskManager.shared.registerBackgroundTasks()
setupBackgroundTasks() setupBackgroundTasks()
setupNotifications()
handleNotificationLaunch()
} }
} }
} }

View File

@ -2,6 +2,18 @@ import Foundation
import UserNotifications import UserNotifications
import UIKit 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 { class NotificationManager: ObservableObject {
static let shared = NotificationManager() static let shared = NotificationManager()
@ -17,11 +29,20 @@ class NotificationManager: ObservableObject {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
DispatchQueue.main.async { DispatchQueue.main.async {
self.isNotificationsEnabled = granted self.isNotificationsEnabled = granted
if granted {
self.registerForRemoteNotifications()
}
completion(granted) completion(granted)
} }
} }
} }
private func registerForRemoteNotifications() {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
func checkNotificationStatus() { func checkNotificationStatus() {
UNUserNotificationCenter.current().getNotificationSettings { settings in UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -33,36 +54,90 @@ class NotificationManager: ObservableObject {
func subscribeToBoard(_ boardCode: String) { func subscribeToBoard(_ boardCode: String) {
subscribedBoards.insert(boardCode) subscribedBoards.insert(boardCode)
saveSubscribedBoards() saveSubscribedBoards()
BackgroundTaskManager.shared.scheduleBackgroundTask() 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) { func unsubscribeFromBoard(_ boardCode: String) {
subscribedBoards.remove(boardCode) subscribedBoards.remove(boardCode)
saveSubscribedBoards() saveSubscribedBoards()
let savedThreadsKey = "savedThreads_\(boardCode)"
UserDefaults.standard.removeObject(forKey: savedThreadsKey)
} }
func scheduleNotification(for thread: Thread, boardCode: String) { func scheduleNotification(for thread: Thread, boardCode: String) {
guard isNotificationsEnabled else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Новый тред" content.title = "Новый тред"
content.body = "\(thread.title) в /\(boardCode)/" content.body = "\(thread.title) в /\(boardCode)/"
content.sound = .default content.sound = .default
content.badge = 1
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false) let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: "thread_\(thread.id)", content: content, trigger: trigger) 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() { func scheduleTestNotification() {
guard isNotificationsEnabled else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Тестовое уведомление" content.title = "Тестовое уведомление"
content.body = "Новый тред: Тестовый тред в /test/" content.body = "Новый тред: Тестовый тред в /test/"
content.sound = .default 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) 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() { private func loadSubscribedBoards() {

View File

@ -7,6 +7,8 @@ struct NotificationSettingsView: View {
@State private var showingPermissionAlert = false @State private var showingPermissionAlert = false
@State private var boards: [Board] = [] @State private var boards: [Board] = []
@State private var isLoadingBoards = false @State private var isLoadingBoards = false
@State private var isCheckingThreads = false
@State private var showingTestNotification = false
var body: some View { var body: some View {
VStack { VStack {
@ -40,7 +42,7 @@ struct NotificationSettingsView: View {
.cornerRadius(8) .cornerRadius(8)
.padding(.horizontal) .padding(.horizontal)
Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.") Text("Уведомления находятся в бета-версии и могут работать нестабильно.")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -71,12 +73,59 @@ struct NotificationSettingsView: View {
} }
.onChange(of: settings.notificationInterval) { _ in .onChange(of: settings.notificationInterval) { _ in
settings.saveSettings() settings.saveSettings()
BackgroundTaskManager.shared.scheduleBackgroundTask()
} }
Button("Проверить новые треды сейчас") { Button(action: {
checkNewThreadsNow() showingTestNotification = true
notificationManager.scheduleTestNotification()
}) {
HStack {
Image(systemName: "bell.badge")
Text("Отправить тестовое уведомление")
}
} }
.foregroundColor(.blue) .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("Загрузка досок...") Text("Загрузка досок...")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} else if boards.isEmpty {
Text("Не удалось загрузить доски")
.foregroundColor(.secondary)
} else { } else {
ForEach(boards) { board in ForEach(boards) { board in
HStack { 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: { } message: {
Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках") Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках")
} }
.alert("Тестовое уведомление", isPresented: $showingTestNotification) {
Button("OK") { }
} message: {
Text("Тестовое уведомление отправлено. Проверьте, получили ли вы его.")
}
} }
} }
@ -154,25 +227,57 @@ extension NotificationSettingsView {
private func checkNewThreadsNow() { private func checkNewThreadsNow() {
guard !notificationManager.subscribedBoards.isEmpty else { return } guard !notificationManager.subscribedBoards.isEmpty else { return }
for boardCode in notificationManager.subscribedBoards { let group = DispatchGroup()
let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)") var foundNewThreads = false
apiClient.checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in for boardCode in notificationManager.subscribedBoards {
group.enter()
apiClient.checkNewThreads(forBoard: boardCode, lastKnownThreadId: 0) { result in
DispatchQueue.main.async { DispatchQueue.main.async {
switch result { switch result {
case .success(let newThreads): case .success(let newThreads):
if !newThreads.isEmpty { if !newThreads.isEmpty {
foundNewThreads = true
for thread in newThreads { for thread in newThreads {
notificationManager.scheduleNotification(for: thread, boardCode: boardCode) notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
} }
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
} }
case .failure: case .failure(let error):
break 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() { private func loadBoards() {