diff --git a/FavoritesWidget/AppIntent.swift b/FavoritesWidget/AppIntent.swift index a632773..ef1eca8 100644 --- a/FavoritesWidget/AppIntent.swift +++ b/FavoritesWidget/AppIntent.swift @@ -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 } } diff --git a/FavoritesWidget/FavoritesWidget.swift b/FavoritesWidget/FavoritesWidget.swift index 94f4d3c..a48be3e 100644 --- a/FavoritesWidget/FavoritesWidget.swift +++ b/FavoritesWidget/FavoritesWidget.swift @@ -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 { - 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 { -// // 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 { + 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) -} diff --git a/FavoritesWidget/FavoritesWidgetBundle.swift b/FavoritesWidget/FavoritesWidgetBundle.swift index d34a5e7..7649c20 100644 --- a/FavoritesWidget/FavoritesWidgetBundle.swift +++ b/FavoritesWidget/FavoritesWidgetBundle.swift @@ -12,7 +12,5 @@ import SwiftUI struct FavoritesWidgetBundle: WidgetBundle { var body: some Widget { FavoritesWidget() - FavoritesWidgetControl() - FavoritesWidgetLiveActivity() } } diff --git a/FavoritesWidget/FavoritesWidgetControl.swift b/FavoritesWidget/FavoritesWidgetControl.swift deleted file mode 100644 index abdaf51..0000000 --- a/FavoritesWidget/FavoritesWidgetControl.swift +++ /dev/null @@ -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() - } -} diff --git a/FavoritesWidget/FavoritesWidgetLiveActivity.swift b/FavoritesWidget/FavoritesWidgetLiveActivity.swift deleted file mode 100644 index 424e382..0000000 --- a/FavoritesWidget/FavoritesWidgetLiveActivity.swift +++ /dev/null @@ -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 -} diff --git a/FavoritesWidgetExtension.entitlements b/FavoritesWidgetExtension.entitlements index 0c67376..2f28a30 100644 --- a/FavoritesWidgetExtension.entitlements +++ b/FavoritesWidgetExtension.entitlements @@ -1,5 +1,10 @@ - + + com.apple.security.application-groups + + group.mobilemkch + + diff --git a/MobileMkch.xcodeproj/project.pbxproj b/MobileMkch.xcodeproj/project.pbxproj index 42153fb..4f35b65 100644 --- a/MobileMkch.xcodeproj/project.pbxproj +++ b/MobileMkch.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; + }; 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 = ""; + }; 1D8926E02E43CE4C00C5590A = { isa = PBXGroup; children = ( + 1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */, 1D8926EB2E43CE4C00C5590A /* MobileMkch */, + 1D2B9E3D2E4603660097A722 /* FavoritesWidget */, + 1D2B9E382E4603660097A722 /* Frameworks */, 1D8926EA2E43CE4C00C5590A /* Products */, ); sourceTree = ""; @@ -54,6 +124,7 @@ isa = PBXGroup; children = ( 1D8926E92E43CE4C00C5590A /* MobileMkch.app */, + 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -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 = ( diff --git a/MobileMkch.xcodeproj/xcuserdata/platon.xcuserdatad/xcschemes/xcschememanagement.plist b/MobileMkch.xcodeproj/xcuserdata/platon.xcuserdatad/xcschemes/xcschememanagement.plist index 90251dc..5a845ff 100644 --- a/MobileMkch.xcodeproj/xcuserdata/platon.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/MobileMkch.xcodeproj/xcuserdata/platon.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,6 +4,11 @@ SchemeUserState + FavoritesWidgetExtension.xcscheme_^#shared#^_ + + orderHint + 1 + MobileMkch.xcscheme_^#shared#^_ orderHint diff --git a/MobileMkch/APIClient.swift b/MobileMkch/APIClient.swift index baeedb7..82cbd41 100644 --- a/MobileMkch/APIClient.swift +++ b/MobileMkch/APIClient.swift @@ -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) -> 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 } diff --git a/MobileMkch/AppGroup.swift b/MobileMkch/AppGroup.swift new file mode 100644 index 0000000..67e1abd --- /dev/null +++ b/MobileMkch/AppGroup.swift @@ -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 +} + + diff --git a/MobileMkch/BackgroundTaskManager.swift b/MobileMkch/BackgroundTaskManager.swift index da9058d..d35d175 100644 --- a/MobileMkch/BackgroundTaskManager.swift +++ b/MobileMkch/BackgroundTaskManager.swift @@ -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 diff --git a/MobileMkch/BoardsView.swift b/MobileMkch/BoardsView.swift index 49f0349..11cce5d 100644 --- a/MobileMkch/BoardsView.swift +++ b/MobileMkch/BoardsView.swift @@ -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() diff --git a/MobileMkch/Cache.swift b/MobileMkch/Cache.swift index 6a9aa68..d39abd8 100644 --- a/MobileMkch/Cache.swift +++ b/MobileMkch/Cache.swift @@ -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(_ 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(_ 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)") } -} \ No newline at end of file + + func getCommentsStale(forThreadId threadId: Int) -> [Comment]? { + return getStale([Comment].self, forKey: "comments_\(threadId)") + } +} diff --git a/MobileMkch/Info.plist b/MobileMkch/Info.plist index 2016c55..d05b0fa 100644 --- a/MobileMkch/Info.plist +++ b/MobileMkch/Info.plist @@ -10,6 +10,8 @@ background-processing background-fetch + fetch + processing diff --git a/MobileMkch/MobileMkch.entitlements b/MobileMkch/MobileMkch.entitlements index 2eb7e33..2f28a30 100644 --- a/MobileMkch/MobileMkch.entitlements +++ b/MobileMkch/MobileMkch.entitlements @@ -3,6 +3,8 @@ com.apple.security.application-groups - + + group.mobilemkch + diff --git a/MobileMkch/MobileMkchApp.swift b/MobileMkch/MobileMkchApp.swift index 1e8fea0..0fcd0fc 100644 --- a/MobileMkch/MobileMkchApp.swift +++ b/MobileMkch/MobileMkchApp.swift @@ -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) } } diff --git a/MobileMkch/NetworkMonitor.swift b/MobileMkch/NetworkMonitor.swift new file mode 100644 index 0000000..114ee4e --- /dev/null +++ b/MobileMkch/NetworkMonitor.swift @@ -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) + } +} + + diff --git a/MobileMkch/Settings.swift b/MobileMkch/Settings.swift index fff30f4..2e26c97 100644 --- a/MobileMkch/Settings.swift +++ b/MobileMkch/Settings.swift @@ -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? } \ No newline at end of file diff --git a/MobileMkch/SettingsView.swift b/MobileMkch/SettingsView.swift index 27aa004..b805e3c 100644 --- a/MobileMkch/SettingsView.swift +++ b/MobileMkch/SettingsView.swift @@ -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") diff --git a/MobileMkch/ThreadDetailView.swift b/MobileMkch/ThreadDetailView.swift index 3a7f9cb..9f8f87e 100644 --- a/MobileMkch/ThreadDetailView.swift +++ b/MobileMkch/ThreadDetailView.swift @@ -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() diff --git a/MobileMkch/ThreadsView.swift b/MobileMkch/ThreadsView.swift index 963a528..6967d49 100644 --- a/MobileMkch/ThreadsView.swift +++ b/MobileMkch/ThreadsView.swift @@ -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() diff --git a/README.md b/README.md index 73506d1..6edec6d 100644 --- a/README.md +++ b/README.md @@ -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*