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

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 WidgetKit
import AppIntents import AppIntents
struct ConfigurationAppIntent: WidgetConfigurationIntent { struct ConfigurationAppIntent: WidgetConfigurationIntent, AppIntent {
static var title: LocalizedStringResource { "Configuration" } static var title: LocalizedStringResource { "Конфигурация виджета" }
static var description: IntentDescription { "This is an example widget." } static var description: IntentDescription { "Выберите доску для загрузки в случае отсутствия доступа к данным приложения." }
// An example configurable parameter. @Parameter(title: "Код доски", default: "b")
@Parameter(title: "Favorite Emoji", default: "😃") var boardCode: String
var favoriteEmoji: String
static var openAppWhenRun: Bool { true }
} }

View File

@ -8,50 +8,162 @@
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
struct Provider: AppIntentTimelineProvider { private let appGroupId = "group.mobilemkch"
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent()) struct FavoriteThreadWidget: Identifiable, Codable {
let id: Int
let title: String
let board: String
let boardDescription: String
let addedDate: Date
} }
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { struct ThreadDTO: Codable, Identifiable {
SimpleEntry(date: Date(), configuration: configuration) let id: Int
} let title: String
let board: String
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
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 SimpleEntry: TimelineEntry { struct SimpleEntry: TimelineEntry {
let date: Date let date: Date
let configuration: ConfigurationAppIntent let favorites: [FavoriteThreadWidget]
let offline: Bool
}
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), favorites: sample(), offline: false)
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
let favs = loadFavorites()
if favs.isEmpty {
return SimpleEntry(date: Date(), favorites: await loadFromNetwork(board: configuration.boardCode), offline: false)
}
return SimpleEntry(date: Date(), favorites: favs, offline: loadOffline())
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var favorites = loadFavorites()
var offline = loadOffline()
if favorites.isEmpty {
favorites = await loadFromNetwork(board: configuration.boardCode)
offline = false
}
let entry = SimpleEntry(date: Date(), favorites: favorites, offline: offline)
let refresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900)
return Timeline(entries: [entry], policy: .after(refresh))
}
private func loadFavorites() -> [FavoriteThreadWidget] {
guard let defaults = UserDefaults(suiteName: appGroupId),
let data = defaults.data(forKey: "favoriteThreads"),
let items = try? JSONDecoder().decode([FavoriteThreadWidget].self, from: data) else {
return []
}
return items
}
private func loadOffline() -> Bool {
let defaults = UserDefaults(suiteName: appGroupId)
return defaults?.bool(forKey: "offlineMode") ?? false
}
private func sample() -> [FavoriteThreadWidget] {
[FavoriteThreadWidget(id: 1, title: "Пример треда", board: "b", boardDescription: "Болталка", addedDate: Date())]
}
private func loadFromNetwork(board: String) async -> [FavoriteThreadWidget] {
guard let url = URL(string: "https://mkch.pooziqo.xyz/api/board/\(board)") else { return [] }
var req = URLRequest(url: url)
req.setValue("MobileMkch/2.1.1-widget", forHTTPHeaderField: "User-Agent")
do {
let (data, _) = try await URLSession.shared.data(for: req)
let threads = try JSONDecoder().decode([ThreadDTO].self, from: data)
return Array(threads.prefix(3)).map { t in
FavoriteThreadWidget(id: t.id, title: t.title, board: t.board, boardDescription: "", addedDate: Date())
}
} catch {
return []
}
}
} }
struct FavoritesWidgetEntryView : View { struct FavoritesWidgetEntryView : View {
var entry: Provider.Entry var entry: Provider.Entry
@Environment(\.widgetFamily) private var family
var body: some View { var body: some View {
VStack { VStack(alignment: .leading, spacing: spacing) {
Text("Time:") header
Text(entry.date, style: .time) content
Spacer(minLength: 0)
Text("Favorite Emoji:")
Text(entry.configuration.favoriteEmoji)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(padding)
.foregroundStyle(.primary)
.containerBackground(.background, for: .widget)
}
private var spacing: CGFloat { family == .systemSmall ? 4 : 6 }
private var padding: CGFloat { family == .systemSmall ? 8 : 12 }
private var titleFont: Font { family == .systemSmall ? .caption2 : .caption }
private var headerFont: Font { family == .systemSmall ? .footnote : .headline }
private var maxItems: Int { family == .systemSmall ? 1 : 3 }
@ViewBuilder private var header: some View {
HStack(spacing: 6) {
Text("Избранное")
.font(headerFont)
.lineLimit(1)
.minimumScaleFactor(0.8)
Spacer()
if entry.offline {
Image(systemName: "wifi.slash").foregroundColor(.orange)
}
}
}
@ViewBuilder private var content: some View {
if entry.favorites.isEmpty {
Text("Пусто")
.foregroundColor(.secondary)
.font(titleFont)
} else {
ForEach(entry.favorites.prefix(maxItems)) { fav in
if family == .systemSmall {
HStack(spacing: 6) {
BoardTag(code: fav.board)
Text(fav.title)
.font(titleFont)
.lineLimit(1)
.truncationMode(.tail)
}
} else {
VStack(alignment: .leading, spacing: 2) {
BoardTag(code: fav.board)
Text(fav.title)
.font(titleFont)
.lineLimit(2)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
}
}
}
}
}
}
private struct BoardTag: View {
let code: String
var body: some View {
Text("/\(code)/")
.font(.caption2)
.foregroundColor(.blue)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
} }
} }
@ -61,28 +173,8 @@ struct FavoritesWidget: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
FavoritesWidgetEntryView(entry: entry) FavoritesWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget) }
.configurationDisplayName("Избранное MobileMkch")
.description("Показывает избранные треды или топ по выбранной доске.")
} }
} }
}
extension ConfigurationAppIntent {
fileprivate static var smiley: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "😀"
return intent
}
fileprivate static var starEyes: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "🤩"
return intent
}
}
#Preview(as: .systemSmall) {
FavoritesWidget()
} timeline: {
SimpleEntry(date: .now, configuration: .smiley)
SimpleEntry(date: .now, configuration: .starEyes)
}

View File

@ -12,7 +12,5 @@ import SwiftUI
struct FavoritesWidgetBundle: WidgetBundle { struct FavoritesWidgetBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
FavoritesWidget() 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"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict/> <dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.mobilemkch</string>
</array>
</dict>
</plist> </plist>

View File

@ -6,11 +6,52 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
1D2B9E3A2E4603660097A722 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D2B9E392E4603660097A722 /* WidgetKit.framework */; };
1D2B9E3C2E4603660097A722 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D2B9E3B2E4603660097A722 /* SwiftUI.framework */; };
1D2B9E4D2E4603660097A722 /* FavoritesWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
1D2B9E4B2E4603660097A722 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 1D8926E12E43CE4C00C5590A /* Project object */;
proxyType = 1;
remoteGlobalIDString = 1D2B9E362E4603660097A722;
remoteInfo = FavoritesWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
1D2B9E4D2E4603660097A722 /* FavoritesWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FavoritesWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
1D2B9E392E4603660097A722 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FavoritesWidgetExtension.entitlements; sourceTree = "<group>"; };
1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */;
};
1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */ = { 1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
@ -21,6 +62,14 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
1D2B9E3D2E4603660097A722 /* FavoritesWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */,
);
path = FavoritesWidget;
sourceTree = "<group>";
};
1D8926EB2E43CE4C00C5590A /* MobileMkch */ = { 1D8926EB2E43CE4C00C5590A /* MobileMkch */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
@ -32,6 +81,15 @@
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
1D2B9E342E4603660097A722 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1D2B9E3C2E4603660097A722 /* SwiftUI.framework in Frameworks */,
1D2B9E3A2E4603660097A722 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
1D8926E62E43CE4C00C5590A /* Frameworks */ = { 1D8926E62E43CE4C00C5590A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -42,10 +100,22 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
1D2B9E382E4603660097A722 /* Frameworks */ = {
isa = PBXGroup;
children = (
1D2B9E392E4603660097A722 /* WidgetKit.framework */,
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
1D8926E02E43CE4C00C5590A = { 1D8926E02E43CE4C00C5590A = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */,
1D8926EB2E43CE4C00C5590A /* MobileMkch */, 1D8926EB2E43CE4C00C5590A /* MobileMkch */,
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
1D2B9E382E4603660097A722 /* Frameworks */,
1D8926EA2E43CE4C00C5590A /* Products */, 1D8926EA2E43CE4C00C5590A /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -54,6 +124,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1D8926E92E43CE4C00C5590A /* MobileMkch.app */, 1D8926E92E43CE4C00C5590A /* MobileMkch.app */,
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -61,6 +132,28 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1D2B9E4E2E4603660097A722 /* Build configuration list for PBXNativeTarget "FavoritesWidgetExtension" */;
buildPhases = (
1D2B9E332E4603660097A722 /* Sources */,
1D2B9E342E4603660097A722 /* Frameworks */,
1D2B9E352E4603660097A722 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
);
name = FavoritesWidgetExtension;
packageProductDependencies = (
);
productName = FavoritesWidgetExtension;
productReference = 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
1D8926E82E43CE4C00C5590A /* MobileMkch */ = { 1D8926E82E43CE4C00C5590A /* MobileMkch */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */; buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */;
@ -68,10 +161,12 @@
1D8926E52E43CE4C00C5590A /* Sources */, 1D8926E52E43CE4C00C5590A /* Sources */,
1D8926E62E43CE4C00C5590A /* Frameworks */, 1D8926E62E43CE4C00C5590A /* Frameworks */,
1D8926E72E43CE4C00C5590A /* Resources */, 1D8926E72E43CE4C00C5590A /* Resources */,
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
1D8926EB2E43CE4C00C5590A /* MobileMkch */, 1D8926EB2E43CE4C00C5590A /* MobileMkch */,
@ -93,6 +188,9 @@
LastSwiftUpdateCheck = 1620; LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620; LastUpgradeCheck = 1620;
TargetAttributes = { TargetAttributes = {
1D2B9E362E4603660097A722 = {
CreatedOnToolsVersion = 16.2;
};
1D8926E82E43CE4C00C5590A = { 1D8926E82E43CE4C00C5590A = {
CreatedOnToolsVersion = 16.2; CreatedOnToolsVersion = 16.2;
}; };
@ -113,11 +211,19 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
1D8926E82E43CE4C00C5590A /* MobileMkch */, 1D8926E82E43CE4C00C5590A /* MobileMkch */,
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
1D2B9E352E4603660097A722 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
1D8926E72E43CE4C00C5590A /* Resources */ = { 1D8926E72E43CE4C00C5590A /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -128,6 +234,13 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
1D2B9E332E4603660097A722 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
1D8926E52E43CE4C00C5590A /* Sources */ = { 1D8926E52E43CE4C00C5590A /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -137,7 +250,71 @@
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */;
targetProxy = 1D2B9E4B2E4603660097A722 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
1D2B9E4F2E4603660097A722 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = FavoritesWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 9U88M9D595;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FavoritesWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = FavoritesWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch.FavoritesWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
1D2B9E502E4603660097A722 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = FavoritesWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 9U88M9D595;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FavoritesWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = FavoritesWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch.FavoritesWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
1D8926F52E43CE4D00C5590A /* Debug */ = { 1D8926F52E43CE4D00C5590A /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@ -262,6 +439,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = MobileMkch/MobileMkch.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "2-alpha"; CURRENT_PROJECT_VERSION = "2-alpha";
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
@ -298,6 +476,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = MobileMkch/MobileMkch.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "2-alpha"; CURRENT_PROJECT_VERSION = "2-alpha";
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
@ -332,6 +511,15 @@
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
1D2B9E4E2E4603660097A722 /* Build configuration list for PBXNativeTarget "FavoritesWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1D2B9E4F2E4603660097A722 /* Debug */,
1D2B9E502E4603660097A722 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */ = { 1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View File

@ -4,6 +4,11 @@
<dict> <dict>
<key>SchemeUserState</key> <key>SchemeUserState</key>
<dict> <dict>
<key>FavoritesWidgetExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>MobileMkch.xcscheme_^#shared#^_</key> <key>MobileMkch.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import Network
class APIClient: ObservableObject { class APIClient: ObservableObject {
private let baseURL = "https://mkch.pooziqo.xyz" private let baseURL = "https://mkch.pooziqo.xyz"
@ -152,6 +153,14 @@ class APIClient: ObservableObject {
} }
func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) { func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let cached = Cache.shared.getBoardsStale(), !cached.isEmpty {
completion(.success(cached))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённых данных", code: 0)))
}
return
}
if let cachedBoards = Cache.shared.getBoards() { if let cachedBoards = Cache.shared.getBoards() {
completion(.success(cachedBoards)) completion(.success(cachedBoards))
return return
@ -164,18 +173,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(error)) completion(.failure(error))
}
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { httpResponse.statusCode == 200 else {
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Ошибка получения досок", code: 0))) completion(.failure(APIError(message: "Ошибка получения досок", code: 0)))
}
return return
} }
guard let data = data else { guard let data = data else {
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Нет данных", code: 0))) completion(.failure(APIError(message: "Нет данных", code: 0)))
}
return return
} }
@ -191,6 +212,14 @@ class APIClient: ObservableObject {
} }
func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) { func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённых тредов", code: 0)))
}
return
}
if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) { if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) {
completion(.success(cachedThreads)) completion(.success(cachedThreads))
return return
@ -203,18 +232,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(error)) completion(.failure(error))
}
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { httpResponse.statusCode == 200 else {
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Ошибка получения тредов", code: 0))) completion(.failure(APIError(message: "Ошибка получения тредов", code: 0)))
}
return return
} }
guard let data = data else { guard let data = data else {
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Нет данных", code: 0))) completion(.failure(APIError(message: "Нет данных", code: 0)))
}
return return
} }
@ -230,6 +271,14 @@ class APIClient: ObservableObject {
} }
func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) { func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённого треда", code: 0)))
}
return
}
if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) { if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) {
completion(.success(cachedThread)) completion(.success(cachedThread))
return return
@ -242,18 +291,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(error)) completion(.failure(error))
}
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { httpResponse.statusCode == 200 else {
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Ошибка получения треда", code: 0))) completion(.failure(APIError(message: "Ошибка получения треда", code: 0)))
}
return return
} }
guard let data = data else { guard let data = data else {
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Нет данных", code: 0))) completion(.failure(APIError(message: "Нет данных", code: 0)))
}
return return
} }
@ -269,6 +330,14 @@ class APIClient: ObservableObject {
} }
func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) { func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённых комментариев", code: 0)))
}
return
}
if let cachedComments = Cache.shared.getComments(forThreadId: threadId) { if let cachedComments = Cache.shared.getComments(forThreadId: threadId) {
completion(.success(cachedComments)) completion(.success(cachedComments))
return return
@ -281,18 +350,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(error)) completion(.failure(error))
}
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { httpResponse.statusCode == 200 else {
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Ошибка получения комментариев", code: 0))) completion(.failure(APIError(message: "Ошибка получения комментариев", code: 0)))
}
return return
} }
guard let data = data else { guard let data = data else {
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Нет данных", code: 0))) completion(.failure(APIError(message: "Нет данных", code: 0)))
}
return 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() static let shared = BackgroundTaskManager()
private var backgroundTaskIdentifier: String { private var backgroundTaskIdentifier: String {
// Используем идентификатор из Info.plist (BGTaskSchedulerPermittedIdentifiers)
if let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String], if let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String],
let first = identifiers.first { let first = identifiers.first {
return first return first
} }
// Фоллбек на значение по умолчанию
return "com.mkch.MobileMkch.backgroundrefresh" return "com.mkch.MobileMkch.backgroundrefresh"
} }
private let notificationManager = NotificationManager.shared private let notificationManager = NotificationManager.shared

View File

@ -3,6 +3,7 @@ import SwiftUI
struct BoardsView: View { struct BoardsView: View {
@EnvironmentObject var settings: Settings @EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient @EnvironmentObject var apiClient: APIClient
@EnvironmentObject var networkMonitor: NetworkMonitor
@State private var boards: [Board] = [] @State private var boards: [Board] = []
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@ -10,6 +11,15 @@ struct BoardsView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
List { List {
if networkMonitor.offlineEffective {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundColor(.orange)
Text("Оффлайн режим. Показаны сохранённые данные")
.foregroundColor(.secondary)
.font(.caption)
}
}
if isLoading { if isLoading {
HStack { HStack {
ProgressView() ProgressView()

View File

@ -1,10 +1,12 @@
import Foundation import Foundation
import CryptoKit
class Cache { class Cache {
static let shared = Cache() static let shared = Cache()
private var items: [String: CacheItem] = [:] private var items: [String: CacheItem] = [:]
private let queue = DispatchQueue(label: "cache.queue", attributes: .concurrent) private let queue = DispatchQueue(label: "cache.queue", attributes: .concurrent)
private let fileManager = FileManager.default
private init() { private init() {
startCleanupTimer() startCleanupTimer()
@ -20,41 +22,57 @@ class Cache {
ttl: ttl ttl: ttl
) )
} }
saveToDisk(key: key, data: encodedData, ttl: ttl)
} catch { } catch {
print("Ошибка кодирования данных для кэша: \(error)") print("Ошибка кодирования данных для кэша: \(error)")
} }
} }
func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? { func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
return queue.sync { if let inMemory: T = queue.sync(execute: { () -> T? in
guard let item = items[key] else { return nil } guard let item = items[key] else { return nil }
if Date().timeIntervalSince(item.timestamp) > item.ttl { if Date().timeIntervalSince(item.timestamp) > item.ttl {
items.removeValue(forKey: key) items.removeValue(forKey: key)
return nil return nil
} }
do { return try JSONDecoder().decode(type, from: item.data) } catch { return nil }
let data = item.data }) {
return inMemory
do { }
return try JSONDecoder().decode(type, from: data) guard let diskItem = loadFromDisk(key: key) else { return nil }
} catch { if Date().timeIntervalSince(diskItem.timestamp) > diskItem.ttl {
print("Ошибка декодирования данных из кэша: \(error)") delete(key)
return nil return nil
} }
queue.async(flags: .barrier) {
self.items[key] = diskItem
} }
do { return try JSONDecoder().decode(type, from: diskItem.data) } catch { return nil }
}
func getStale<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
if let item = queue.sync(execute: { items[key] }) {
return try? JSONDecoder().decode(type, from: item.data)
}
guard let diskItem = loadFromDisk(key: key) else { return nil }
queue.async(flags: .barrier) {
self.items[key] = diskItem
}
return try? JSONDecoder().decode(type, from: diskItem.data)
} }
func delete(_ key: String) { func delete(_ key: String) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.items.removeValue(forKey: key) self.items.removeValue(forKey: key)
} }
deleteFromDisk(key: key)
} }
func clear() { func clear() {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.items.removeAll() self.items.removeAll()
} }
clearDisk()
} }
private func startCleanupTimer() { private func startCleanupTimer() {
@ -73,6 +91,55 @@ class Cache {
return true return true
} }
} }
cleanupDisk()
}
private func cachesDirectory() -> URL {
fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}
private func fileURL(forKey key: String) -> URL {
let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
return cachesDirectory().appendingPathComponent("Cache_\(hash).json")
}
private func saveToDisk(key: String, data: Data, ttl: TimeInterval) {
let item = PersistedCacheItem(data: data, timestamp: Date(), ttl: ttl)
guard let encoded = try? JSONEncoder().encode(item) else { return }
let url = fileURL(forKey: key)
try? encoded.write(to: url, options: .atomic)
}
private func loadFromDisk(key: String) -> CacheItem? {
let url = fileURL(forKey: key)
guard let data = try? Data(contentsOf: url) else { return nil }
guard let persisted = try? JSONDecoder().decode(PersistedCacheItem.self, from: data) else { return nil }
return CacheItem(data: persisted.data, timestamp: persisted.timestamp, ttl: persisted.ttl)
}
private func deleteFromDisk(key: String) {
let url = fileURL(forKey: key)
try? fileManager.removeItem(at: url)
}
private func clearDisk() {
let dir = cachesDirectory()
let contents = (try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
for url in contents where url.lastPathComponent.hasPrefix("Cache_") && url.pathExtension == "json" {
try? fileManager.removeItem(at: url)
}
}
private func cleanupDisk() {
let dir = cachesDirectory()
let contents = (try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
for url in contents where url.lastPathComponent.hasPrefix("Cache_") && url.pathExtension == "json" {
guard let data = try? Data(contentsOf: url),
let persisted = try? JSONDecoder().decode(PersistedCacheItem.self, from: data) else { continue }
if Date().timeIntervalSince(persisted.timestamp) > persisted.ttl {
try? fileManager.removeItem(at: url)
}
}
} }
} }
@ -82,6 +149,12 @@ struct CacheItem {
let ttl: TimeInterval let ttl: TimeInterval
} }
struct PersistedCacheItem: Codable {
let data: Data
let timestamp: Date
let ttl: TimeInterval
}
extension Cache { extension Cache {
func setBoards(_ boards: [Board]) { func setBoards(_ boards: [Board]) {
set(boards, forKey: "boards", ttl: 600) set(boards, forKey: "boards", ttl: 600)
@ -91,6 +164,10 @@ extension Cache {
return get([Board].self, forKey: "boards") return get([Board].self, forKey: "boards")
} }
func getBoardsStale() -> [Board]? {
return getStale([Board].self, forKey: "boards")
}
func setThreads(_ threads: [Thread], forBoard boardCode: String) { func setThreads(_ threads: [Thread], forBoard boardCode: String) {
set(threads, forKey: "threads_\(boardCode)", ttl: 300) set(threads, forKey: "threads_\(boardCode)", ttl: 300)
} }
@ -99,6 +176,10 @@ extension Cache {
return get([Thread].self, forKey: "threads_\(boardCode)") return get([Thread].self, forKey: "threads_\(boardCode)")
} }
func getThreadsStale(forBoard boardCode: String) -> [Thread]? {
return getStale([Thread].self, forKey: "threads_\(boardCode)")
}
func setThreadDetail(_ thread: ThreadDetail, forThreadId threadId: Int) { func setThreadDetail(_ thread: ThreadDetail, forThreadId threadId: Int) {
set(thread, forKey: "thread_detail_\(threadId)", ttl: 180) set(thread, forKey: "thread_detail_\(threadId)", ttl: 180)
} }
@ -107,6 +188,10 @@ extension Cache {
return get(ThreadDetail.self, forKey: "thread_detail_\(threadId)") return get(ThreadDetail.self, forKey: "thread_detail_\(threadId)")
} }
func getThreadDetailStale(forThreadId threadId: Int) -> ThreadDetail? {
return getStale(ThreadDetail.self, forKey: "thread_detail_\(threadId)")
}
func setComments(_ comments: [Comment], forThreadId threadId: Int) { func setComments(_ comments: [Comment], forThreadId threadId: Int) {
set(comments, forKey: "comments_\(threadId)", ttl: 180) set(comments, forKey: "comments_\(threadId)", ttl: 180)
} }
@ -114,4 +199,8 @@ extension Cache {
func getComments(forThreadId threadId: Int) -> [Comment]? { func getComments(forThreadId threadId: Int) -> [Comment]? {
return get([Comment].self, forKey: "comments_\(threadId)") return get([Comment].self, forKey: "comments_\(threadId)")
} }
func getCommentsStale(forThreadId threadId: Int) -> [Comment]? {
return getStale([Comment].self, forKey: "comments_\(threadId)")
}
} }

View File

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

View File

@ -3,6 +3,8 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array/> <array>
<string>group.mobilemkch</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -14,6 +14,7 @@ struct MobileMkchApp: App {
@StateObject private var apiClient = APIClient() @StateObject private var apiClient = APIClient()
@StateObject private var crashHandler = CrashHandler.shared @StateObject private var crashHandler = CrashHandler.shared
@StateObject private var notificationManager = NotificationManager.shared @StateObject private var notificationManager = NotificationManager.shared
@StateObject private var networkMonitor = NetworkMonitor.shared
private func setupBackgroundTasks() { private func setupBackgroundTasks() {
if let bundleIdentifier = Bundle.main.bundleIdentifier { if let bundleIdentifier = Bundle.main.bundleIdentifier {
@ -52,6 +53,7 @@ struct MobileMkchApp: App {
.environmentObject(settings) .environmentObject(settings)
.environmentObject(apiClient) .environmentObject(apiClient)
.environmentObject(notificationManager) .environmentObject(notificationManager)
.environmentObject(networkMonitor)
.preferredColorScheme(settings.theme == "dark" ? .dark : .light) .preferredColorScheme(settings.theme == "dark" ? .dark : .light)
} }
} }

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 Foundation
import WidgetKit
class Settings: ObservableObject { class Settings: ObservableObject {
@Published var theme: String = "dark" @Published var theme: String = "dark"
@ -14,12 +15,14 @@ class Settings: ObservableObject {
@Published var notificationsEnabled: Bool = false @Published var notificationsEnabled: Bool = false
@Published var notificationInterval: Int = 300 @Published var notificationInterval: Int = 300
@Published var favoriteThreads: [FavoriteThread] = [] @Published var favoriteThreads: [FavoriteThread] = []
@Published var offlineMode: Bool = false
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let settingsKey = "MobileMkchSettings" private let settingsKey = "MobileMkchSettings"
init() { init() {
loadSettings() loadSettings()
mirrorStateToAppGroup()
} }
func loadSettings() { func loadSettings() {
@ -38,7 +41,9 @@ class Settings: ObservableObject {
self.notificationsEnabled = settings.notificationsEnabled self.notificationsEnabled = settings.notificationsEnabled
self.notificationInterval = settings.notificationInterval self.notificationInterval = settings.notificationInterval
self.favoriteThreads = settings.favoriteThreads self.favoriteThreads = settings.favoriteThreads
self.offlineMode = settings.offlineMode ?? false
} }
mirrorStateToAppGroup()
} }
func saveSettings() { func saveSettings() {
@ -56,11 +61,15 @@ class Settings: ObservableObject {
notificationsEnabled: notificationsEnabled, notificationsEnabled: notificationsEnabled,
notificationInterval: notificationInterval, notificationInterval: notificationInterval,
favoriteThreads: favoriteThreads favoriteThreads: favoriteThreads
,
offlineMode: offlineMode
) )
if let data = try? JSONEncoder().encode(settingsData) { if let data = try? JSONEncoder().encode(settingsData) {
userDefaults.set(data, forKey: settingsKey) userDefaults.set(data, forKey: settingsKey)
} }
mirrorStateToAppGroup()
WidgetCenter.shared.reloadAllTimelines()
} }
func resetSettings() { func resetSettings() {
@ -77,6 +86,7 @@ class Settings: ObservableObject {
notificationsEnabled = false notificationsEnabled = false
notificationInterval = 300 notificationInterval = 300
favoriteThreads = [] favoriteThreads = []
offlineMode = false
saveSettings() saveSettings()
} }
@ -100,6 +110,17 @@ class Settings: ObservableObject {
func isFavorite(_ threadId: Int, boardCode: String) -> Bool { func isFavorite(_ threadId: Int, boardCode: String) -> Bool {
return favoriteThreads.contains { $0.id == threadId && $0.board == boardCode } return favoriteThreads.contains { $0.id == threadId && $0.board == boardCode }
} }
private func mirrorStateToAppGroup() {
guard let shared = AppGroup.defaults else { return }
let mapped = favoriteThreads.map { FavoriteThreadWidget(id: $0.id, title: $0.title, board: $0.board, boardDescription: $0.boardDescription, addedDate: $0.addedDate) }
if let encodedFavorites = try? JSONEncoder().encode(mapped) {
shared.set(encodedFavorites, forKey: "favoriteThreads")
}
shared.set(offlineMode, forKey: "offlineMode")
shared.set(lastBoard, forKey: "lastBoard")
WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget")
}
} }
struct SettingsData: Codable { struct SettingsData: Codable {
@ -116,4 +137,5 @@ struct SettingsData: Codable {
let notificationsEnabled: Bool let notificationsEnabled: Bool
let notificationInterval: Int let notificationInterval: Int
let favoriteThreads: [FavoriteThread] let favoriteThreads: [FavoriteThread]
let offlineMode: Bool?
} }

View File

@ -5,6 +5,7 @@ import Darwin
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var settings: Settings @EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient @EnvironmentObject var apiClient: APIClient
@EnvironmentObject var networkMonitor: NetworkMonitor
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingInfo = false @State private var showingInfo = false
@ -110,6 +111,26 @@ struct SettingsView: View {
} }
} }
Section("Оффлайн режим") {
HStack {
Image(systemName: "wifi.slash")
.foregroundColor(.orange)
.frame(width: 24)
Toggle("Принудительно оффлайн", isOn: $settings.offlineMode)
}
.onChange(of: settings.offlineMode) { newValue in
if networkMonitor.forceOffline != newValue {
networkMonitor.forceOffline = newValue
settings.saveSettings()
}
}
VStack(alignment: .leading, spacing: 4) {
Text(networkMonitor.offlineEffective ? "Сейчас оффлайн: показываем кэш" : "Онлайн: будут загружаться свежие данные")
.font(.caption)
.foregroundColor(.secondary)
}
}
Section("Аутентификация") { Section("Аутентификация") {
HStack { HStack {
Image(systemName: "lock.shield") Image(systemName: "lock.shield")

View File

@ -5,6 +5,7 @@ struct ThreadDetailView: View {
let thread: Thread let thread: Thread
@EnvironmentObject var settings: Settings @EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient @EnvironmentObject var apiClient: APIClient
@EnvironmentObject var networkMonitor: NetworkMonitor
@State private var threadDetail: ThreadDetail? @State private var threadDetail: ThreadDetail?
@State private var comments: [Comment] = [] @State private var comments: [Comment] = []
@State private var isLoading = false @State private var isLoading = false
@ -14,6 +15,15 @@ struct ThreadDetailView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if networkMonitor.offlineEffective {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundColor(.orange)
Text("Оффлайн режим. Показаны сохранённые данные")
.foregroundColor(.secondary)
.font(.caption)
}
}
if isLoading { if isLoading {
VStack { VStack {
ProgressView() ProgressView()

View File

@ -5,6 +5,7 @@ struct ThreadsView: View {
@EnvironmentObject var settings: Settings @EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient @EnvironmentObject var apiClient: APIClient
@EnvironmentObject var notificationManager: NotificationManager @EnvironmentObject var notificationManager: NotificationManager
@EnvironmentObject var networkMonitor: NetworkMonitor
@State private var threads: [Thread] = [] @State private var threads: [Thread] = []
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@ -21,6 +22,15 @@ struct ThreadsView: View {
var body: some View { var body: some View {
VStack { VStack {
if networkMonitor.offlineEffective {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundColor(.orange)
Text("Оффлайн режим. Показаны сохранённые данные")
.foregroundColor(.secondary)
.font(.caption)
}
}
if isLoading { if isLoading {
VStack { VStack {
ProgressView() ProgressView()

View File

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