MobileMkch-iOS/FavoritesWidget/FavoritesWidget.swift
2025-08-08 14:43:55 +03:00

300 lines
10 KiB
Swift

//
// FavoritesWidget.swift
// FavoritesWidget
//
// Created by Platon on 08.08.2025.
//
import WidgetKit
import SwiftUI
import ActivityKit
private let appGroupId = "group.mobilemkch"
struct FavoriteThreadWidget: Identifiable, Codable {
let id: Int
let title: String
let board: String
let boardDescription: String
let addedDate: Date
}
struct ThreadDTO: Codable, Identifiable {
let id: Int
let title: String
let board: String
}
struct SimpleEntry: TimelineEntry {
let date: Date
let favorites: [FavoriteThreadWidget]
let offline: Bool
}
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), favorites: [
FavoriteThreadWidget(id: 1, title: "Загрузка...", board: "b", boardDescription: "", addedDate: Date())
], offline: false)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let favs = loadFavorites()
if favs.isEmpty {
Task {
let networkFavs = await loadFromNetwork(board: "b")
completion(SimpleEntry(date: Date(), favorites: networkFavs, offline: false))
}
} else {
completion(SimpleEntry(date: Date(), favorites: favs, offline: loadOffline()))
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
Task {
var favorites = loadFavorites()
var offline = loadOffline()
if favorites.isEmpty {
favorites = await loadFromNetwork(board: "b")
offline = false
}
let entry = SimpleEntry(date: Date(), favorites: favorites, offline: offline)
let refresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900)
let timeline = Timeline(entries: [entry], policy: .after(refresh))
completion(timeline)
}
}
private func loadFavorites() -> [FavoriteThreadWidget] {
guard let defaults = UserDefaults(suiteName: appGroupId),
let data = defaults.data(forKey: "favoriteThreads"),
let items = try? JSONDecoder().decode([FavoriteThreadWidget].self, from: data) else {
return []
}
return items
}
private func loadOffline() -> Bool {
let defaults = UserDefaults(suiteName: appGroupId)
return defaults?.bool(forKey: "offlineMode") ?? false
}
private func sample() -> [FavoriteThreadWidget] {
[]
}
private func loadFromNetwork(board: String) async -> [FavoriteThreadWidget] {
guard let url = URL(string: "https://mkch.pooziqo.xyz/api/board/\(board)") else { return [] }
var req = URLRequest(url: url)
req.setValue("MobileMkch/2.1.1-widget", forHTTPHeaderField: "User-Agent")
do {
let (data, _) = try await URLSession.shared.data(for: req)
let threads = try JSONDecoder().decode([ThreadDTO].self, from: data)
return Array(threads.prefix(3)).map { t in
FavoriteThreadWidget(id: t.id, title: t.title, board: t.board, boardDescription: "", addedDate: Date())
}
} catch {
return []
}
}
}
struct FavoritesWidgetEntryView : View {
var entry: Provider.Entry
@Environment(\.widgetFamily) private var family
var body: some View {
VStack(alignment: .leading, spacing: spacing) {
header
content
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(padding)
.background(
Group {
if #available(iOS 17.0, *) {
Color.clear
.containerBackground(for: .widget) {
Color.clear
}
} else {
Color.clear
}
}
)
}
private var spacing: CGFloat { family == .systemSmall ? 4 : 6 }
private var padding: CGFloat { family == .systemSmall ? 8 : 12 }
private var titleFont: Font { family == .systemSmall ? .caption2 : .caption }
private var headerFont: Font { family == .systemSmall ? .footnote : .headline }
private var maxItems: Int { family == .systemSmall ? 1 : 3 }
@ViewBuilder private var header: some View {
HStack(spacing: 6) {
Text("Избранное")
.font(headerFont)
.foregroundColor(.primary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Spacer()
if entry.offline {
Image(systemName: "wifi.slash").foregroundColor(.orange)
}
}
}
@ViewBuilder private var content: some View {
if entry.favorites.isEmpty {
Text("Пусто")
.foregroundColor(.secondary)
.font(titleFont)
} else {
ForEach(entry.favorites.prefix(maxItems)) { fav in
if family == .systemSmall {
HStack(spacing: 6) {
BoardTag(code: fav.board)
Text(fav.title)
.font(titleFont)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
} else {
VStack(alignment: .leading, spacing: 2) {
BoardTag(code: fav.board)
Text(fav.title)
.font(titleFont)
.foregroundColor(.primary)
.lineLimit(2)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
}
}
}
}
}
}
private struct BoardTag: View {
let code: String
var body: some View {
Text("/\(code)/")
.font(.caption2)
.foregroundColor(.blue)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
}
}
struct FavoritesWidget: Widget {
let kind: String = "FavoritesWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
FavoritesWidgetEntryView(entry: entry)
}
.configurationDisplayName("Избранное MobileMkch")
.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)
}
}