add live activity
This commit is contained in:
parent
013623290d
commit
f1864dc2ba
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
201
MobileMkch/LiveActivityManager.swift
Normal file
201
MobileMkch/LiveActivityManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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?
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
30
README.md
30
README.md
@ -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. Тикер:
|
||||||
|
- В Настройки -> Уведомления включите "Тикер случайных тредов"
|
||||||
|
- Выберите случайную борду или укажите код борды
|
||||||
|
- Задайте интервал (5–120 сек)
|
||||||
|
- Кнопки "Старт тикера" / "Стоп тикера"
|
||||||
|
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-уведомления с фоновым обновлением
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user