оффлайн + виджет
This commit is contained in:
parent
11fdf3186b
commit
013623290d
@ -8,11 +8,12 @@
|
||||
import WidgetKit
|
||||
import AppIntents
|
||||
|
||||
struct ConfigurationAppIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource { "Configuration" }
|
||||
static var description: IntentDescription { "This is an example widget." }
|
||||
struct ConfigurationAppIntent: WidgetConfigurationIntent, AppIntent {
|
||||
static var title: LocalizedStringResource { "Конфигурация виджета" }
|
||||
static var description: IntentDescription { "Выберите доску для загрузки в случае отсутствия доступа к данным приложения." }
|
||||
|
||||
// An example configurable parameter.
|
||||
@Parameter(title: "Favorite Emoji", default: "😃")
|
||||
var favoriteEmoji: String
|
||||
@Parameter(title: "Код доски", default: "b")
|
||||
var boardCode: String
|
||||
|
||||
static var openAppWhenRun: Bool { true }
|
||||
}
|
||||
|
||||
@ -8,50 +8,162 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct Provider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> SimpleEntry {
|
||||
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
|
||||
}
|
||||
private let appGroupId = "group.mobilemkch"
|
||||
|
||||
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
|
||||
SimpleEntry(date: Date(), configuration: configuration)
|
||||
}
|
||||
struct FavoriteThreadWidget: Identifiable, Codable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let board: String
|
||||
let boardDescription: String
|
||||
let addedDate: Date
|
||||
}
|
||||
|
||||
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
|
||||
var entries: [SimpleEntry] = []
|
||||
|
||||
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
|
||||
let currentDate = Date()
|
||||
for hourOffset in 0 ..< 5 {
|
||||
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
|
||||
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 ThreadDTO: Codable, Identifiable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let board: String
|
||||
}
|
||||
|
||||
struct SimpleEntry: TimelineEntry {
|
||||
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 {
|
||||
var entry: Provider.Entry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Time:")
|
||||
Text(entry.date, style: .time)
|
||||
|
||||
Text("Favorite Emoji:")
|
||||
Text(entry.configuration.favoriteEmoji)
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
header
|
||||
content
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.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 {
|
||||
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
|
||||
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 {
|
||||
var body: some Widget {
|
||||
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"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.mobilemkch</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -6,11 +6,52 @@
|
||||
objectVersion = 77;
|
||||
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 */
|
||||
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; };
|
||||
/* End PBXFileReference 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 */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
@ -21,6 +62,14 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
1D2B9E3D2E4603660097A722 /* FavoritesWidget */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */,
|
||||
);
|
||||
path = FavoritesWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@ -32,6 +81,15 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup 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 */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -42,10 +100,22 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1D2B9E382E4603660097A722 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D2B9E392E4603660097A722 /* WidgetKit.framework */,
|
||||
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1D8926E02E43CE4C00C5590A = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */,
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
||||
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
|
||||
1D2B9E382E4603660097A722 /* Frameworks */,
|
||||
1D8926EA2E43CE4C00C5590A /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@ -54,6 +124,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D8926E92E43CE4C00C5590A /* MobileMkch.app */,
|
||||
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -61,6 +132,28 @@
|
||||
/* End PBXGroup 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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */;
|
||||
@ -68,10 +161,12 @@
|
||||
1D8926E52E43CE4C00C5590A /* Sources */,
|
||||
1D8926E62E43CE4C00C5590A /* Frameworks */,
|
||||
1D8926E72E43CE4C00C5590A /* Resources */,
|
||||
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
||||
@ -93,6 +188,9 @@
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1620;
|
||||
TargetAttributes = {
|
||||
1D2B9E362E4603660097A722 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
1D8926E82E43CE4C00C5590A = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
@ -113,11 +211,19 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1D8926E82E43CE4C00C5590A /* MobileMkch */,
|
||||
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
1D2B9E352E4603660097A722 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1D8926E72E43CE4C00C5590A /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -128,6 +234,13 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1D2B9E332E4603660097A722 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1D8926E52E43CE4C00C5590A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -137,7 +250,71 @@
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */;
|
||||
targetProxy = 1D2B9E4B2E4603660097A722 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency 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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@ -262,6 +439,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = MobileMkch/MobileMkch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "2-alpha";
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
||||
@ -298,6 +476,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = MobileMkch/MobileMkch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "2-alpha";
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
||||
@ -332,6 +511,15 @@
|
||||
/* End XCBuildConfiguration 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" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@ -4,6 +4,11 @@
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>FavoritesWidgetExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>MobileMkch.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
class APIClient: ObservableObject {
|
||||
private let baseURL = "https://mkch.pooziqo.xyz"
|
||||
@ -152,6 +153,14 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
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() {
|
||||
completion(.success(cachedBoards))
|
||||
return
|
||||
@ -164,18 +173,30 @@ class APIClient: ObservableObject {
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -191,6 +212,14 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
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) {
|
||||
completion(.success(cachedThreads))
|
||||
return
|
||||
@ -203,18 +232,30 @@ class APIClient: ObservableObject {
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
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
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -230,6 +271,14 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
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) {
|
||||
completion(.success(cachedThread))
|
||||
return
|
||||
@ -242,18 +291,30 @@ class APIClient: ObservableObject {
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -269,6 +330,14 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
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) {
|
||||
completion(.success(cachedComments))
|
||||
return
|
||||
@ -281,18 +350,30 @@ class APIClient: ObservableObject {
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
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
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
private var backgroundTaskIdentifier: String {
|
||||
// Используем идентификатор из Info.plist (BGTaskSchedulerPermittedIdentifiers)
|
||||
if let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String],
|
||||
let first = identifiers.first {
|
||||
return first
|
||||
}
|
||||
// Фоллбек на значение по умолчанию
|
||||
return "com.mkch.MobileMkch.backgroundrefresh"
|
||||
}
|
||||
private let notificationManager = NotificationManager.shared
|
||||
|
||||
@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct BoardsView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
@State private var boards: [Board] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@ -10,6 +11,15 @@ struct BoardsView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
if networkMonitor.offlineEffective {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.orange)
|
||||
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
class Cache {
|
||||
static let shared = Cache()
|
||||
|
||||
private var items: [String: CacheItem] = [:]
|
||||
private let queue = DispatchQueue(label: "cache.queue", attributes: .concurrent)
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private init() {
|
||||
startCleanupTimer()
|
||||
@ -20,41 +22,57 @@ class Cache {
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
saveToDisk(key: key, data: encodedData, ttl: ttl)
|
||||
} catch {
|
||||
print("Ошибка кодирования данных для кэша: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
if Date().timeIntervalSince(item.timestamp) > item.ttl {
|
||||
items.removeValue(forKey: key)
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = item.data
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
print("Ошибка декодирования данных из кэша: \(error)")
|
||||
return nil
|
||||
}
|
||||
do { return try JSONDecoder().decode(type, from: item.data) } catch { return nil }
|
||||
}) {
|
||||
return inMemory
|
||||
}
|
||||
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) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.items.removeValue(forKey: key)
|
||||
}
|
||||
deleteFromDisk(key: key)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.items.removeAll()
|
||||
}
|
||||
clearDisk()
|
||||
}
|
||||
|
||||
private func startCleanupTimer() {
|
||||
@ -73,6 +91,55 @@ class Cache {
|
||||
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
|
||||
}
|
||||
|
||||
struct PersistedCacheItem: Codable {
|
||||
let data: Data
|
||||
let timestamp: Date
|
||||
let ttl: TimeInterval
|
||||
}
|
||||
|
||||
extension Cache {
|
||||
func setBoards(_ boards: [Board]) {
|
||||
set(boards, forKey: "boards", ttl: 600)
|
||||
@ -91,6 +164,10 @@ extension Cache {
|
||||
return get([Board].self, forKey: "boards")
|
||||
}
|
||||
|
||||
func getBoardsStale() -> [Board]? {
|
||||
return getStale([Board].self, forKey: "boards")
|
||||
}
|
||||
|
||||
func setThreads(_ threads: [Thread], forBoard boardCode: String) {
|
||||
set(threads, forKey: "threads_\(boardCode)", ttl: 300)
|
||||
}
|
||||
@ -99,6 +176,10 @@ extension Cache {
|
||||
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) {
|
||||
set(thread, forKey: "thread_detail_\(threadId)", ttl: 180)
|
||||
}
|
||||
@ -107,6 +188,10 @@ extension Cache {
|
||||
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) {
|
||||
set(comments, forKey: "comments_\(threadId)", ttl: 180)
|
||||
}
|
||||
@ -114,4 +199,8 @@ extension Cache {
|
||||
func getComments(forThreadId threadId: Int) -> [Comment]? {
|
||||
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>
|
||||
<string>background-processing</string>
|
||||
<string>background-fetch</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array/>
|
||||
<array>
|
||||
<string>group.mobilemkch</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -14,6 +14,7 @@ struct MobileMkchApp: App {
|
||||
@StateObject private var apiClient = APIClient()
|
||||
@StateObject private var crashHandler = CrashHandler.shared
|
||||
@StateObject private var notificationManager = NotificationManager.shared
|
||||
@StateObject private var networkMonitor = NetworkMonitor.shared
|
||||
|
||||
private func setupBackgroundTasks() {
|
||||
if let bundleIdentifier = Bundle.main.bundleIdentifier {
|
||||
@ -52,6 +53,7 @@ struct MobileMkchApp: App {
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
.environmentObject(notificationManager)
|
||||
.environmentObject(networkMonitor)
|
||||
.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 WidgetKit
|
||||
|
||||
class Settings: ObservableObject {
|
||||
@Published var theme: String = "dark"
|
||||
@ -14,12 +15,14 @@ class Settings: ObservableObject {
|
||||
@Published var notificationsEnabled: Bool = false
|
||||
@Published var notificationInterval: Int = 300
|
||||
@Published var favoriteThreads: [FavoriteThread] = []
|
||||
@Published var offlineMode: Bool = false
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let settingsKey = "MobileMkchSettings"
|
||||
|
||||
init() {
|
||||
loadSettings()
|
||||
mirrorStateToAppGroup()
|
||||
}
|
||||
|
||||
func loadSettings() {
|
||||
@ -38,7 +41,9 @@ class Settings: ObservableObject {
|
||||
self.notificationsEnabled = settings.notificationsEnabled
|
||||
self.notificationInterval = settings.notificationInterval
|
||||
self.favoriteThreads = settings.favoriteThreads
|
||||
self.offlineMode = settings.offlineMode ?? false
|
||||
}
|
||||
mirrorStateToAppGroup()
|
||||
}
|
||||
|
||||
func saveSettings() {
|
||||
@ -56,11 +61,15 @@ class Settings: ObservableObject {
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
notificationInterval: notificationInterval,
|
||||
favoriteThreads: favoriteThreads
|
||||
,
|
||||
offlineMode: offlineMode
|
||||
)
|
||||
|
||||
if let data = try? JSONEncoder().encode(settingsData) {
|
||||
userDefaults.set(data, forKey: settingsKey)
|
||||
}
|
||||
mirrorStateToAppGroup()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
func resetSettings() {
|
||||
@ -77,6 +86,7 @@ class Settings: ObservableObject {
|
||||
notificationsEnabled = false
|
||||
notificationInterval = 300
|
||||
favoriteThreads = []
|
||||
offlineMode = false
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
@ -100,6 +110,17 @@ class Settings: ObservableObject {
|
||||
func isFavorite(_ threadId: Int, boardCode: String) -> Bool {
|
||||
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 {
|
||||
@ -116,4 +137,5 @@ struct SettingsData: Codable {
|
||||
let notificationsEnabled: Bool
|
||||
let notificationInterval: Int
|
||||
let favoriteThreads: [FavoriteThread]
|
||||
let offlineMode: Bool?
|
||||
}
|
||||
@ -5,6 +5,7 @@ import Darwin
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
|
||||
@State private var showingAbout = 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("Аутентификация") {
|
||||
HStack {
|
||||
Image(systemName: "lock.shield")
|
||||
|
||||
@ -5,6 +5,7 @@ struct ThreadDetailView: View {
|
||||
let thread: Thread
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
@State private var threadDetail: ThreadDetail?
|
||||
@State private var comments: [Comment] = []
|
||||
@State private var isLoading = false
|
||||
@ -14,6 +15,15 @@ struct ThreadDetailView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if networkMonitor.offlineEffective {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.orange)
|
||||
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
|
||||
@ -5,6 +5,7 @@ struct ThreadsView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var notificationManager: NotificationManager
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
@State private var threads: [Thread] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@ -21,6 +22,15 @@ struct ThreadsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if networkMonitor.offlineEffective {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.orange)
|
||||
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
|
||||
25
README.md
25
README.md
@ -2,7 +2,7 @@
|
||||
|
||||
Нативный iOS клиент для борды mkch.pooziqo.xyz
|
||||
|
||||

|
||||

|
||||

|
||||

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