оффлайн + виджет

This commit is contained in:
Lain Iwakura 2025-08-08 13:30:26 +03:00
parent 11fdf3186b
commit 013623290d
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
22 changed files with 686 additions and 250 deletions

View File

@ -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 }
}

View File

@ -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)
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = []
struct FavoriteThreadWidget: Identifiable, Codable {
let id: Int
let title: String
let board: String
let boardDescription: String
let addedDate: Date
}
// 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)
}

View File

@ -12,7 +12,5 @@ import SwiftUI
struct FavoritesWidgetBundle: WidgetBundle {
var body: some Widget {
FavoritesWidget()
FavoritesWidgetControl()
FavoritesWidgetLiveActivity()
}
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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>

View File

@ -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 = (

View File

@ -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>

View File

@ -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
View 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
}

View File

@ -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

View File

@ -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()

View File

@ -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)")
}
}

View File

@ -10,6 +10,8 @@
<array>
<string>background-processing</string>
<string>background-fetch</string>
<string>fetch</string>
<string>processing</string>
</array>
</dict>
</plist>

View File

@ -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>

View File

@ -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)
}
}

View 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)
}
}

View File

@ -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?
}

View File

@ -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")

View File

@ -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()

View File

@ -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()

View File

@ -2,7 +2,7 @@
Нативный iOS клиент для борды mkch.pooziqo.xyz
![Version](https://img.shields.io/badge/версия-2.1.0--ios--alpha-blue)
![Version](https://img.shields.io/badge/версия-2.1.1--ios--alpha-blue)
![iOS](https://img.shields.io/badge/iOS-15.0%2B-green)
![Swift](https://img.shields.io/badge/Swift-5-orange)
@ -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*