diff --git a/FavoritesWidget/FavoritesWidget.swift b/FavoritesWidget/FavoritesWidget.swift index a48be3e..502650d 100644 --- a/FavoritesWidget/FavoritesWidget.swift +++ b/FavoritesWidget/FavoritesWidget.swift @@ -7,6 +7,7 @@ import WidgetKit import SwiftUI +import ActivityKit private let appGroupId = "group.mobilemkch" @@ -178,3 +179,99 @@ struct FavoritesWidget: Widget { .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 + 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) + } +} diff --git a/FavoritesWidget/FavoritesWidgetBundle.swift b/FavoritesWidget/FavoritesWidgetBundle.swift index 7649c20..611425d 100644 --- a/FavoritesWidget/FavoritesWidgetBundle.swift +++ b/FavoritesWidget/FavoritesWidgetBundle.swift @@ -7,10 +7,14 @@ import WidgetKit import SwiftUI +import ActivityKit @main struct FavoritesWidgetBundle: WidgetBundle { var body: some Widget { FavoritesWidget() + if #available(iOS 16.1, *) { + ThreadLiveActivity() + } } } diff --git a/FavoritesWidget/Info.plist b/FavoritesWidget/Info.plist index 0f118fb..6baffac 100644 --- a/FavoritesWidget/Info.plist +++ b/FavoritesWidget/Info.plist @@ -6,6 +6,8 @@ NSExtensionPointIdentifier com.apple.widgetkit-extension - + + NSSupportsLiveActivities + diff --git a/MobileMkch/Info.plist b/MobileMkch/Info.plist index d05b0fa..2bd486b 100644 --- a/MobileMkch/Info.plist +++ b/MobileMkch/Info.plist @@ -6,6 +6,8 @@ com.mkch.MobileMkch.backgroundrefresh + NSSupportsLiveActivities + UIBackgroundModes background-processing diff --git a/MobileMkch/LiveActivityManager.swift b/MobileMkch/LiveActivityManager.swift new file mode 100644 index 0000000..906b829 --- /dev/null +++ b/MobileMkch/LiveActivityManager.swift @@ -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? + 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.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? { + guard let id = threadIdToActivityId[threadId] else { return nil } + return Activity.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.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.activities.first(where: { $0.id == activityId }) else { break } + await withCheckedContinuation { (cont: CheckedContinuation) 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.activities.first(where: { $0.id == id }) { + Task { await activity.end(dismissalPolicy: .immediate) } + } + tickerActivityId = nil + } +} + + diff --git a/MobileMkch/Settings.swift b/MobileMkch/Settings.swift index 2e26c97..08ca4e5 100644 --- a/MobileMkch/Settings.swift +++ b/MobileMkch/Settings.swift @@ -16,6 +16,14 @@ class Settings: ObservableObject { @Published var notificationInterval: Int = 300 @Published var favoriteThreads: [FavoriteThread] = [] @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 settingsKey = "MobileMkchSettings" @@ -42,6 +50,14 @@ class Settings: ObservableObject { self.notificationInterval = settings.notificationInterval self.favoriteThreads = settings.favoriteThreads 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() } @@ -63,6 +79,15 @@ class Settings: ObservableObject { favoriteThreads: favoriteThreads , 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) { @@ -138,4 +163,12 @@ struct SettingsData: Codable { let notificationInterval: Int let favoriteThreads: [FavoriteThread] 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? } \ No newline at end of file diff --git a/MobileMkch/SettingsView.swift b/MobileMkch/SettingsView.swift index b805e3c..7ecdf73 100644 --- a/MobileMkch/SettingsView.swift +++ b/MobileMkch/SettingsView.swift @@ -1,12 +1,14 @@ import SwiftUI import Combine import Darwin +import ActivityKit struct SettingsView: View { @EnvironmentObject var settings: Settings @EnvironmentObject var apiClient: APIClient @EnvironmentObject var networkMonitor: NetworkMonitor + @State private var isTickerRunning = false @State private var showingAbout = false @State private var showingInfo = false @State private var testKeyResult: String? @@ -203,6 +205,65 @@ struct SettingsView: View { } .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("Управление кэшем") { @@ -434,6 +495,7 @@ struct AboutView: View { struct DebugMenuView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var notificationManager: NotificationManager + @State private var liveActivityStarted = false var body: some View { NavigationView { @@ -455,6 +517,25 @@ struct DebugMenuView: View { } .buttonStyle(.borderedProminent) .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() diff --git a/MobileMkch/ThreadDetailView.swift b/MobileMkch/ThreadDetailView.swift index 9f8f87e..e91810c 100644 --- a/MobileMkch/ThreadDetailView.swift +++ b/MobileMkch/ThreadDetailView.swift @@ -11,6 +11,7 @@ struct ThreadDetailView: View { @State private var isLoading = false @State private var errorMessage: String? @State private var showingAddComment = false + @State private var activityOn = false var body: some View { ScrollView { @@ -88,8 +89,30 @@ struct ThreadDetailView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button("Обновить") { - loadThreadDetail() + HStack { + 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)): self.threadDetail = detail 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): self.errorMessage = error.localizedDescription } diff --git a/README.md b/README.md index 6edec6d..5f35875 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ - **Debug меню** (5 тапов по информации об устройстве): - Тест краша приложения - Тестовые уведомления +- **Live Activity (BETA)**: + - Отображение треда на экране блокировки/Dynamic Island + - Тикер случайных тредов по доскам с настраиваемым интервалом + - Гибкие опции: заголовок, последний коммент, счётчик - **Управление кэшем**: - Очистка кэша досок - Очистка кэша тредов @@ -110,6 +114,18 @@ 5. **Подпишитесь на нужные доски** переключателями 6. **Протестируйте** кнопкой "Проверить новые треды сейчас" +### Live Activity (BETA) + +1. Требования: iPhone c iOS 16.1+ (на iPad до iPadOS 18 Live Activity не показываются; на симуляторе поддержка ограничена) +2. Включите: Настройки -> Уведомления -> Live Activity +3. Отдельный тред: откройте тред и включите тумблер в правом верхнем углу +4. Тикер: + - В Настройки -> Уведомления включите "Тикер случайных тредов" + - Выберите случайную борду или укажите код борды + - Задайте интервал (5–120 сек) + - Кнопки "Старт тикера" / "Стоп тикера" +5. Ограничения платформы: бегущая строка в Live Activity недоступна. Контент обновляется дискретно через интервал. Частота обновлений в фоне ограничивается iOS. + ### Оффлайн режим 1. Откройте вкладку "Настройки" @@ -130,6 +146,7 @@ | `APIClient.swift` | HTTP клиент с CSRF и аутентификацией | | `Settings.swift` | Система настроек с JSON сериализацией | | `Cache.swift` | Многоуровневое кэширование с TTL | +| `LiveActivityManager.swift` | Управление Live Activity и тикером | ### UI компоненты @@ -144,6 +161,15 @@ | `FileView.swift` | Просмотр файлов с полноэкранным режимом | | `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` | Обработка крашей | | `ImageLoader.swift` | Асинхронная загрузка изображений | | `NetworkMonitor.swift` | Монитор сети и принудительный оффлайн | +| `AppGroup.swift` | Общие UserDefaults для app ↔ widget | ## API интеграция @@ -211,6 +238,7 @@ P.S. Костыль через Payload/MobileMkch.app в зипе и переи ## Версии и обновления ### Версия 2.1.1-ios-alpha (Текущая) +- Live Activity (BETA): тумблер в треде, тикер в "Настройки -> Уведомления" - Добавлен оффлайн режим (тумблер в настройках) - Дисковый кэш и фолбэк на сохранённые данные - Оффлайн-баннеры в списках и деталях @@ -225,7 +253,7 @@ P.S. Костыль через Payload/MobileMkch.app в зипе и переи p.s. я не уверен работает ли оно, но оно работает по крайней мере на моем устройстве -### Версия 2.0.0-ios-alpha (Текущая) +### Версия 2.0.0-ios-alpha - Полная переработка UI на SwiftUI - Система избранного с локальным сохранением - Push-уведомления с фоновым обновлением