поддержка увед ураа

This commit is contained in:
Lain Iwakura 2025-08-07 02:10:59 +03:00
parent 22960a3911
commit e27e343e53
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
12 changed files with 426 additions and 3 deletions

View File

@ -10,9 +10,22 @@
1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* 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 */ /* Begin PBXFileSystemSynchronizedRootGroup section */
1D8926EB2E43CE4C00C5590A /* MobileMkch */ = { 1D8926EB2E43CE4C00C5590A /* MobileMkch */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */,
);
path = MobileMkch; path = MobileMkch;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -255,6 +268,7 @@
DEVELOPMENT_TEAM = 9U88M9D595; DEVELOPMENT_TEAM = 9U88M9D595;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = MobileMkch/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = MobileMkch; INFOPLIST_KEY_CFBundleDisplayName = MobileMkch;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@ -290,6 +304,7 @@
DEVELOPMENT_TEAM = 9U88M9D595; DEVELOPMENT_TEAM = 9U88M9D595;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = MobileMkch/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = MobileMkch; INFOPLIST_KEY_CFBundleDisplayName = MobileMkch;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;

View File

@ -509,4 +509,36 @@ class APIClient: ObservableObject {
return String(html[range]) 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()
}
} }

View File

@ -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()
}
}
}
}

View File

@ -35,7 +35,8 @@ struct BoardsView: View {
ForEach(boards) { board in ForEach(boards) { board in
NavigationLink(destination: ThreadsView(board: board) NavigationLink(destination: ThreadsView(board: board)
.environmentObject(settings) .environmentObject(settings)
.environmentObject(apiClient)) { .environmentObject(apiClient)
.environmentObject(NotificationManager.shared)) {
BoardRow(board: board) BoardRow(board: board)
} }
} }

15
MobileMkch/Info.plist Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.mkch.MobileMkch.backgroundrefresh</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>background-processing</string>
<string>background-fetch</string>
</array>
</dict>
</plist>

View File

@ -12,6 +12,15 @@ struct MobileMkchApp: App {
@StateObject private var settings = Settings() @StateObject private var settings = Settings()
@StateObject private var apiClient = APIClient() @StateObject private var apiClient = APIClient()
@StateObject private var crashHandler = CrashHandler.shared @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 { var body: some Scene {
WindowGroup { WindowGroup {
@ -23,10 +32,15 @@ struct MobileMkchApp: App {
BoardsView() BoardsView()
.environmentObject(settings) .environmentObject(settings)
.environmentObject(apiClient) .environmentObject(apiClient)
.environmentObject(notificationManager)
} }
.preferredColorScheme(settings.theme == "dark" ? .dark : .light) .preferredColorScheme(settings.theme == "dark" ? .dark : .light)
} }
} }
.onAppear {
BackgroundTaskManager.shared.registerBackgroundTasks()
setupBackgroundTasks()
}
} }
} }
} }

View File

@ -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<String> = []
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<String>.self, from: data) {
subscribedBoards = boards
}
}
private func saveSubscribedBoards() {
if let data = try? JSONEncoder().encode(subscribedBoards) {
UserDefaults.standard.set(data, forKey: "subscribedBoards")
}
}
}

View File

@ -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
}
}
}
}

View File

@ -9,6 +9,8 @@ class Settings: ObservableObject {
@Published var pageSize: Int = 10 @Published var pageSize: Int = 10
@Published var passcode: String = "" @Published var passcode: String = ""
@Published var key: String = "" @Published var key: String = ""
@Published var notificationsEnabled: Bool = false
@Published var notificationInterval: Int = 300
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let settingsKey = "MobileMkchSettings" private let settingsKey = "MobileMkchSettings"
@ -28,6 +30,8 @@ class Settings: ObservableObject {
self.pageSize = settings.pageSize self.pageSize = settings.pageSize
self.passcode = settings.passcode self.passcode = settings.passcode
self.key = settings.key self.key = settings.key
self.notificationsEnabled = settings.notificationsEnabled
self.notificationInterval = settings.notificationInterval
} }
} }
@ -40,7 +44,9 @@ class Settings: ObservableObject {
compactMode: compactMode, compactMode: compactMode,
pageSize: pageSize, pageSize: pageSize,
passcode: passcode, passcode: passcode,
key: key key: key,
notificationsEnabled: notificationsEnabled,
notificationInterval: notificationInterval
) )
if let data = try? JSONEncoder().encode(settingsData) { if let data = try? JSONEncoder().encode(settingsData) {
@ -57,6 +63,8 @@ class Settings: ObservableObject {
pageSize = 10 pageSize = 10
passcode = "" passcode = ""
key = "" key = ""
notificationsEnabled = false
notificationInterval = 300
saveSettings() saveSettings()
} }
} }
@ -70,4 +78,6 @@ struct SettingsData: Codable {
let pageSize: Int let pageSize: Int
let passcode: String let passcode: String
let key: String let key: String
let notificationsEnabled: Bool
let notificationInterval: Int
} }

View File

@ -107,6 +107,13 @@ struct SettingsView: View {
} }
} }
Section("Уведомления") {
NavigationLink("Настройки уведомлений") {
NotificationSettingsView()
.environmentObject(apiClient)
}
}
Section("Управление кэшем") { Section("Управление кэшем") {
Button("Очистить кэш досок") { Button("Очистить кэш досок") {
Cache.shared.delete("boards") Cache.shared.delete("boards")
@ -176,6 +183,7 @@ struct SettingsView: View {
} }
.sheet(isPresented: $showingDebugMenu) { .sheet(isPresented: $showingDebugMenu) {
DebugMenuView() DebugMenuView()
.environmentObject(NotificationManager.shared)
} }
.alert("Информация о НЕОЖИДАНЫХ проблемах", isPresented: $showingInfo) { .alert("Информация о НЕОЖИДАНЫХ проблемах", isPresented: $showingInfo) {
Button("Закрыть") { } Button("Закрыть") { }
@ -275,6 +283,7 @@ struct AboutView: View {
struct DebugMenuView: View { struct DebugMenuView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject var notificationManager: NotificationManager
var body: some View { var body: some View {
NavigationView { NavigationView {
@ -290,6 +299,12 @@ struct DebugMenuView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.foregroundColor(.red) .foregroundColor(.red)
Button("Тест уведомления") {
notificationManager.scheduleTestNotification()
}
.buttonStyle(.borderedProminent)
.foregroundColor(.blue)
} }
Spacer() Spacer()

View File

@ -4,6 +4,7 @@ struct ThreadsView: View {
let board: Board let board: Board
@EnvironmentObject var settings: Settings @EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient @EnvironmentObject var apiClient: APIClient
@EnvironmentObject var notificationManager: NotificationManager
@State private var threads: [Thread] = [] @State private var threads: [Thread] = []
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?

View File

@ -30,6 +30,13 @@
- Кэширование данных - Кэширование данных
- Оптимизация потребления батареи - Оптимизация потребления батареи
- Поддержка iOS 15.0+ - Поддержка iOS 15.0+
- **Push-уведомления о новых тредах:**
- Подписка на доски через тумблеры в настройках
- Настраиваемый интервал проверки (5 мин - 1 час)
- Фоновое обновление
- Задержка уведомлений 10 секунд
- Формат: "Новый тред: [название] в /boardname/"
- Тестовые уведомления в debug меню
## Аутентификация и постинг ## Аутентификация и постинг
@ -54,6 +61,33 @@
3. Введите текст комментария 3. Введите текст комментария
4. Нажмите "Добавить" 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` - создание тредов - `CreateThreadView.swift` - создание тредов
- `AddCommentView.swift` - добавление комментариев - `AddCommentView.swift` - добавление комментариев
- `SettingsView.swift` - экран настроек - `SettingsView.swift` - экран настроек
- `NotificationManager.swift` - управление уведомлениями и тестовые уведомления
- `BackgroundTaskManager.swift` - фоновые задачи
- `NotificationSettingsView.swift` - настройки уведомлений с тумблерами подписок
## Технологии ## Технологии