поддержка увед ураа
This commit is contained in:
parent
22960a3911
commit
e27e343e53
@ -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 = "<group>";
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
80
MobileMkch/BackgroundTaskManager.swift
Normal file
80
MobileMkch/BackgroundTaskManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
15
MobileMkch/Info.plist
Normal file
15
MobileMkch/Info.plist
Normal 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>
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
MobileMkch/NotificationManager.swift
Normal file
80
MobileMkch/NotificationManager.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
123
MobileMkch/NotificationSettingsView.swift
Normal file
123
MobileMkch/NotificationSettingsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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?
|
||||
|
||||
39
README.md
39
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 (айпад СЛЕГКА поломан, проверил, мб чет с этим сделаю, лень)
|
||||
- Поддержка темной/светлой темы
|
||||
- Адаптивный интерфейс
|
||||
- Адаптивный интерфейс
|
||||
Loading…
x
Reference in New Issue
Block a user