add live activity

This commit is contained in:
Lain Iwakura 2025-08-08 14:05:55 +03:00
parent 013623290d
commit f1864dc2ba
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
9 changed files with 478 additions and 4 deletions

View File

@ -7,6 +7,7 @@
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
import ActivityKit
private let appGroupId = "group.mobilemkch" private let appGroupId = "group.mobilemkch"
@ -178,3 +179,99 @@ struct FavoritesWidget: Widget {
.description("Показывает избранные треды или топ по выбранной доске.") .description("Показывает избранные треды или топ по выбранной доске.")
} }
} }
@available(iOS 16.1, *)
struct ThreadActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var latestCommentText: String
var commentsCount: Int
var showTitle: Bool
var showLastComment: Bool
var showCommentCount: Bool
var currentTitle: String
var currentBoard: String
}
var threadId: Int
var title: String
var board: String
}
@available(iOS 16.1, *)
struct ThreadLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: ThreadActivityAttributes.self) { context in
ThreadLiveActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text("/\(context.state.currentBoard)/")
.font(.caption)
.foregroundColor(.blue)
}
DynamicIslandExpandedRegion(.center) {
VStack(alignment: .leading, spacing: 4) {
if context.state.showTitle {
Text(context.state.currentTitle)
.font(.footnote)
.lineLimit(2)
}
if context.state.showLastComment && !context.state.latestCommentText.isEmpty {
Text(context.state.latestCommentText)
.font(.caption2)
.lineLimit(2)
.foregroundColor(.secondary)
}
}
}
DynamicIslandExpandedRegion(.trailing) {
if context.state.showCommentCount {
Label("\(context.state.commentsCount)", systemImage: "text.bubble")
.font(.caption)
}
}
} compactLeading: {
Text("/\(context.state.currentBoard)/")
.font(.caption2)
} compactTrailing: {
if context.state.showCommentCount {
Text("\(context.state.commentsCount)")
.font(.caption2)
}
} minimal: {
Image(systemName: "text.bubble")
}
}
}
}
@available(iOS 16.1, *)
private struct ThreadLiveActivityView: View {
let context: ActivityViewContext<ThreadActivityAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text("/\(context.state.currentBoard)/")
.font(.caption)
.foregroundColor(.blue)
if context.state.showCommentCount {
Label("\(context.state.commentsCount)", systemImage: "text.bubble")
.font(.caption)
}
Spacer()
}
if context.state.showTitle {
Text(context.state.currentTitle)
.font(.footnote)
.lineLimit(2)
}
if context.state.showLastComment && !context.state.latestCommentText.isEmpty {
Text(context.state.latestCommentText)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.padding(12)
}
}

View File

@ -7,10 +7,14 @@
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
import ActivityKit
@main @main
struct FavoritesWidgetBundle: WidgetBundle { struct FavoritesWidgetBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
FavoritesWidget() FavoritesWidget()
if #available(iOS 16.1, *) {
ThreadLiveActivity()
}
} }
} }

View File

@ -6,6 +6,8 @@
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string> <string>com.apple.widgetkit-extension</string>
</dict> </dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@ -6,6 +6,8 @@
<array> <array>
<string>com.mkch.MobileMkch.backgroundrefresh</string> <string>com.mkch.MobileMkch.backgroundrefresh</string>
</array> </array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>background-processing</string> <string>background-processing</string>

View File

@ -0,0 +1,201 @@
import Foundation
import ActivityKit
@available(iOS 16.1, *)
final class LiveActivityManager {
static let shared = LiveActivityManager()
private let storeKey = "activeThreadActivities"
private var threadIdToActivityId: [Int: String] = [:]
private var tickerTask: Task<Void, Never>?
private var tickerActivityId: String?
private init() {
load()
}
func isActive(threadId: Int) -> Bool {
return threadIdToActivityId[threadId] != nil && activity(for: threadId) != nil
}
func start(for detail: ThreadDetail, comments: [Comment], settings: Settings) {
let latestText = comments.last?.formattedText ?? ""
let count = comments.count
let attributes = ThreadActivityAttributes(threadId: detail.id, title: detail.title, board: detail.board)
let state = ThreadActivityAttributes.ContentState(
latestCommentText: latestText,
commentsCount: count,
showTitle: settings.liveActivityShowTitle,
showLastComment: settings.liveActivityShowLastComment,
showCommentCount: settings.liveActivityShowCommentCount,
currentTitle: detail.title,
currentBoard: detail.board
)
do {
let activity = try Activity<ThreadActivityAttributes>.request(attributes: attributes, contentState: state, pushType: nil)
threadIdToActivityId[detail.id] = activity.id
save()
} catch {
}
}
func update(threadId: Int, comments: [Comment], settings: Settings) {
guard let activity = activity(for: threadId) else { return }
let latestText = comments.last?.formattedText ?? ""
let count = comments.count
let state = ThreadActivityAttributes.ContentState(
latestCommentText: latestText,
commentsCount: count,
showTitle: settings.liveActivityShowTitle,
showLastComment: settings.liveActivityShowLastComment,
showCommentCount: settings.liveActivityShowCommentCount,
currentTitle: "",
currentBoard: ""
)
Task {
await activity.update(using: state)
}
}
func end(threadId: Int) {
guard let activity = activity(for: threadId) else { return }
Task {
await activity.end(dismissalPolicy: .immediate)
}
threadIdToActivityId.removeValue(forKey: threadId)
save()
}
private func activity(for threadId: Int) -> Activity<ThreadActivityAttributes>? {
guard let id = threadIdToActivityId[threadId] else { return nil }
return Activity<ThreadActivityAttributes>.activities.first(where: { $0.id == id })
}
private func save() {
if let data = try? JSONEncoder().encode(threadIdToActivityId) {
UserDefaults.standard.set(data, forKey: storeKey)
}
}
private func load() {
if let data = UserDefaults.standard.data(forKey: storeKey),
let map = try? JSONDecoder().decode([Int: String].self, from: data) {
threadIdToActivityId = map
}
}
}
@available(iOS 16.1, *)
struct ThreadActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var latestCommentText: String
var commentsCount: Int
var showTitle: Bool
var showLastComment: Bool
var showCommentCount: Bool
var currentTitle: String
var currentBoard: String
}
var threadId: Int
var title: String
var board: String
}
@available(iOS 16.1, *)
extension LiveActivityManager {
var isTickerRunning: Bool { tickerTask != nil }
func startTicker(settings: Settings, apiClient: APIClient) {
stopTicker()
let attributes = ThreadActivityAttributes(threadId: -1, title: "", board: "")
let initial = ThreadActivityAttributes.ContentState(
latestCommentText: "",
commentsCount: 0,
showTitle: settings.liveActivityShowTitle,
showLastComment: settings.liveActivityShowLastComment,
showCommentCount: settings.liveActivityShowCommentCount,
currentTitle: "",
currentBoard: ""
)
do {
let activity = try Activity<ThreadActivityAttributes>.request(attributes: attributes, contentState: initial, pushType: nil)
tickerActivityId = activity.id
} catch {
return
}
tickerTask = Task { [weak self] in
while !(Task.isCancelled) {
guard let self = self, let activityId = self.tickerActivityId,
let activity = Activity<ThreadActivityAttributes>.activities.first(where: { $0.id == activityId }) else { break }
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
let boardHandler: (String) -> Void = { boardCode in
apiClient.getThreads(forBoard: boardCode) { result in
switch result {
case .success(let threads):
guard let thread = threads.randomElement() else {
cont.resume()
return
}
apiClient.getFullThread(boardCode: boardCode, threadId: thread.id) { full in
switch full {
case .success(let (detail, comments)):
let text = comments.last?.formattedText ?? detail.text
let count = comments.count
let state = ThreadActivityAttributes.ContentState(
latestCommentText: text,
commentsCount: count,
showTitle: settings.liveActivityShowTitle,
showLastComment: settings.liveActivityShowLastComment,
showCommentCount: settings.liveActivityShowCommentCount,
currentTitle: detail.title,
currentBoard: detail.board
)
Task { await activity.update(using: state) }
cont.resume()
case .failure:
cont.resume()
}
}
case .failure:
cont.resume()
}
}
}
if settings.liveActivityTickerRandomBoard {
apiClient.getBoards { boardsResult in
switch boardsResult {
case .success(let boards):
if let random = boards.randomElement() {
boardHandler(random.code)
} else {
cont.resume()
}
case .failure:
cont.resume()
}
}
} else {
let code = settings.liveActivityTickerBoardCode.isEmpty ? settings.lastBoard : settings.liveActivityTickerBoardCode
boardHandler(code)
}
}
try? await Task.sleep(nanoseconds: UInt64(max(settings.liveActivityTickerInterval, 5)) * 1_000_000_000)
}
}
}
func stopTicker() {
tickerTask?.cancel()
tickerTask = nil
if let id = tickerActivityId,
let activity = Activity<ThreadActivityAttributes>.activities.first(where: { $0.id == id }) {
Task { await activity.end(dismissalPolicy: .immediate) }
}
tickerActivityId = nil
}
}

View File

@ -16,6 +16,14 @@ class Settings: ObservableObject {
@Published var notificationInterval: Int = 300 @Published var notificationInterval: Int = 300
@Published var favoriteThreads: [FavoriteThread] = [] @Published var favoriteThreads: [FavoriteThread] = []
@Published var offlineMode: Bool = false @Published var offlineMode: Bool = false
@Published var liveActivityEnabled: Bool = false
@Published var liveActivityShowTitle: Bool = true
@Published var liveActivityShowLastComment: Bool = true
@Published var liveActivityShowCommentCount: Bool = true
@Published var liveActivityTickerEnabled: Bool = false
@Published var liveActivityTickerRandomBoard: Bool = true
@Published var liveActivityTickerBoardCode: String = "b"
@Published var liveActivityTickerInterval: Int = 15
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let settingsKey = "MobileMkchSettings" private let settingsKey = "MobileMkchSettings"
@ -42,6 +50,14 @@ class Settings: ObservableObject {
self.notificationInterval = settings.notificationInterval self.notificationInterval = settings.notificationInterval
self.favoriteThreads = settings.favoriteThreads self.favoriteThreads = settings.favoriteThreads
self.offlineMode = settings.offlineMode ?? false self.offlineMode = settings.offlineMode ?? false
self.liveActivityEnabled = settings.liveActivityEnabled ?? false
self.liveActivityShowTitle = settings.liveActivityShowTitle ?? true
self.liveActivityShowLastComment = settings.liveActivityShowLastComment ?? true
self.liveActivityShowCommentCount = settings.liveActivityShowCommentCount ?? true
self.liveActivityTickerEnabled = settings.liveActivityTickerEnabled ?? false
self.liveActivityTickerRandomBoard = settings.liveActivityTickerRandomBoard ?? true
self.liveActivityTickerBoardCode = settings.liveActivityTickerBoardCode ?? "b"
self.liveActivityTickerInterval = settings.liveActivityTickerInterval ?? 15
} }
mirrorStateToAppGroup() mirrorStateToAppGroup()
} }
@ -63,6 +79,15 @@ class Settings: ObservableObject {
favoriteThreads: favoriteThreads favoriteThreads: favoriteThreads
, ,
offlineMode: offlineMode offlineMode: offlineMode
,
liveActivityEnabled: liveActivityEnabled,
liveActivityShowTitle: liveActivityShowTitle,
liveActivityShowLastComment: liveActivityShowLastComment,
liveActivityShowCommentCount: liveActivityShowCommentCount,
liveActivityTickerEnabled: liveActivityTickerEnabled,
liveActivityTickerRandomBoard: liveActivityTickerRandomBoard,
liveActivityTickerBoardCode: liveActivityTickerBoardCode,
liveActivityTickerInterval: liveActivityTickerInterval
) )
if let data = try? JSONEncoder().encode(settingsData) { if let data = try? JSONEncoder().encode(settingsData) {
@ -138,4 +163,12 @@ struct SettingsData: Codable {
let notificationInterval: Int let notificationInterval: Int
let favoriteThreads: [FavoriteThread] let favoriteThreads: [FavoriteThread]
let offlineMode: Bool? let offlineMode: Bool?
let liveActivityEnabled: Bool?
let liveActivityShowTitle: Bool?
let liveActivityShowLastComment: Bool?
let liveActivityShowCommentCount: Bool?
let liveActivityTickerEnabled: Bool?
let liveActivityTickerRandomBoard: Bool?
let liveActivityTickerBoardCode: String?
let liveActivityTickerInterval: Int?
} }

View File

@ -1,12 +1,14 @@
import SwiftUI import SwiftUI
import Combine import Combine
import Darwin import Darwin
import ActivityKit
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var settings: Settings @EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient @EnvironmentObject var apiClient: APIClient
@EnvironmentObject var networkMonitor: NetworkMonitor @EnvironmentObject var networkMonitor: NetworkMonitor
@State private var isTickerRunning = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingInfo = false @State private var showingInfo = false
@State private var testKeyResult: String? @State private var testKeyResult: String?
@ -203,6 +205,65 @@ struct SettingsView: View {
} }
.padding(.trailing, 8) .padding(.trailing, 8)
) )
if #available(iOS 16.1, *) {
Toggle("Live Activity", isOn: $settings.liveActivityEnabled)
.onReceive(Just(settings.liveActivityEnabled)) { _ in
settings.saveSettings()
}
if settings.liveActivityEnabled {
Toggle("Показывать заголовок", isOn: $settings.liveActivityShowTitle)
.onReceive(Just(settings.liveActivityShowTitle)) { _ in settings.saveSettings() }
Toggle("Показывать последний коммент", isOn: $settings.liveActivityShowLastComment)
.onReceive(Just(settings.liveActivityShowLastComment)) { _ in settings.saveSettings() }
Toggle("Показывать счётчик", isOn: $settings.liveActivityShowCommentCount)
.onReceive(Just(settings.liveActivityShowCommentCount)) { _ in settings.saveSettings() }
Toggle("Тикер случайных тредов", isOn: $settings.liveActivityTickerEnabled)
.onReceive(Just(settings.liveActivityTickerEnabled)) { _ in settings.saveSettings() }
if settings.liveActivityTickerEnabled {
Toggle("Случайная борда", isOn: $settings.liveActivityTickerRandomBoard)
.onReceive(Just(settings.liveActivityTickerRandomBoard)) { _ in settings.saveSettings() }
if !settings.liveActivityTickerRandomBoard {
HStack {
Text("Код борды")
TextField("b", text: $settings.liveActivityTickerBoardCode)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
}
.onReceive(Just(settings.liveActivityTickerBoardCode)) { _ in settings.saveSettings() }
}
HStack {
Text("Интервал, сек")
Spacer()
Stepper(value: $settings.liveActivityTickerInterval, in: 5...120, step: 5) {
Text("\(settings.liveActivityTickerInterval)")
}
}
.onReceive(Just(settings.liveActivityTickerInterval)) { _ in settings.saveSettings() }
HStack(spacing: 12) {
Button("Старт тикера") {
LiveActivityManager.shared.startTicker(settings: settings, apiClient: apiClient)
isTickerRunning = true
}
.buttonStyle(.bordered)
.tint(.green)
Button("Стоп тикера") {
LiveActivityManager.shared.stopTicker()
isTickerRunning = false
}
.buttonStyle(.bordered)
.tint(.red)
Spacer()
Text(isTickerRunning ? "Работает" : "Остановлен")
.font(.caption)
.foregroundColor(isTickerRunning ? .green : .secondary)
}
.onAppear { isTickerRunning = LiveActivityManager.shared.isTickerRunning }
}
Text("В фоне частые обновления ограничены системой")
.font(.caption2)
.foregroundColor(.secondary)
}
}
} }
Section("Управление кэшем") { Section("Управление кэшем") {
@ -434,6 +495,7 @@ struct AboutView: View {
struct DebugMenuView: View { struct DebugMenuView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject var notificationManager: NotificationManager @EnvironmentObject var notificationManager: NotificationManager
@State private var liveActivityStarted = false
var body: some View { var body: some View {
NavigationView { NavigationView {
@ -455,6 +517,25 @@ struct DebugMenuView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.foregroundColor(.blue) .foregroundColor(.blue)
if #available(iOS 16.1, *) {
Button(liveActivityStarted ? "Остановить Live Activity" : "Тест Live Activity") {
if liveActivityStarted {
LiveActivityManager.shared.end(threadId: 999999)
liveActivityStarted = false
} else {
let detail = ThreadDetail(id: 999999, creation: "2023-01-01T00:00:00Z", title: "Тестовый тред", text: "", board: "b", files: [])
let comments = [Comment(id: 1, text: "Привет из Live Activity", creation: "2023-01-01T00:00:00Z", files: [])]
var s = Settings()
s.liveActivityEnabled = true
s.liveActivityShowTitle = true
s.liveActivityShowLastComment = true
s.liveActivityShowCommentCount = true
LiveActivityManager.shared.start(for: detail, comments: comments, settings: s)
liveActivityStarted = true
}
}
.buttonStyle(.borderedProminent)
}
} }
Spacer() Spacer()

View File

@ -11,6 +11,7 @@ struct ThreadDetailView: View {
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showingAddComment = false @State private var showingAddComment = false
@State private var activityOn = false
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -88,8 +89,30 @@ struct ThreadDetailView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Обновить") { HStack {
loadThreadDetail() Button("Обновить") { loadThreadDetail() }
if #available(iOS 16.1, *) {
Toggle("", isOn: $activityOn)
.toggleStyle(SwitchToggleStyle(tint: .blue))
.labelsHidden()
.onChange(of: activityOn) { newValue in
guard settings.liveActivityEnabled else { return }
if newValue {
if let detail = threadDetail {
LiveActivityManager.shared.start(for: detail, comments: comments, settings: settings)
}
} else {
LiveActivityManager.shared.end(threadId: thread.id)
}
}
.onAppear {
if settings.liveActivityEnabled {
if #available(iOS 16.1, *) {
activityOn = LiveActivityManager.shared.isActive(threadId: thread.id)
}
}
}
}
} }
} }
} }
@ -117,6 +140,9 @@ struct ThreadDetailView: View {
case .success(let (detail, loadedComments)): case .success(let (detail, loadedComments)):
self.threadDetail = detail self.threadDetail = detail
self.comments = loadedComments self.comments = loadedComments
if #available(iOS 16.1, *), settings.liveActivityEnabled, activityOn {
LiveActivityManager.shared.update(threadId: thread.id, comments: loadedComments, settings: settings)
}
case .failure(let error): case .failure(let error):
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
} }

View File

@ -77,6 +77,10 @@
- **Debug меню** (5 тапов по информации об устройстве): - **Debug меню** (5 тапов по информации об устройстве):
- Тест краша приложения - Тест краша приложения
- Тестовые уведомления - Тестовые уведомления
- **Live Activity (BETA)**:
- Отображение треда на экране блокировки/Dynamic Island
- Тикер случайных тредов по доскам с настраиваемым интервалом
- Гибкие опции: заголовок, последний коммент, счётчик
- **Управление кэшем**: - **Управление кэшем**:
- Очистка кэша досок - Очистка кэша досок
- Очистка кэша тредов - Очистка кэша тредов
@ -110,6 +114,18 @@
5. **Подпишитесь на нужные доски** переключателями 5. **Подпишитесь на нужные доски** переключателями
6. **Протестируйте** кнопкой "Проверить новые треды сейчас" 6. **Протестируйте** кнопкой "Проверить новые треды сейчас"
### Live Activity (BETA)
1. Требования: iPhone c iOS 16.1+ (на iPad до iPadOS 18 Live Activity не показываются; на симуляторе поддержка ограничена)
2. Включите: Настройки -> Уведомления -> Live Activity
3. Отдельный тред: откройте тред и включите тумблер в правом верхнем углу
4. Тикер:
- В Настройки -> Уведомления включите "Тикер случайных тредов"
- Выберите случайную борду или укажите код борды
- Задайте интервал (5120 сек)
- Кнопки "Старт тикера" / "Стоп тикера"
5. Ограничения платформы: бегущая строка в Live Activity недоступна. Контент обновляется дискретно через интервал. Частота обновлений в фоне ограничивается iOS.
### Оффлайн режим ### Оффлайн режим
1. Откройте вкладку "Настройки" 1. Откройте вкладку "Настройки"
@ -130,6 +146,7 @@
| `APIClient.swift` | HTTP клиент с CSRF и аутентификацией | | `APIClient.swift` | HTTP клиент с CSRF и аутентификацией |
| `Settings.swift` | Система настроек с JSON сериализацией | | `Settings.swift` | Система настроек с JSON сериализацией |
| `Cache.swift` | Многоуровневое кэширование с TTL | | `Cache.swift` | Многоуровневое кэширование с TTL |
| `LiveActivityManager.swift` | Управление Live Activity и тикером |
### UI компоненты ### UI компоненты
@ -144,6 +161,15 @@
| `FileView.swift` | Просмотр файлов с полноэкранным режимом | | `FileView.swift` | Просмотр файлов с полноэкранным режимом |
| `NotificationSettingsView.swift` | BETA настройки уведомлений | | `NotificationSettingsView.swift` | BETA настройки уведомлений |
### Виджеты и Live Activity
| Файл | Описание |
|------|----------|
| `FavoritesWidget/FavoritesWidget.swift` | Конфигурация виджета и UI Live Activity |
| `FavoritesWidget/FavoritesWidgetBundle.swift` | Регистрация виджета и Live Activity |
| `FavoritesWidget/AppIntent.swift` | Intent-конфигурация виджета |
| `FavoritesWidget/Info.plist` | Ключ `NSSupportsLiveActivities` |
### Системные сервисы ### Системные сервисы
| Файл | Описание | | Файл | Описание |
@ -153,6 +179,7 @@
| `CrashHandler.swift` | Обработка крашей | | `CrashHandler.swift` | Обработка крашей |
| `ImageLoader.swift` | Асинхронная загрузка изображений | | `ImageLoader.swift` | Асинхронная загрузка изображений |
| `NetworkMonitor.swift` | Монитор сети и принудительный оффлайн | | `NetworkMonitor.swift` | Монитор сети и принудительный оффлайн |
| `AppGroup.swift` | Общие UserDefaults для app ↔ widget |
## API интеграция ## API интеграция
@ -211,6 +238,7 @@ P.S. Костыль через Payload/MobileMkch.app в зипе и переи
## Версии и обновления ## Версии и обновления
### Версия 2.1.1-ios-alpha (Текущая) ### Версия 2.1.1-ios-alpha (Текущая)
- Live Activity (BETA): тумблер в треде, тикер в "Настройки -> Уведомления"
- Добавлен оффлайн режим (тумблер в настройках) - Добавлен оффлайн режим (тумблер в настройках)
- Дисковый кэш и фолбэк на сохранённые данные - Дисковый кэш и фолбэк на сохранённые данные
- Оффлайн-баннеры в списках и деталях - Оффлайн-баннеры в списках и деталях
@ -225,7 +253,7 @@ P.S. Костыль через Payload/MobileMkch.app в зипе и переи
p.s. я не уверен работает ли оно, но оно работает по крайней мере на моем устройстве p.s. я не уверен работает ли оно, но оно работает по крайней мере на моем устройстве
### Версия 2.0.0-ios-alpha (Текущая) ### Версия 2.0.0-ios-alpha
- Полная переработка UI на SwiftUI - Полная переработка UI на SwiftUI
- Система избранного с локальным сохранением - Система избранного с локальным сохранением
- Push-уведомления с фоновым обновлением - Push-уведомления с фоновым обновлением