оффлайн + виджет
This commit is contained in:
parent
11fdf3186b
commit
013623290d
@ -8,11 +8,12 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import AppIntents
|
import AppIntents
|
||||||
|
|
||||||
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
struct ConfigurationAppIntent: WidgetConfigurationIntent, AppIntent {
|
||||||
static var title: LocalizedStringResource { "Configuration" }
|
static var title: LocalizedStringResource { "Конфигурация виджета" }
|
||||||
static var description: IntentDescription { "This is an example widget." }
|
static var description: IntentDescription { "Выберите доску для загрузки в случае отсутствия доступа к данным приложения." }
|
||||||
|
|
||||||
// An example configurable parameter.
|
@Parameter(title: "Код доски", default: "b")
|
||||||
@Parameter(title: "Favorite Emoji", default: "😃")
|
var boardCode: String
|
||||||
var favoriteEmoji: String
|
|
||||||
|
static var openAppWhenRun: Bool { true }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,50 +8,162 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct Provider: AppIntentTimelineProvider {
|
private let appGroupId = "group.mobilemkch"
|
||||||
func placeholder(in context: Context) -> SimpleEntry {
|
|
||||||
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
struct FavoriteThreadWidget: Identifiable, Codable {
|
||||||
SimpleEntry(date: Date(), configuration: configuration)
|
let id: Int
|
||||||
}
|
let title: String
|
||||||
|
let board: String
|
||||||
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
let boardDescription: String
|
||||||
var entries: [SimpleEntry] = []
|
let addedDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
|
struct ThreadDTO: Codable, Identifiable {
|
||||||
let currentDate = Date()
|
let id: Int
|
||||||
for hourOffset in 0 ..< 5 {
|
let title: String
|
||||||
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
|
let board: String
|
||||||
let entry = SimpleEntry(date: entryDate, configuration: configuration)
|
|
||||||
entries.append(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// func relevances() async -> WidgetRelevances<ConfigurationAppIntent> {
|
|
||||||
// // Generate a list containing the contexts this widget is relevant in.
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SimpleEntry: TimelineEntry {
|
struct SimpleEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
let configuration: ConfigurationAppIntent
|
let favorites: [FavoriteThreadWidget]
|
||||||
|
let offline: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Provider: AppIntentTimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> SimpleEntry {
|
||||||
|
SimpleEntry(date: Date(), favorites: sample(), offline: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
||||||
|
let favs = loadFavorites()
|
||||||
|
if favs.isEmpty {
|
||||||
|
return SimpleEntry(date: Date(), favorites: await loadFromNetwork(board: configuration.boardCode), offline: false)
|
||||||
|
}
|
||||||
|
return SimpleEntry(date: Date(), favorites: favs, offline: loadOffline())
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
||||||
|
var favorites = loadFavorites()
|
||||||
|
var offline = loadOffline()
|
||||||
|
if favorites.isEmpty {
|
||||||
|
favorites = await loadFromNetwork(board: configuration.boardCode)
|
||||||
|
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)
|
||||||
|
return Timeline(entries: [entry], policy: .after(refresh))
|
||||||
|
}
|
||||||
|
|
||||||
|
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] {
|
||||||
|
[FavoriteThreadWidget(id: 1, title: "Пример треда", board: "b", boardDescription: "Болталка", addedDate: Date())]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
struct FavoritesWidgetEntryView : View {
|
||||||
var entry: Provider.Entry
|
var entry: Provider.Entry
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(alignment: .leading, spacing: spacing) {
|
||||||
Text("Time:")
|
header
|
||||||
Text(entry.date, style: .time)
|
content
|
||||||
|
Spacer(minLength: 0)
|
||||||
Text("Favorite Emoji:")
|
|
||||||
Text(entry.configuration.favoriteEmoji)
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(padding)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.containerBackground(.background, for: .widget)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.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)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
BoardTag(code: fav.board)
|
||||||
|
Text(fav.title)
|
||||||
|
.font(titleFont)
|
||||||
|
.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,28 +173,8 @@ struct FavoritesWidget: Widget {
|
|||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
||||||
FavoritesWidgetEntryView(entry: entry)
|
FavoritesWidgetEntryView(entry: entry)
|
||||||
.containerBackground(.fill.tertiary, for: .widget)
|
|
||||||
}
|
}
|
||||||
|
.configurationDisplayName("Избранное MobileMkch")
|
||||||
|
.description("Показывает избранные треды или топ по выбранной доске.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConfigurationAppIntent {
|
|
||||||
fileprivate static var smiley: ConfigurationAppIntent {
|
|
||||||
let intent = ConfigurationAppIntent()
|
|
||||||
intent.favoriteEmoji = "😀"
|
|
||||||
return intent
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate static var starEyes: ConfigurationAppIntent {
|
|
||||||
let intent = ConfigurationAppIntent()
|
|
||||||
intent.favoriteEmoji = "🤩"
|
|
||||||
return intent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(as: .systemSmall) {
|
|
||||||
FavoritesWidget()
|
|
||||||
} timeline: {
|
|
||||||
SimpleEntry(date: .now, configuration: .smiley)
|
|
||||||
SimpleEntry(date: .now, configuration: .starEyes)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -12,7 +12,5 @@ import SwiftUI
|
|||||||
struct FavoritesWidgetBundle: WidgetBundle {
|
struct FavoritesWidgetBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
FavoritesWidget()
|
FavoritesWidget()
|
||||||
FavoritesWidgetControl()
|
|
||||||
FavoritesWidgetLiveActivity()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
//
|
|
||||||
// FavoritesWidgetControl.swift
|
|
||||||
// FavoritesWidget
|
|
||||||
//
|
|
||||||
// Created by Platon on 08.08.2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppIntents
|
|
||||||
import SwiftUI
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
struct FavoritesWidgetControl: ControlWidget {
|
|
||||||
static let kind: String = "com.mkch.MobileMkch.FavoritesWidget"
|
|
||||||
|
|
||||||
var body: some ControlWidgetConfiguration {
|
|
||||||
AppIntentControlConfiguration(
|
|
||||||
kind: Self.kind,
|
|
||||||
provider: Provider()
|
|
||||||
) { value in
|
|
||||||
ControlWidgetToggle(
|
|
||||||
"Start Timer",
|
|
||||||
isOn: value.isRunning,
|
|
||||||
action: StartTimerIntent(value.name)
|
|
||||||
) { isRunning in
|
|
||||||
Label(isRunning ? "On" : "Off", systemImage: "timer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.displayName("Timer")
|
|
||||||
.description("A an example control that runs a timer.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FavoritesWidgetControl {
|
|
||||||
struct Value {
|
|
||||||
var isRunning: Bool
|
|
||||||
var name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Provider: AppIntentControlValueProvider {
|
|
||||||
func previewValue(configuration: TimerConfiguration) -> Value {
|
|
||||||
FavoritesWidgetControl.Value(isRunning: false, name: configuration.timerName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentValue(configuration: TimerConfiguration) async throws -> Value {
|
|
||||||
let isRunning = true // Check if the timer is running
|
|
||||||
return FavoritesWidgetControl.Value(isRunning: isRunning, name: configuration.timerName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TimerConfiguration: ControlConfigurationIntent {
|
|
||||||
static let title: LocalizedStringResource = "Timer Name Configuration"
|
|
||||||
|
|
||||||
@Parameter(title: "Timer Name", default: "Timer")
|
|
||||||
var timerName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StartTimerIntent: SetValueIntent {
|
|
||||||
static let title: LocalizedStringResource = "Start a timer"
|
|
||||||
|
|
||||||
@Parameter(title: "Timer Name")
|
|
||||||
var name: String
|
|
||||||
|
|
||||||
@Parameter(title: "Timer is running")
|
|
||||||
var value: Bool
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
init(_ name: String) {
|
|
||||||
self.name = name
|
|
||||||
}
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult {
|
|
||||||
// Start the timer…
|
|
||||||
return .result()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
//
|
|
||||||
// FavoritesWidgetLiveActivity.swift
|
|
||||||
// FavoritesWidget
|
|
||||||
//
|
|
||||||
// Created by Platon on 08.08.2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import ActivityKit
|
|
||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct FavoritesWidgetAttributes: ActivityAttributes {
|
|
||||||
public struct ContentState: Codable, Hashable {
|
|
||||||
// Dynamic stateful properties about your activity go here!
|
|
||||||
var emoji: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fixed non-changing properties about your activity go here!
|
|
||||||
var name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FavoritesWidgetLiveActivity: Widget {
|
|
||||||
var body: some WidgetConfiguration {
|
|
||||||
ActivityConfiguration(for: FavoritesWidgetAttributes.self) { context in
|
|
||||||
// Lock screen/banner UI goes here
|
|
||||||
VStack {
|
|
||||||
Text("Hello \(context.state.emoji)")
|
|
||||||
}
|
|
||||||
.activityBackgroundTint(Color.cyan)
|
|
||||||
.activitySystemActionForegroundColor(Color.black)
|
|
||||||
|
|
||||||
} dynamicIsland: { context in
|
|
||||||
DynamicIsland {
|
|
||||||
// Expanded UI goes here. Compose the expanded UI through
|
|
||||||
// various regions, like leading/trailing/center/bottom
|
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
|
||||||
Text("Leading")
|
|
||||||
}
|
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
|
||||||
Text("Trailing")
|
|
||||||
}
|
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
|
||||||
Text("Bottom \(context.state.emoji)")
|
|
||||||
// more content
|
|
||||||
}
|
|
||||||
} compactLeading: {
|
|
||||||
Text("L")
|
|
||||||
} compactTrailing: {
|
|
||||||
Text("T \(context.state.emoji)")
|
|
||||||
} minimal: {
|
|
||||||
Text(context.state.emoji)
|
|
||||||
}
|
|
||||||
.widgetURL(URL(string: "http://www.apple.com"))
|
|
||||||
.keylineTint(Color.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FavoritesWidgetAttributes {
|
|
||||||
fileprivate static var preview: FavoritesWidgetAttributes {
|
|
||||||
FavoritesWidgetAttributes(name: "World")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FavoritesWidgetAttributes.ContentState {
|
|
||||||
fileprivate static var smiley: FavoritesWidgetAttributes.ContentState {
|
|
||||||
FavoritesWidgetAttributes.ContentState(emoji: "😀")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate static var starEyes: FavoritesWidgetAttributes.ContentState {
|
|
||||||
FavoritesWidgetAttributes.ContentState(emoji: "🤩")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Notification", as: .content, using: FavoritesWidgetAttributes.preview) {
|
|
||||||
FavoritesWidgetLiveActivity()
|
|
||||||
} contentStates: {
|
|
||||||
FavoritesWidgetAttributes.ContentState.smiley
|
|
||||||
FavoritesWidgetAttributes.ContentState.starEyes
|
|
||||||
}
|
|
||||||
@ -1,5 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict/>
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.mobilemkch</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -6,11 +6,52 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
1D2B9E3A2E4603660097A722 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D2B9E392E4603660097A722 /* WidgetKit.framework */; };
|
||||||
|
1D2B9E3C2E4603660097A722 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D2B9E3B2E4603660097A722 /* SwiftUI.framework */; };
|
||||||
|
1D2B9E4D2E4603660097A722 /* FavoritesWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
1D2B9E4B2E4603660097A722 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 1D8926E12E43CE4C00C5590A /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 1D2B9E362E4603660097A722;
|
||||||
|
remoteInfo = FavoritesWidgetExtension;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
1D2B9E4D2E4603660097A722 /* FavoritesWidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FavoritesWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
1D2B9E392E4603660097A722 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
|
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
|
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FavoritesWidgetExtension.entitlements; sourceTree = "<group>"; };
|
||||||
1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = 1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */;
|
||||||
|
};
|
||||||
1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */ = {
|
1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
@ -21,6 +62,14 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
1D2B9E3D2E4603660097A722 /* FavoritesWidget */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */,
|
||||||
|
);
|
||||||
|
path = FavoritesWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */ = {
|
1D8926EB2E43CE4C00C5590A /* MobileMkch */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
@ -32,6 +81,15 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
1D2B9E342E4603660097A722 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
1D2B9E3C2E4603660097A722 /* SwiftUI.framework in Frameworks */,
|
||||||
|
1D2B9E3A2E4603660097A722 /* WidgetKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
1D8926E62E43CE4C00C5590A /* Frameworks */ = {
|
1D8926E62E43CE4C00C5590A /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -42,10 +100,22 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
1D2B9E382E4603660097A722 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1D2B9E392E4603660097A722 /* WidgetKit.framework */,
|
||||||
|
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
1D8926E02E43CE4C00C5590A = {
|
1D8926E02E43CE4C00C5590A = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */,
|
||||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
||||||
|
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
|
||||||
|
1D2B9E382E4603660097A722 /* Frameworks */,
|
||||||
1D8926EA2E43CE4C00C5590A /* Products */,
|
1D8926EA2E43CE4C00C5590A /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -54,6 +124,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
1D8926E92E43CE4C00C5590A /* MobileMkch.app */,
|
1D8926E92E43CE4C00C5590A /* MobileMkch.app */,
|
||||||
|
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -61,6 +132,28 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 1D2B9E4E2E4603660097A722 /* Build configuration list for PBXNativeTarget "FavoritesWidgetExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
1D2B9E332E4603660097A722 /* Sources */,
|
||||||
|
1D2B9E342E4603660097A722 /* Frameworks */,
|
||||||
|
1D2B9E352E4603660097A722 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
|
||||||
|
);
|
||||||
|
name = FavoritesWidgetExtension;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = FavoritesWidgetExtension;
|
||||||
|
productReference = 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
1D8926E82E43CE4C00C5590A /* MobileMkch */ = {
|
1D8926E82E43CE4C00C5590A /* MobileMkch */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */;
|
buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */;
|
||||||
@ -68,10 +161,12 @@
|
|||||||
1D8926E52E43CE4C00C5590A /* Sources */,
|
1D8926E52E43CE4C00C5590A /* Sources */,
|
||||||
1D8926E62E43CE4C00C5590A /* Frameworks */,
|
1D8926E62E43CE4C00C5590A /* Frameworks */,
|
||||||
1D8926E72E43CE4C00C5590A /* Resources */,
|
1D8926E72E43CE4C00C5590A /* Resources */,
|
||||||
|
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
||||||
@ -93,6 +188,9 @@
|
|||||||
LastSwiftUpdateCheck = 1620;
|
LastSwiftUpdateCheck = 1620;
|
||||||
LastUpgradeCheck = 1620;
|
LastUpgradeCheck = 1620;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
1D2B9E362E4603660097A722 = {
|
||||||
|
CreatedOnToolsVersion = 16.2;
|
||||||
|
};
|
||||||
1D8926E82E43CE4C00C5590A = {
|
1D8926E82E43CE4C00C5590A = {
|
||||||
CreatedOnToolsVersion = 16.2;
|
CreatedOnToolsVersion = 16.2;
|
||||||
};
|
};
|
||||||
@ -113,11 +211,19 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
1D8926E82E43CE4C00C5590A /* MobileMkch */,
|
1D8926E82E43CE4C00C5590A /* MobileMkch */,
|
||||||
|
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
1D2B9E352E4603660097A722 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
1D8926E72E43CE4C00C5590A /* Resources */ = {
|
1D8926E72E43CE4C00C5590A /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -128,6 +234,13 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
1D2B9E332E4603660097A722 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
1D8926E52E43CE4C00C5590A /* Sources */ = {
|
1D8926E52E43CE4C00C5590A /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -137,7 +250,71 @@
|
|||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */;
|
||||||
|
targetProxy = 1D2B9E4B2E4603660097A722 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
1D2B9E4F2E4603660097A722 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = FavoritesWidgetExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 9U88M9D595;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = FavoritesWidget/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = FavoritesWidget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch.FavoritesWidget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1D2B9E502E4603660097A722 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = FavoritesWidgetExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 9U88M9D595;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = FavoritesWidget/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = FavoritesWidget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch.FavoritesWidget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
1D8926F52E43CE4D00C5590A /* Debug */ = {
|
1D8926F52E43CE4D00C5590A /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@ -262,6 +439,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = MobileMkch/MobileMkch.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "2-alpha";
|
CURRENT_PROJECT_VERSION = "2-alpha";
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
||||||
@ -298,6 +476,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = MobileMkch/MobileMkch.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "2-alpha";
|
CURRENT_PROJECT_VERSION = "2-alpha";
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
||||||
@ -332,6 +511,15 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
1D2B9E4E2E4603660097A722 /* Build configuration list for PBXNativeTarget "FavoritesWidgetExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1D2B9E4F2E4603660097A722 /* Debug */,
|
||||||
|
1D2B9E502E4603660097A722 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */ = {
|
1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@ -4,6 +4,11 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>FavoritesWidgetExtension.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
<key>MobileMkch.xcscheme_^#shared#^_</key>
|
<key>MobileMkch.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
class APIClient: ObservableObject {
|
class APIClient: ObservableObject {
|
||||||
private let baseURL = "https://mkch.pooziqo.xyz"
|
private let baseURL = "https://mkch.pooziqo.xyz"
|
||||||
@ -152,6 +153,14 @@ class APIClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) {
|
func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) {
|
||||||
|
if NetworkMonitor.shared.offlineEffective {
|
||||||
|
if let cached = Cache.shared.getBoardsStale(), !cached.isEmpty {
|
||||||
|
completion(.success(cached))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Оффлайн: нет сохранённых данных", code: 0)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if let cachedBoards = Cache.shared.getBoards() {
|
if let cachedBoards = Cache.shared.getBoards() {
|
||||||
completion(.success(cachedBoards))
|
completion(.success(cachedBoards))
|
||||||
return
|
return
|
||||||
@ -164,18 +173,30 @@ class APIClient: ObservableObject {
|
|||||||
session.dataTask(with: request) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
httpResponse.statusCode == 200 else {
|
httpResponse.statusCode == 200 else {
|
||||||
completion(.failure(APIError(message: "Ошибка получения досок", code: 0)))
|
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Ошибка получения досок", code: 0)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +212,14 @@ class APIClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) {
|
func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) {
|
||||||
|
if NetworkMonitor.shared.offlineEffective {
|
||||||
|
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Оффлайн: нет сохранённых тредов", code: 0)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) {
|
if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) {
|
||||||
completion(.success(cachedThreads))
|
completion(.success(cachedThreads))
|
||||||
return
|
return
|
||||||
@ -203,18 +232,30 @@ class APIClient: ObservableObject {
|
|||||||
session.dataTask(with: request) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
httpResponse.statusCode == 200 else {
|
httpResponse.statusCode == 200 else {
|
||||||
completion(.failure(APIError(message: "Ошибка получения тредов", code: 0)))
|
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Ошибка получения тредов", code: 0)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,6 +271,14 @@ class APIClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) {
|
func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) {
|
||||||
|
if NetworkMonitor.shared.offlineEffective {
|
||||||
|
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Оффлайн: нет сохранённого треда", code: 0)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) {
|
if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) {
|
||||||
completion(.success(cachedThread))
|
completion(.success(cachedThread))
|
||||||
return
|
return
|
||||||
@ -242,18 +291,30 @@ class APIClient: ObservableObject {
|
|||||||
session.dataTask(with: request) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
httpResponse.statusCode == 200 else {
|
httpResponse.statusCode == 200 else {
|
||||||
completion(.failure(APIError(message: "Ошибка получения треда", code: 0)))
|
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Ошибка получения треда", code: 0)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,6 +330,14 @@ class APIClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) {
|
func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) {
|
||||||
|
if NetworkMonitor.shared.offlineEffective {
|
||||||
|
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Оффлайн: нет сохранённых комментариев", code: 0)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if let cachedComments = Cache.shared.getComments(forThreadId: threadId) {
|
if let cachedComments = Cache.shared.getComments(forThreadId: threadId) {
|
||||||
completion(.success(cachedComments))
|
completion(.success(cachedComments))
|
||||||
return
|
return
|
||||||
@ -281,18 +350,30 @@ class APIClient: ObservableObject {
|
|||||||
session.dataTask(with: request) { data, response, error in
|
session.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
completion(.failure(error))
|
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
httpResponse.statusCode == 200 else {
|
httpResponse.statusCode == 200 else {
|
||||||
completion(.failure(APIError(message: "Ошибка получения комментариев", code: 0)))
|
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Ошибка получения комментариев", code: 0)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
|
||||||
|
completion(.success(stale))
|
||||||
|
} else {
|
||||||
|
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
MobileMkch/AppGroup.swift
Normal file
16
MobileMkch/AppGroup.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppGroup {
|
||||||
|
static let identifier = "group.mobilemkch"
|
||||||
|
static var defaults: UserDefaults? { UserDefaults(suiteName: identifier) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FavoriteThreadWidget: Identifiable, Codable {
|
||||||
|
let id: Int
|
||||||
|
let title: String
|
||||||
|
let board: String
|
||||||
|
let boardDescription: String
|
||||||
|
let addedDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -6,12 +6,10 @@ class BackgroundTaskManager {
|
|||||||
static let shared = BackgroundTaskManager()
|
static let shared = BackgroundTaskManager()
|
||||||
|
|
||||||
private var backgroundTaskIdentifier: String {
|
private var backgroundTaskIdentifier: String {
|
||||||
// Используем идентификатор из Info.plist (BGTaskSchedulerPermittedIdentifiers)
|
|
||||||
if let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String],
|
if let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String],
|
||||||
let first = identifiers.first {
|
let first = identifiers.first {
|
||||||
return first
|
return first
|
||||||
}
|
}
|
||||||
// Фоллбек на значение по умолчанию
|
|
||||||
return "com.mkch.MobileMkch.backgroundrefresh"
|
return "com.mkch.MobileMkch.backgroundrefresh"
|
||||||
}
|
}
|
||||||
private let notificationManager = NotificationManager.shared
|
private let notificationManager = NotificationManager.shared
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct BoardsView: View {
|
struct BoardsView: View {
|
||||||
@EnvironmentObject var settings: Settings
|
@EnvironmentObject var settings: Settings
|
||||||
@EnvironmentObject var apiClient: APIClient
|
@EnvironmentObject var apiClient: APIClient
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
@State private var boards: [Board] = []
|
@State private var boards: [Board] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@ -10,6 +11,15 @@ struct BoardsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
List {
|
List {
|
||||||
|
if networkMonitor.offlineEffective {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
if isLoading {
|
if isLoading {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
class Cache {
|
class Cache {
|
||||||
static let shared = Cache()
|
static let shared = Cache()
|
||||||
|
|
||||||
private var items: [String: CacheItem] = [:]
|
private var items: [String: CacheItem] = [:]
|
||||||
private let queue = DispatchQueue(label: "cache.queue", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "cache.queue", attributes: .concurrent)
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
startCleanupTimer()
|
startCleanupTimer()
|
||||||
@ -20,41 +22,57 @@ class Cache {
|
|||||||
ttl: ttl
|
ttl: ttl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
saveToDisk(key: key, data: encodedData, ttl: ttl)
|
||||||
} catch {
|
} catch {
|
||||||
print("Ошибка кодирования данных для кэша: \(error)")
|
print("Ошибка кодирования данных для кэша: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
|
func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
|
||||||
return queue.sync {
|
if let inMemory: T = queue.sync(execute: { () -> T? in
|
||||||
guard let item = items[key] else { return nil }
|
guard let item = items[key] else { return nil }
|
||||||
|
|
||||||
if Date().timeIntervalSince(item.timestamp) > item.ttl {
|
if Date().timeIntervalSince(item.timestamp) > item.ttl {
|
||||||
items.removeValue(forKey: key)
|
items.removeValue(forKey: key)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
do { return try JSONDecoder().decode(type, from: item.data) } catch { return nil }
|
||||||
let data = item.data
|
}) {
|
||||||
|
return inMemory
|
||||||
do {
|
|
||||||
return try JSONDecoder().decode(type, from: data)
|
|
||||||
} catch {
|
|
||||||
print("Ошибка декодирования данных из кэша: \(error)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
guard let diskItem = loadFromDisk(key: key) else { return nil }
|
||||||
|
if Date().timeIntervalSince(diskItem.timestamp) > diskItem.ttl {
|
||||||
|
delete(key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.items[key] = diskItem
|
||||||
|
}
|
||||||
|
do { return try JSONDecoder().decode(type, from: diskItem.data) } catch { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStale<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
|
||||||
|
if let item = queue.sync(execute: { items[key] }) {
|
||||||
|
return try? JSONDecoder().decode(type, from: item.data)
|
||||||
|
}
|
||||||
|
guard let diskItem = loadFromDisk(key: key) else { return nil }
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.items[key] = diskItem
|
||||||
|
}
|
||||||
|
return try? JSONDecoder().decode(type, from: diskItem.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ key: String) {
|
func delete(_ key: String) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.items.removeValue(forKey: key)
|
self.items.removeValue(forKey: key)
|
||||||
}
|
}
|
||||||
|
deleteFromDisk(key: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clear() {
|
func clear() {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.items.removeAll()
|
self.items.removeAll()
|
||||||
}
|
}
|
||||||
|
clearDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startCleanupTimer() {
|
private func startCleanupTimer() {
|
||||||
@ -73,6 +91,55 @@ class Cache {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cleanupDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cachesDirectory() -> URL {
|
||||||
|
fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileURL(forKey key: String) -> URL {
|
||||||
|
let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||||
|
return cachesDirectory().appendingPathComponent("Cache_\(hash).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveToDisk(key: String, data: Data, ttl: TimeInterval) {
|
||||||
|
let item = PersistedCacheItem(data: data, timestamp: Date(), ttl: ttl)
|
||||||
|
guard let encoded = try? JSONEncoder().encode(item) else { return }
|
||||||
|
let url = fileURL(forKey: key)
|
||||||
|
try? encoded.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFromDisk(key: String) -> CacheItem? {
|
||||||
|
let url = fileURL(forKey: key)
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
guard let persisted = try? JSONDecoder().decode(PersistedCacheItem.self, from: data) else { return nil }
|
||||||
|
return CacheItem(data: persisted.data, timestamp: persisted.timestamp, ttl: persisted.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteFromDisk(key: String) {
|
||||||
|
let url = fileURL(forKey: key)
|
||||||
|
try? fileManager.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearDisk() {
|
||||||
|
let dir = cachesDirectory()
|
||||||
|
let contents = (try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||||
|
for url in contents where url.lastPathComponent.hasPrefix("Cache_") && url.pathExtension == "json" {
|
||||||
|
try? fileManager.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupDisk() {
|
||||||
|
let dir = cachesDirectory()
|
||||||
|
let contents = (try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||||
|
for url in contents where url.lastPathComponent.hasPrefix("Cache_") && url.pathExtension == "json" {
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let persisted = try? JSONDecoder().decode(PersistedCacheItem.self, from: data) else { continue }
|
||||||
|
if Date().timeIntervalSince(persisted.timestamp) > persisted.ttl {
|
||||||
|
try? fileManager.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +149,12 @@ struct CacheItem {
|
|||||||
let ttl: TimeInterval
|
let ttl: TimeInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PersistedCacheItem: Codable {
|
||||||
|
let data: Data
|
||||||
|
let timestamp: Date
|
||||||
|
let ttl: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
extension Cache {
|
extension Cache {
|
||||||
func setBoards(_ boards: [Board]) {
|
func setBoards(_ boards: [Board]) {
|
||||||
set(boards, forKey: "boards", ttl: 600)
|
set(boards, forKey: "boards", ttl: 600)
|
||||||
@ -91,6 +164,10 @@ extension Cache {
|
|||||||
return get([Board].self, forKey: "boards")
|
return get([Board].self, forKey: "boards")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getBoardsStale() -> [Board]? {
|
||||||
|
return getStale([Board].self, forKey: "boards")
|
||||||
|
}
|
||||||
|
|
||||||
func setThreads(_ threads: [Thread], forBoard boardCode: String) {
|
func setThreads(_ threads: [Thread], forBoard boardCode: String) {
|
||||||
set(threads, forKey: "threads_\(boardCode)", ttl: 300)
|
set(threads, forKey: "threads_\(boardCode)", ttl: 300)
|
||||||
}
|
}
|
||||||
@ -99,6 +176,10 @@ extension Cache {
|
|||||||
return get([Thread].self, forKey: "threads_\(boardCode)")
|
return get([Thread].self, forKey: "threads_\(boardCode)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getThreadsStale(forBoard boardCode: String) -> [Thread]? {
|
||||||
|
return getStale([Thread].self, forKey: "threads_\(boardCode)")
|
||||||
|
}
|
||||||
|
|
||||||
func setThreadDetail(_ thread: ThreadDetail, forThreadId threadId: Int) {
|
func setThreadDetail(_ thread: ThreadDetail, forThreadId threadId: Int) {
|
||||||
set(thread, forKey: "thread_detail_\(threadId)", ttl: 180)
|
set(thread, forKey: "thread_detail_\(threadId)", ttl: 180)
|
||||||
}
|
}
|
||||||
@ -107,6 +188,10 @@ extension Cache {
|
|||||||
return get(ThreadDetail.self, forKey: "thread_detail_\(threadId)")
|
return get(ThreadDetail.self, forKey: "thread_detail_\(threadId)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getThreadDetailStale(forThreadId threadId: Int) -> ThreadDetail? {
|
||||||
|
return getStale(ThreadDetail.self, forKey: "thread_detail_\(threadId)")
|
||||||
|
}
|
||||||
|
|
||||||
func setComments(_ comments: [Comment], forThreadId threadId: Int) {
|
func setComments(_ comments: [Comment], forThreadId threadId: Int) {
|
||||||
set(comments, forKey: "comments_\(threadId)", ttl: 180)
|
set(comments, forKey: "comments_\(threadId)", ttl: 180)
|
||||||
}
|
}
|
||||||
@ -114,4 +199,8 @@ extension Cache {
|
|||||||
func getComments(forThreadId threadId: Int) -> [Comment]? {
|
func getComments(forThreadId threadId: Int) -> [Comment]? {
|
||||||
return get([Comment].self, forKey: "comments_\(threadId)")
|
return get([Comment].self, forKey: "comments_\(threadId)")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
func getCommentsStale(forThreadId threadId: Int) -> [Comment]? {
|
||||||
|
return getStale([Comment].self, forKey: "comments_\(threadId)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>background-processing</string>
|
<string>background-processing</string>
|
||||||
<string>background-fetch</string>
|
<string>background-fetch</string>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array/>
|
<array>
|
||||||
|
<string>group.mobilemkch</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct MobileMkchApp: App {
|
|||||||
@StateObject private var apiClient = APIClient()
|
@StateObject private var apiClient = APIClient()
|
||||||
@StateObject private var crashHandler = CrashHandler.shared
|
@StateObject private var crashHandler = CrashHandler.shared
|
||||||
@StateObject private var notificationManager = NotificationManager.shared
|
@StateObject private var notificationManager = NotificationManager.shared
|
||||||
|
@StateObject private var networkMonitor = NetworkMonitor.shared
|
||||||
|
|
||||||
private func setupBackgroundTasks() {
|
private func setupBackgroundTasks() {
|
||||||
if let bundleIdentifier = Bundle.main.bundleIdentifier {
|
if let bundleIdentifier = Bundle.main.bundleIdentifier {
|
||||||
@ -52,6 +53,7 @@ struct MobileMkchApp: App {
|
|||||||
.environmentObject(settings)
|
.environmentObject(settings)
|
||||||
.environmentObject(apiClient)
|
.environmentObject(apiClient)
|
||||||
.environmentObject(notificationManager)
|
.environmentObject(notificationManager)
|
||||||
|
.environmentObject(networkMonitor)
|
||||||
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
|
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
MobileMkch/NetworkMonitor.swift
Normal file
24
MobileMkch/NetworkMonitor.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
class NetworkMonitor: ObservableObject {
|
||||||
|
static let shared = NetworkMonitor()
|
||||||
|
@Published private(set) var isConnected: Bool = true
|
||||||
|
@Published var forceOffline: Bool = UserDefaults.standard.bool(forKey: "ForceOffline") {
|
||||||
|
didSet { UserDefaults.standard.set(forceOffline, forKey: "ForceOffline") }
|
||||||
|
}
|
||||||
|
var offlineEffective: Bool { forceOffline || !isConnected }
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "network.monitor.queue")
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.isConnected = path.status == .satisfied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
class Settings: ObservableObject {
|
class Settings: ObservableObject {
|
||||||
@Published var theme: String = "dark"
|
@Published var theme: String = "dark"
|
||||||
@ -14,12 +15,14 @@ class Settings: ObservableObject {
|
|||||||
@Published var notificationsEnabled: Bool = false
|
@Published var notificationsEnabled: Bool = false
|
||||||
@Published var notificationInterval: Int = 300
|
@Published var notificationInterval: Int = 300
|
||||||
@Published var favoriteThreads: [FavoriteThread] = []
|
@Published var favoriteThreads: [FavoriteThread] = []
|
||||||
|
@Published var offlineMode: Bool = false
|
||||||
|
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let settingsKey = "MobileMkchSettings"
|
private let settingsKey = "MobileMkchSettings"
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
loadSettings()
|
loadSettings()
|
||||||
|
mirrorStateToAppGroup()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSettings() {
|
func loadSettings() {
|
||||||
@ -38,7 +41,9 @@ class Settings: ObservableObject {
|
|||||||
self.notificationsEnabled = settings.notificationsEnabled
|
self.notificationsEnabled = settings.notificationsEnabled
|
||||||
self.notificationInterval = settings.notificationInterval
|
self.notificationInterval = settings.notificationInterval
|
||||||
self.favoriteThreads = settings.favoriteThreads
|
self.favoriteThreads = settings.favoriteThreads
|
||||||
|
self.offlineMode = settings.offlineMode ?? false
|
||||||
}
|
}
|
||||||
|
mirrorStateToAppGroup()
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSettings() {
|
func saveSettings() {
|
||||||
@ -56,11 +61,15 @@ class Settings: ObservableObject {
|
|||||||
notificationsEnabled: notificationsEnabled,
|
notificationsEnabled: notificationsEnabled,
|
||||||
notificationInterval: notificationInterval,
|
notificationInterval: notificationInterval,
|
||||||
favoriteThreads: favoriteThreads
|
favoriteThreads: favoriteThreads
|
||||||
|
,
|
||||||
|
offlineMode: offlineMode
|
||||||
)
|
)
|
||||||
|
|
||||||
if let data = try? JSONEncoder().encode(settingsData) {
|
if let data = try? JSONEncoder().encode(settingsData) {
|
||||||
userDefaults.set(data, forKey: settingsKey)
|
userDefaults.set(data, forKey: settingsKey)
|
||||||
}
|
}
|
||||||
|
mirrorStateToAppGroup()
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetSettings() {
|
func resetSettings() {
|
||||||
@ -77,6 +86,7 @@ class Settings: ObservableObject {
|
|||||||
notificationsEnabled = false
|
notificationsEnabled = false
|
||||||
notificationInterval = 300
|
notificationInterval = 300
|
||||||
favoriteThreads = []
|
favoriteThreads = []
|
||||||
|
offlineMode = false
|
||||||
saveSettings()
|
saveSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +110,17 @@ class Settings: ObservableObject {
|
|||||||
func isFavorite(_ threadId: Int, boardCode: String) -> Bool {
|
func isFavorite(_ threadId: Int, boardCode: String) -> Bool {
|
||||||
return favoriteThreads.contains { $0.id == threadId && $0.board == boardCode }
|
return favoriteThreads.contains { $0.id == threadId && $0.board == boardCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func mirrorStateToAppGroup() {
|
||||||
|
guard let shared = AppGroup.defaults else { return }
|
||||||
|
let mapped = favoriteThreads.map { FavoriteThreadWidget(id: $0.id, title: $0.title, board: $0.board, boardDescription: $0.boardDescription, addedDate: $0.addedDate) }
|
||||||
|
if let encodedFavorites = try? JSONEncoder().encode(mapped) {
|
||||||
|
shared.set(encodedFavorites, forKey: "favoriteThreads")
|
||||||
|
}
|
||||||
|
shared.set(offlineMode, forKey: "offlineMode")
|
||||||
|
shared.set(lastBoard, forKey: "lastBoard")
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsData: Codable {
|
struct SettingsData: Codable {
|
||||||
@ -116,4 +137,5 @@ struct SettingsData: Codable {
|
|||||||
let notificationsEnabled: Bool
|
let notificationsEnabled: Bool
|
||||||
let notificationInterval: Int
|
let notificationInterval: Int
|
||||||
let favoriteThreads: [FavoriteThread]
|
let favoriteThreads: [FavoriteThread]
|
||||||
|
let offlineMode: Bool?
|
||||||
}
|
}
|
||||||
@ -5,6 +5,7 @@ import Darwin
|
|||||||
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
|
||||||
|
|
||||||
@State private var showingAbout = false
|
@State private var showingAbout = false
|
||||||
@State private var showingInfo = false
|
@State private var showingInfo = false
|
||||||
@ -110,6 +111,26 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Оффлайн режим") {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.frame(width: 24)
|
||||||
|
Toggle("Принудительно оффлайн", isOn: $settings.offlineMode)
|
||||||
|
}
|
||||||
|
.onChange(of: settings.offlineMode) { newValue in
|
||||||
|
if networkMonitor.forceOffline != newValue {
|
||||||
|
networkMonitor.forceOffline = newValue
|
||||||
|
settings.saveSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(networkMonitor.offlineEffective ? "Сейчас оффлайн: показываем кэш" : "Онлайн: будут загружаться свежие данные")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("Аутентификация") {
|
Section("Аутентификация") {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "lock.shield")
|
Image(systemName: "lock.shield")
|
||||||
|
|||||||
@ -5,6 +5,7 @@ struct ThreadDetailView: View {
|
|||||||
let thread: Thread
|
let thread: Thread
|
||||||
@EnvironmentObject var settings: Settings
|
@EnvironmentObject var settings: Settings
|
||||||
@EnvironmentObject var apiClient: APIClient
|
@EnvironmentObject var apiClient: APIClient
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
@State private var threadDetail: ThreadDetail?
|
@State private var threadDetail: ThreadDetail?
|
||||||
@State private var comments: [Comment] = []
|
@State private var comments: [Comment] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@ -14,6 +15,15 @@ struct ThreadDetailView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if networkMonitor.offlineEffective {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
if isLoading {
|
if isLoading {
|
||||||
VStack {
|
VStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
|||||||
@ -5,6 +5,7 @@ struct ThreadsView: View {
|
|||||||
@EnvironmentObject var settings: Settings
|
@EnvironmentObject var settings: Settings
|
||||||
@EnvironmentObject var apiClient: APIClient
|
@EnvironmentObject var apiClient: APIClient
|
||||||
@EnvironmentObject var notificationManager: NotificationManager
|
@EnvironmentObject var notificationManager: NotificationManager
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
@State private var threads: [Thread] = []
|
@State private var threads: [Thread] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@ -21,6 +22,15 @@ struct ThreadsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
if networkMonitor.offlineEffective {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
if isLoading {
|
if isLoading {
|
||||||
VStack {
|
VStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
|||||||
25
README.md
25
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Нативный iOS клиент для борды mkch.pooziqo.xyz
|
Нативный iOS клиент для борды mkch.pooziqo.xyz
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
@ -26,6 +26,7 @@
|
|||||||
- **Полноэкранный просмотр изображений** с зумом и жестами
|
- **Полноэкранный просмотр изображений** с зумом и жестами
|
||||||
- **Компактный/обычный режим** отображения для экономии места
|
- **Компактный/обычный режим** отображения для экономии места
|
||||||
- **Пагинация** с настраиваемым размером страницы (5-20 элементов)
|
- **Пагинация** с настраиваемым размером страницы (5-20 элементов)
|
||||||
|
- **Оффлайн режим**: просмотр сохранённых данных без сети
|
||||||
|
|
||||||
### Система избранного
|
### Система избранного
|
||||||
- **Добавление тредов в избранное** одним тапом
|
- **Добавление тредов в избранное** одним тапом
|
||||||
@ -41,6 +42,7 @@
|
|||||||
- **Размер страницы**: от 5 до 20 элементов
|
- **Размер страницы**: от 5 до 20 элементов
|
||||||
- **Пагинация**: включение/отключение
|
- **Пагинация**: включение/отключение
|
||||||
- **Нестабильные функции** с предупреждением
|
- **Нестабильные функции** с предупреждением
|
||||||
|
- **Принудительно оффлайн**: работа только с кэшем
|
||||||
|
|
||||||
### Полная поддержка постинга
|
### Полная поддержка постинга
|
||||||
- **Аутентификация по ключу** с тестированием подключения
|
- **Аутентификация по ключу** с тестированием подключения
|
||||||
@ -68,6 +70,7 @@
|
|||||||
- **Оптимизация батареи** для фоновых задач
|
- **Оптимизация батареи** для фоновых задач
|
||||||
- **Ленивая загрузка** контента
|
- **Ленивая загрузка** контента
|
||||||
- **Graceful error handling** с retry логикой
|
- **Graceful error handling** с retry логикой
|
||||||
|
- **Дисковый кэш** с фолбэком на устаревшие данные при оффлайне
|
||||||
|
|
||||||
### Дополнительные функции
|
### Дополнительные функции
|
||||||
- **Crash Handler** с детальной диагностикой
|
- **Crash Handler** с детальной диагностикой
|
||||||
@ -107,6 +110,14 @@
|
|||||||
5. **Подпишитесь на нужные доски** переключателями
|
5. **Подпишитесь на нужные доски** переключателями
|
||||||
6. **Протестируйте** кнопкой "Проверить новые треды сейчас"
|
6. **Протестируйте** кнопкой "Проверить новые треды сейчас"
|
||||||
|
|
||||||
|
### Оффлайн режим
|
||||||
|
|
||||||
|
1. Откройте вкладку "Настройки"
|
||||||
|
2. Включите тумблер "Принудительно оффлайн"
|
||||||
|
3. На экранах появится баннер "Оффлайн режим. Показаны сохранённые данные"
|
||||||
|
4. Загрузка из сети отключается, используются данные из кэша (если есть)
|
||||||
|
5. Отключите тумблер для возврата в онлайн
|
||||||
|
|
||||||
## Архитектура приложения
|
## Архитектура приложения
|
||||||
|
|
||||||
### Основные компоненты
|
### Основные компоненты
|
||||||
@ -141,6 +152,7 @@
|
|||||||
| `BackgroundTaskManager.swift` | Фоновое обновление |
|
| `BackgroundTaskManager.swift` | Фоновое обновление |
|
||||||
| `CrashHandler.swift` | Обработка крашей |
|
| `CrashHandler.swift` | Обработка крашей |
|
||||||
| `ImageLoader.swift` | Асинхронная загрузка изображений |
|
| `ImageLoader.swift` | Асинхронная загрузка изображений |
|
||||||
|
| `NetworkMonitor.swift` | Монитор сети и принудительный оффлайн |
|
||||||
|
|
||||||
## API интеграция
|
## API интеграция
|
||||||
|
|
||||||
@ -162,6 +174,7 @@
|
|||||||
- **Треды**: 5 минут (часто обновляются)
|
- **Треды**: 5 минут (часто обновляются)
|
||||||
- **Детали**: 3 минуты (могут изменяться)
|
- **Детали**: 3 минуты (могут изменяться)
|
||||||
- **Изображения**: NSCache с лимитами памяти
|
- **Изображения**: NSCache с лимитами памяти
|
||||||
|
- **Оффлайн фолбэк**: при ошибке сети или включённом оффлайне данные берутся из дискового кэша, если доступны
|
||||||
|
|
||||||
## Сборка проекта
|
## Сборка проекта
|
||||||
|
|
||||||
@ -197,7 +210,12 @@ P.S. Костыль через Payload/MobileMkch.app в зипе и переи
|
|||||||
|
|
||||||
## Версии и обновления
|
## Версии и обновления
|
||||||
|
|
||||||
### Версия 2.1.0-ios-alpha (Текущая)
|
### Версия 2.1.1-ios-alpha (Текущая)
|
||||||
|
- Добавлен оффлайн режим (тумблер в настройках)
|
||||||
|
- Дисковый кэш и фолбэк на сохранённые данные
|
||||||
|
- Оффлайн-баннеры в списках и деталях
|
||||||
|
|
||||||
|
### Версия 2.1.0-ios-alpha
|
||||||
- Добавлены push-уведомления
|
- Добавлены push-уведомления
|
||||||
- Добавлены фоновые задачи
|
- Добавлены фоновые задачи
|
||||||
- Добавлены уведомления о новых тредах
|
- Добавлены уведомления о новых тредах
|
||||||
@ -220,7 +238,6 @@ p.s. я не уверен работает ли оно, но оно работа
|
|||||||
|
|
||||||
### Планы развития
|
### Планы развития
|
||||||
- Поддержка загрузки файлов при постинге
|
- Поддержка загрузки файлов при постинге
|
||||||
- Офлайн режим чтения
|
|
||||||
- Поиск по тредам и комментариям
|
- Поиск по тредам и комментариям
|
||||||
- Темы оформления (кастомные цвета)
|
- Темы оформления (кастомные цвета)
|
||||||
- Статистика использования (мб и не будет, я не знаю мне лень)
|
- Статистика использования (мб и не будет, я не знаю мне лень)
|
||||||
@ -263,7 +280,7 @@ p.s. я не уверен работает ли оно, но оно работа
|
|||||||
|
|
||||||
**Автор**: w^x (лейн, платон)
|
**Автор**: w^x (лейн, платон)
|
||||||
**Контакт**: mkch.pooziqo.xyz
|
**Контакт**: mkch.pooziqo.xyz
|
||||||
**Версия**: 2.1.0-ios-alpha (Always in alpha lol)
|
**Версия**: 2.1.1-ios-alpha (Always in alpha lol)
|
||||||
**Дата**: Август 2025
|
**Дата**: Август 2025
|
||||||
|
|
||||||
*Разработано с <3 на Swift*
|
*Разработано с <3 на Swift*
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user