Compare commits

..

10 Commits

Author SHA1 Message Date
Lain Iwakura
8cab7bac36
add pics support
Some checks failed
Build IPA / build (push) Has been cancelled
2025-08-08 15:45:20 +03:00
Lain Iwakura
4b20775c61
upd version 2025-08-08 14:45:23 +03:00
Lain Iwakura
c07be8c189
fully works ios 15 widgets! 2025-08-08 14:43:55 +03:00
Lain Iwakura
f1864dc2ba
add live activity 2025-08-08 14:05:55 +03:00
Lain Iwakura
013623290d
оффлайн + виджет 2025-08-08 13:30:26 +03:00
Lain Iwakura
11fdf3186b
offline + виджет 2025-08-08 13:30:00 +03:00
Lain Iwakura
4c090d4295
ласт воркинг билд с некоторыми патчами ибо могу 2025-08-07 22:43:06 +03:00
Lain Iwakura
e2a4c5d8c0
update v2.1.0 - добавил уведы спустя сто лет нахуй 2025-08-07 21:25:50 +03:00
Lain Iwakura
e78c66b0c8
завел уведы вроде как ура 2025-08-07 21:19:40 +03:00
Lain Iwakura
d053ffccc6
я случайно 2025-08-07 21:19:39 +03:00
32 changed files with 1830 additions and 95 deletions

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,299 @@
//
// FavoritesWidget.swift
// FavoritesWidget
//
// Created by Platon on 08.08.2025.
//
import WidgetKit
import SwiftUI
import ActivityKit
private let appGroupId = "group.mobilemkch"
struct FavoriteThreadWidget: Identifiable, Codable {
let id: Int
let title: String
let board: String
let boardDescription: String
let addedDate: Date
}
struct ThreadDTO: Codable, Identifiable {
let id: Int
let title: String
let board: String
}
struct SimpleEntry: TimelineEntry {
let date: Date
let favorites: [FavoriteThreadWidget]
let offline: Bool
}
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), favorites: [
FavoriteThreadWidget(id: 1, title: "Загрузка...", board: "b", boardDescription: "", addedDate: Date())
], offline: false)
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let favs = loadFavorites()
if favs.isEmpty {
Task {
let networkFavs = await loadFromNetwork(board: "b")
completion(SimpleEntry(date: Date(), favorites: networkFavs, offline: false))
}
} else {
completion(SimpleEntry(date: Date(), favorites: favs, offline: loadOffline()))
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
Task {
var favorites = loadFavorites()
var offline = loadOffline()
if favorites.isEmpty {
favorites = await loadFromNetwork(board: "b")
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)
let timeline = Timeline(entries: [entry], policy: .after(refresh))
completion(timeline)
}
}
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] {
[]
}
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(alignment: .leading, spacing: spacing) {
header
content
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(padding)
.background(
Group {
if #available(iOS 17.0, *) {
Color.clear
.containerBackground(for: .widget) {
Color.clear
}
} else {
Color.clear
}
}
)
}
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)
.foregroundColor(.primary)
.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)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
} else {
VStack(alignment: .leading, spacing: 2) {
BoardTag(code: fav.board)
Text(fav.title)
.font(titleFont)
.foregroundColor(.primary)
.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))
}
}
struct FavoritesWidget: Widget {
let kind: String = "FavoritesWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
FavoritesWidgetEntryView(entry: entry)
}
.configurationDisplayName("Избранное MobileMkch")
.description("Показывает избранные треды или топ по выбранной доске.")
}
}
@available(iOS 16.1, *)
struct ThreadActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var latestCommentText: String
var commentsCount: Int
var showTitle: Bool
var showLastComment: Bool
var showCommentCount: Bool
var currentTitle: String
var currentBoard: String
}
var threadId: Int
var title: String
var board: String
}
@available(iOS 16.1, *)
struct ThreadLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: ThreadActivityAttributes.self) { context in
ThreadLiveActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text("/\(context.state.currentBoard)/")
.font(.caption)
.foregroundColor(.blue)
}
DynamicIslandExpandedRegion(.center) {
VStack(alignment: .leading, spacing: 4) {
if context.state.showTitle {
Text(context.state.currentTitle)
.font(.footnote)
.lineLimit(2)
}
if context.state.showLastComment && !context.state.latestCommentText.isEmpty {
Text(context.state.latestCommentText)
.font(.caption2)
.lineLimit(2)
.foregroundColor(.secondary)
}
}
}
DynamicIslandExpandedRegion(.trailing) {
if context.state.showCommentCount {
Label("\(context.state.commentsCount)", systemImage: "text.bubble")
.font(.caption)
}
}
} compactLeading: {
Text("/\(context.state.currentBoard)/")
.font(.caption2)
} compactTrailing: {
if context.state.showCommentCount {
Text("\(context.state.commentsCount)")
.font(.caption2)
}
} minimal: {
Image(systemName: "text.bubble")
}
}
}
}
@available(iOS 16.1, *)
private struct ThreadLiveActivityView: View {
let context: ActivityViewContext<ThreadActivityAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text("/\(context.state.currentBoard)/")
.font(.caption)
.foregroundColor(.blue)
if context.state.showCommentCount {
Label("\(context.state.commentsCount)", systemImage: "text.bubble")
.font(.caption)
}
Spacer()
}
if context.state.showTitle {
Text(context.state.currentTitle)
.font(.footnote)
.lineLimit(2)
}
if context.state.showLastComment && !context.state.latestCommentText.isEmpty {
Text(context.state.latestCommentText)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.padding(12)
}
}

View File

@ -0,0 +1,20 @@
//
// FavoritesWidgetBundle.swift
// FavoritesWidget
//
// Created by Platon on 08.08.2025.
//
import WidgetKit
import SwiftUI
import ActivityKit
@main
struct FavoritesWidgetBundle: WidgetBundle {
var body: some Widget {
FavoritesWidget()
if #available(iOS 16.1, *) {
ThreadLiveActivity()
}
}
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.mobilemkch</string>
</array>
</dict>
</plist>

View File

@ -6,11 +6,52 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
1D2B9E3A2E4603660097A722 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D2B9E392E4603660097A722 /* WidgetKit.framework */; };
1D2B9E3C2E4603660097A722 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D2B9E3B2E4603660097A722 /* SwiftUI.framework */; };
1D2B9E4D2E4603660097A722 /* FavoritesWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
1D2B9E4B2E4603660097A722 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 1D8926E12E43CE4C00C5590A /* Project object */;
proxyType = 1;
remoteGlobalIDString = 1D2B9E362E4603660097A722;
remoteInfo = FavoritesWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
1D2B9E4D2E4603660097A722 /* FavoritesWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FavoritesWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
1D2B9E392E4603660097A722 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FavoritesWidgetExtension.entitlements; sourceTree = "<group>"; };
1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */;
};
1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@ -21,6 +62,14 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
1D2B9E3D2E4603660097A722 /* FavoritesWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */,
);
path = FavoritesWidget;
sourceTree = "<group>";
};
1D8926EB2E43CE4C00C5590A /* MobileMkch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@ -32,6 +81,15 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
1D2B9E342E4603660097A722 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1D2B9E3C2E4603660097A722 /* SwiftUI.framework in Frameworks */,
1D2B9E3A2E4603660097A722 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
1D8926E62E43CE4C00C5590A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -42,10 +100,22 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1D2B9E382E4603660097A722 /* Frameworks */ = {
isa = PBXGroup;
children = (
1D2B9E392E4603660097A722 /* WidgetKit.framework */,
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
1D8926E02E43CE4C00C5590A = {
isa = PBXGroup;
children = (
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */,
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
1D2B9E382E4603660097A722 /* Frameworks */,
1D8926EA2E43CE4C00C5590A /* Products */,
);
sourceTree = "<group>";
@ -54,6 +124,7 @@
isa = PBXGroup;
children = (
1D8926E92E43CE4C00C5590A /* MobileMkch.app */,
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@ -61,6 +132,28 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1D2B9E4E2E4603660097A722 /* Build configuration list for PBXNativeTarget "FavoritesWidgetExtension" */;
buildPhases = (
1D2B9E332E4603660097A722 /* Sources */,
1D2B9E342E4603660097A722 /* Frameworks */,
1D2B9E352E4603660097A722 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
);
name = FavoritesWidgetExtension;
packageProductDependencies = (
);
productName = FavoritesWidgetExtension;
productReference = 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
1D8926E82E43CE4C00C5590A /* MobileMkch */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */;
@ -68,10 +161,12 @@
1D8926E52E43CE4C00C5590A /* Sources */,
1D8926E62E43CE4C00C5590A /* Frameworks */,
1D8926E72E43CE4C00C5590A /* Resources */,
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
@ -93,6 +188,9 @@
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
1D2B9E362E4603660097A722 = {
CreatedOnToolsVersion = 16.2;
};
1D8926E82E43CE4C00C5590A = {
CreatedOnToolsVersion = 16.2;
};
@ -113,11 +211,19 @@
projectRoot = "";
targets = (
1D8926E82E43CE4C00C5590A /* MobileMkch */,
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
1D2B9E352E4603660097A722 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
1D8926E72E43CE4C00C5590A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -128,6 +234,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1D2B9E332E4603660097A722 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
1D8926E52E43CE4C00C5590A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -137,7 +250,73 @@
};
/* 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 = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
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 = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
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 +441,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\"";
@ -281,7 +461,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "2.0.0-ios";
MARKETING_VERSION = "2.1.1-ios";
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@ -298,6 +478,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\"";
@ -317,7 +498,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "2.0.0-ios";
MARKETING_VERSION = "2.1.1-ios";
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@ -332,6 +513,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1D2B9E4E2E4603660097A722 /* Build configuration list for PBXNativeTarget "FavoritesWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1D2B9E4F2E4603660097A722 /* Debug */,
1D2B9E502E4603660097A722 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

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

View File

@ -1,12 +1,30 @@
import Foundation
import Network
struct UploadFile {
let name: String
let filename: String
let mimeType: String
let data: Data
}
class APIClient: ObservableObject {
private let baseURL = "https://mkch.pooziqo.xyz"
private let apiURL = "https://mkch.pooziqo.xyz/api"
private let session = URLSession.shared
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 20
config.timeoutIntervalForResource = 40
config.httpAdditionalHeaders = [
"Accept": "application/json",
"Accept-Language": Locale.preferredLanguages.first ?? "ru-RU",
"User-Agent": self.userAgent
]
return URLSession(configuration: config)
}()
private var authKey: String = ""
private var passcode: String = ""
private let userAgent = "MobileMkch/2.0.0-ios-alpha"
private let userAgent = "MobileMkch/2.1.1-ios-alpha"
func authenticate(authKey: String, completion: @escaping (Error?) -> Void) {
self.authKey = authKey
@ -142,6 +160,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
@ -154,18 +180,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = 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 {
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 {
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Нет данных", code: 0)))
}
return
}
@ -181,6 +219,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
@ -193,18 +239,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = 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 {
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 {
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Нет данных", code: 0)))
}
return
}
@ -220,6 +278,14 @@ class APIClient: ObservableObject {
}
func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённого треда", code: 0)))
}
return
}
if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) {
completion(.success(cachedThread))
return
@ -232,18 +298,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = 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 {
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Ошибка получения треда", code: 0)))
}
return
}
guard let data = data else {
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Нет данных", code: 0)))
}
return
}
@ -259,6 +337,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
@ -271,18 +357,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = 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 {
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 {
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Нет данных", code: 0)))
}
return
}
@ -346,21 +444,21 @@ class APIClient: ObservableObject {
}
}
func createThread(boardCode: String, title: String, text: String, passcode: String, completion: @escaping (Error?) -> Void) {
func createThread(boardCode: String, title: String, text: String, passcode: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
if !passcode.isEmpty {
loginWithPasscode(passcode: passcode) { error in
if let error = error {
completion(error)
return
}
self.performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion)
self.performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion)
}
} else {
performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion)
performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion)
}
}
private func performCreateThread(boardCode: String, title: String, text: String, completion: @escaping (Error?) -> Void) {
private func performCreateThread(boardCode: String, title: String, text: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
let formURL = "\(baseURL)/boards/board/\(boardCode)/new"
let url = URL(string: formURL)!
@ -394,19 +492,30 @@ class APIClient: ObservableObject {
return
}
var postRequest = URLRequest(url: url)
postRequest.httpMethod = "POST"
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
if files.isEmpty {
var formData = URLComponents()
formData.queryItems = [
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
URLQueryItem(name: "title", value: title),
URLQueryItem(name: "text", value: text)
]
var postRequest = URLRequest(url: url)
postRequest.httpMethod = "POST"
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
postRequest.httpBody = formData.query?.data(using: .utf8)
} else {
let boundary = "Boundary-\(UUID().uuidString)"
postRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
let body = self.buildMultipartBody(parameters: [
"csrfmiddlewaretoken": csrfToken,
"title": title,
"text": text
], files: files, boundary: boundary)
postRequest.httpBody = body
}
self.session.dataTask(with: postRequest) { _, postResponse, postError in
DispatchQueue.main.async {
@ -429,21 +538,21 @@ class APIClient: ObservableObject {
}.resume()
}
func addComment(boardCode: String, threadId: Int, text: String, passcode: String, completion: @escaping (Error?) -> Void) {
func addComment(boardCode: String, threadId: Int, text: String, passcode: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
if !passcode.isEmpty {
loginWithPasscode(passcode: passcode) { error in
if let error = error {
completion(error)
return
}
self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion)
self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion)
}
} else {
performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion)
performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion)
}
}
private func performAddComment(boardCode: String, threadId: Int, text: String, completion: @escaping (Error?) -> Void) {
private func performAddComment(boardCode: String, threadId: Int, text: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
let formURL = "\(baseURL)/boards/board/\(boardCode)/thread/\(threadId)/comment"
let url = URL(string: formURL)!
@ -477,18 +586,28 @@ class APIClient: ObservableObject {
return
}
var postRequest = URLRequest(url: url)
postRequest.httpMethod = "POST"
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
if files.isEmpty {
var formData = URLComponents()
formData.queryItems = [
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
URLQueryItem(name: "text", value: text)
]
var postRequest = URLRequest(url: url)
postRequest.httpMethod = "POST"
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
postRequest.httpBody = formData.query?.data(using: .utf8)
} else {
let boundary = "Boundary-\(UUID().uuidString)"
postRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
let body = self.buildMultipartBody(parameters: [
"csrfmiddlewaretoken": csrfToken,
"text": text
], files: files, boundary: boundary)
postRequest.httpBody = body
}
self.session.dataTask(with: postRequest) { _, postResponse, postError in
DispatchQueue.main.async {
@ -526,6 +645,25 @@ class APIClient: ObservableObject {
return String(html[range])
}
private func buildMultipartBody(parameters: [String: String], files: [UploadFile], boundary: String) -> Data {
var body = Data()
let boundaryPrefix = "--\(boundary)\r\n"
for (key, value) in parameters {
body.append(boundaryPrefix.data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
for file in files {
body.append(boundaryPrefix.data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(file.mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(file.data)
body.append("\r\n".data(using: .utf8)!)
}
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
return body
}
func checkNewThreads(forBoard boardCode: String, lastKnownThreadId: Int, completion: @escaping (Result<[Thread], Error>) -> Void) {
let url = URL(string: "\(apiURL)/board/\(boardCode)")!
var request = URLRequest(url: url)
@ -550,9 +688,31 @@ class APIClient: ObservableObject {
}
do {
let threads = try JSONDecoder().decode([Thread].self, from: data)
let newThreads = threads.filter { $0.id > lastKnownThreadId }
let currentThreads = try JSONDecoder().decode([Thread].self, from: data)
let savedThreadsKey = "savedThreads_\(boardCode)"
let savedThreadsData = UserDefaults.standard.data(forKey: savedThreadsKey)
if let savedThreadsData = savedThreadsData,
let savedThreads = try? JSONDecoder().decode([Thread].self, from: savedThreadsData) {
let savedThreadIds = Set(savedThreads.map { $0.id })
let newThreads = currentThreads.filter { !savedThreadIds.contains($0.id) }
if !newThreads.isEmpty {
print("Найдено \(newThreads.count) новых тредов в /\(boardCode)/")
}
completion(.success(newThreads))
} else {
print("Первая синхронизация для /\(boardCode)/ - сохраняем \(currentThreads.count) тредов")
completion(.success([]))
}
if let encodedData = try? JSONEncoder().encode(currentThreads) {
UserDefaults.standard.set(encodedData, forKey: savedThreadsKey)
}
} catch {
completion(.failure(error))
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import PhotosUI
struct AddCommentView: View {
let boardCode: String
@ -12,6 +13,8 @@ struct AddCommentView: View {
@State private var errorMessage: String?
@State private var showingSuccess = false
@FocusState private var isTextFocused: Bool
@State private var pickedImages: [UIImage] = []
@State private var showPicker: Bool = false
var body: some View {
NavigationView {
@ -56,6 +59,38 @@ struct AddCommentView: View {
)
}
VStack(alignment: .leading, spacing: 8) {
Text("Фото")
.font(.headline)
.foregroundColor(.primary)
Button {
showPicker = true
} label: {
HStack {
Image(systemName: "photo.on.rectangle")
Text("Выбрать фото")
Spacer()
}
.padding(12)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
if !pickedImages.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(pickedImages.enumerated()), id: \.offset) { _, img in
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 64, height: 64)
.clipped()
.cornerRadius(8)
}
}
}
}
}
HStack(spacing: 8) {
Image(systemName: settings.passcode.isEmpty ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.foregroundColor(settings.passcode.isEmpty ? .orange : .green)
@ -126,6 +161,11 @@ struct AddCommentView: View {
} message: {
Text("Комментарий добавлен")
}
.sheet(isPresented: $showPicker) {
ImagePickerView(selectionLimit: 4) { images in
pickedImages = images
}
}
}
}
@ -139,7 +179,8 @@ struct AddCommentView: View {
boardCode: boardCode,
threadId: threadId,
text: text,
passcode: settings.passcode
passcode: settings.passcode,
files: buildUploadFiles()
) { error in
DispatchQueue.main.async {
self.isLoading = false
@ -152,6 +193,22 @@ struct AddCommentView: View {
}
}
}
private func buildUploadFiles() -> [UploadFile] {
var result: [UploadFile] = []
for (idx, img) in pickedImages.enumerated() {
if let data = img.jpegData(compressionQuality: 0.9) {
let file = UploadFile(
name: "files",
filename: "photo_\(idx + 1).jpg",
mimeType: "image/jpeg",
data: data
)
result.append(file)
}
}
return result
}
}
#Preview {

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,8 +6,9 @@ class BackgroundTaskManager {
static let shared = BackgroundTaskManager()
private var backgroundTaskIdentifier: String {
if let savedIdentifier = UserDefaults.standard.string(forKey: "BackgroundTaskIdentifier") {
return savedIdentifier
if let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String],
let first = identifiers.first {
return first
}
return "com.mkch.MobileMkch.backgroundrefresh"
}
@ -24,11 +25,12 @@ class BackgroundTaskManager {
func scheduleBackgroundTask() {
let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier)
let settings = Settings()
let interval = TimeInterval(settings.notificationInterval)
let interval = TimeInterval(settings.notificationInterval * 60)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
do {
try BGTaskScheduler.shared.submit(request)
print("Фоновая задача запланирована на \(interval) секунд")
} catch {
print("Не удалось запланировать фоновую задачу: \(error)")
}
@ -51,20 +53,18 @@ class BackgroundTaskManager {
for boardCode in notificationManager.subscribedBoards {
group.enter()
let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)")
APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in
APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: 0) { result in
switch result {
case .success(let newThreads):
if !newThreads.isEmpty {
hasNewThreads = true
print("Найдено \(newThreads.count) новых тредов в /\(boardCode)/")
for thread in newThreads {
self.notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
}
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
}
case .failure:
break
case .failure(let error):
print("Ошибка проверки новых тредов для /\(boardCode)/: \(error)")
}
group.leave()
}

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import SwiftUI
import PhotosUI
struct CreateThreadView: View {
let boardCode: String
@ -13,6 +14,8 @@ struct CreateThreadView: View {
@State private var showingSuccess = false
@FocusState private var titleFocused: Bool
@FocusState private var textFocused: Bool
@State private var pickedImages: [UIImage] = []
@State private var showPicker: Bool = false
var body: some View {
NavigationView {
@ -41,6 +44,38 @@ struct CreateThreadView: View {
)
}
VStack(alignment: .leading, spacing: 8) {
Text("Фото")
.font(.headline)
.foregroundColor(.primary)
Button {
showPicker = true
} label: {
HStack {
Image(systemName: "photo.on.rectangle")
Text("Выбрать фото")
Spacer()
}
.padding(12)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
if !pickedImages.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(pickedImages.enumerated()), id: \.offset) { _, img in
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 64, height: 64)
.clipped()
.cornerRadius(8)
}
}
}
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Содержание")
@ -149,6 +184,11 @@ struct CreateThreadView: View {
} message: {
Text("Тред создан")
}
.sheet(isPresented: $showPicker) {
ImagePickerView(selectionLimit: 4) { images in
pickedImages = images
}
}
}
}
@ -162,7 +202,8 @@ struct CreateThreadView: View {
boardCode: boardCode,
title: title,
text: text,
passcode: settings.passcode
passcode: settings.passcode,
files: buildUploadFiles()
) { error in
DispatchQueue.main.async {
self.isLoading = false
@ -175,6 +216,22 @@ struct CreateThreadView: View {
}
}
}
private func buildUploadFiles() -> [UploadFile] {
var result: [UploadFile] = []
for (idx, img) in pickedImages.enumerated() {
if let data = img.jpegData(compressionQuality: 0.9) {
let file = UploadFile(
name: "files",
filename: "photo_\(idx + 1).jpg",
mimeType: "image/jpeg",
data: data
)
result.append(file)
}
}
return result
}
}
#Preview {

View File

@ -0,0 +1,57 @@
import SwiftUI
import PhotosUI
struct ImagePickerView: UIViewControllerRepresentable {
let selectionLimit: Int
let onComplete: ([UIImage]) -> Void
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = selectionLimit
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onComplete: onComplete)
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onComplete: ([UIImage]) -> Void
init(onComplete: @escaping ([UIImage]) -> Void) {
self.onComplete = onComplete
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard !results.isEmpty else {
picker.dismiss(animated: true)
return
}
let providers = results.map { $0.itemProvider }
var images: [UIImage] = []
let group = DispatchGroup()
for provider in providers {
if provider.canLoadObject(ofClass: UIImage.self) {
group.enter()
provider.loadObject(ofClass: UIImage.self) { object, _ in
if let img = object as? UIImage {
images.append(img)
}
group.leave()
}
}
}
group.notify(queue: .main) {
self.onComplete(images)
picker.dismiss(animated: true)
}
}
}
}

View File

@ -6,10 +6,16 @@
<array>
<string>com.mkch.MobileMkch.backgroundrefresh</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Нужно для выбора фото и загрузки вложений</string>
<key>UIBackgroundModes</key>
<array>
<string>background-processing</string>
<string>background-fetch</string>
<string>fetch</string>
<string>processing</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,201 @@
import Foundation
import ActivityKit
@available(iOS 16.1, *)
final class LiveActivityManager {
static let shared = LiveActivityManager()
private let storeKey = "activeThreadActivities"
private var threadIdToActivityId: [Int: String] = [:]
private var tickerTask: Task<Void, Never>?
private var tickerActivityId: String?
private init() {
load()
}
func isActive(threadId: Int) -> Bool {
return threadIdToActivityId[threadId] != nil && activity(for: threadId) != nil
}
func start(for detail: ThreadDetail, comments: [Comment], settings: Settings) {
let latestText = comments.last?.formattedText ?? ""
let count = comments.count
let attributes = ThreadActivityAttributes(threadId: detail.id, title: detail.title, board: detail.board)
let state = ThreadActivityAttributes.ContentState(
latestCommentText: latestText,
commentsCount: count,
showTitle: settings.liveActivityShowTitle,
showLastComment: settings.liveActivityShowLastComment,
showCommentCount: settings.liveActivityShowCommentCount,
currentTitle: detail.title,
currentBoard: detail.board
)
do {
let activity = try Activity<ThreadActivityAttributes>.request(attributes: attributes, contentState: state, pushType: nil)
threadIdToActivityId[detail.id] = activity.id
save()
} catch {
}
}
func update(threadId: Int, comments: [Comment], settings: Settings) {
guard let activity = activity(for: threadId) else { return }
let latestText = comments.last?.formattedText ?? ""
let count = comments.count
let state = ThreadActivityAttributes.ContentState(
latestCommentText: latestText,
commentsCount: count,
showTitle: settings.liveActivityShowTitle,
showLastComment: settings.liveActivityShowLastComment,
showCommentCount: settings.liveActivityShowCommentCount,
currentTitle: "",
currentBoard: ""
)
Task {
await activity.update(using: state)
}
}
func end(threadId: Int) {
guard let activity = activity(for: threadId) else { return }
Task {
await activity.end(dismissalPolicy: .immediate)
}
threadIdToActivityId.removeValue(forKey: threadId)
save()
}
private func activity(for threadId: Int) -> Activity<ThreadActivityAttributes>? {
guard let id = threadIdToActivityId[threadId] else { return nil }
return Activity<ThreadActivityAttributes>.activities.first(where: { $0.id == id })
}
private func save() {
if let data = try? JSONEncoder().encode(threadIdToActivityId) {
UserDefaults.standard.set(data, forKey: storeKey)
}
}
private func load() {
if let data = UserDefaults.standard.data(forKey: storeKey),
let map = try? JSONDecoder().decode([Int: String].self, from: data) {
threadIdToActivityId = map
}
}
}
@available(iOS 16.1, *)
struct ThreadActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var latestCommentText: String
var commentsCount: Int
var showTitle: Bool
var showLastComment: Bool
var showCommentCount: Bool
var currentTitle: String
var currentBoard: String
}
var threadId: Int
var title: String
var board: String
}
@available(iOS 16.1, *)
extension LiveActivityManager {
var isTickerRunning: Bool { tickerTask != nil }
func startTicker(settings: Settings, apiClient: APIClient) {
stopTicker()
let attributes = ThreadActivityAttributes(threadId: -1, title: "", board: "")
let initial = ThreadActivityAttributes.ContentState(
latestCommentText: "",
commentsCount: 0,
showTitle: settings.liveActivityShowTitle,
showLastComment: settings.liveActivityShowLastComment,
showCommentCount: settings.liveActivityShowCommentCount,
currentTitle: "",
currentBoard: ""
)
do {
let activity = try Activity<ThreadActivityAttributes>.request(attributes: attributes, contentState: initial, pushType: nil)
tickerActivityId = activity.id
} catch {
return
}
tickerTask = Task { [weak self] in
while !(Task.isCancelled) {
guard let self = self, let activityId = self.tickerActivityId,
let activity = Activity<ThreadActivityAttributes>.activities.first(where: { $0.id == activityId }) else { break }
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
let boardHandler: (String) -> Void = { boardCode in
apiClient.getThreads(forBoard: boardCode) { result in
switch result {
case .success(let threads):
guard let thread = threads.randomElement() else {
cont.resume()
return
}
apiClient.getFullThread(boardCode: boardCode, threadId: thread.id) { full in
switch full {
case .success(let (detail, comments)):
let text = comments.last?.formattedText ?? detail.text
let count = comments.count
let state = ThreadActivityAttributes.ContentState(
latestCommentText: text,
commentsCount: count,
showTitle: settings.liveActivityShowTitle,
showLastComment: settings.liveActivityShowLastComment,
showCommentCount: settings.liveActivityShowCommentCount,
currentTitle: detail.title,
currentBoard: detail.board
)
Task { await activity.update(using: state) }
cont.resume()
case .failure:
cont.resume()
}
}
case .failure:
cont.resume()
}
}
}
if settings.liveActivityTickerRandomBoard {
apiClient.getBoards { boardsResult in
switch boardsResult {
case .success(let boards):
if let random = boards.randomElement() {
boardHandler(random.code)
} else {
cont.resume()
}
case .failure:
cont.resume()
}
}
} else {
let code = settings.liveActivityTickerBoardCode.isEmpty ? settings.lastBoard : settings.liveActivityTickerBoardCode
boardHandler(code)
}
}
try? await Task.sleep(nanoseconds: UInt64(max(settings.liveActivityTickerInterval, 5)) * 1_000_000_000)
}
}
}
func stopTicker() {
tickerTask?.cancel()
tickerTask = nil
if let id = tickerActivityId,
let activity = Activity<ThreadActivityAttributes>.activities.first(where: { $0.id == id }) {
Task { await activity.end(dismissalPolicy: .immediate) }
}
tickerActivityId = nil
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.mobilemkch</string>
</array>
</dict>
</plist>

View File

@ -6,6 +6,7 @@
//
import SwiftUI
import UserNotifications
@main
struct MobileMkchApp: App {
@ -13,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 {
@ -22,6 +24,25 @@ struct MobileMkchApp: App {
}
}
private func setupNotifications() {
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
notificationManager.requestPermission { granted in
if granted {
print("Разрешения на уведомления получены")
} else {
print("Разрешения на уведомления отклонены")
}
}
}
private func handleNotificationLaunch() {
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let _ = scene.session.userInfo {
print("Приложение запущено из уведомления")
}
notificationManager.clearBadge()
}
var body: some Scene {
WindowGroup {
Group {
@ -32,12 +53,15 @@ struct MobileMkchApp: App {
.environmentObject(settings)
.environmentObject(apiClient)
.environmentObject(notificationManager)
.environmentObject(networkMonitor)
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
}
}
.onAppear {
BackgroundTaskManager.shared.registerBackgroundTasks()
setupBackgroundTasks()
setupNotifications()
handleNotificationLaunch()
}
}
}

View File

@ -1,5 +1,9 @@
import Foundation
private enum DateFormatterCache {
static let iso8601 = ISO8601DateFormatter()
}
struct Board: Codable, Identifiable {
let code: String
let description: String
@ -18,8 +22,7 @@ struct Thread: Codable, Identifiable {
let files: [String]
var creationDate: Date {
let formatter = ISO8601DateFormatter()
return formatter.date(from: creation) ?? Date()
return DateFormatterCache.iso8601.date(from: creation) ?? Date()
}
var ratingValue: Int {
@ -40,8 +43,7 @@ struct ThreadDetail: Codable, Identifiable {
let files: [String]
var creationDate: Date {
let formatter = ISO8601DateFormatter()
return formatter.date(from: creation) ?? Date()
return DateFormatterCache.iso8601.date(from: creation) ?? Date()
}
}
@ -52,8 +54,7 @@ struct Comment: Codable, Identifiable {
let files: [String]
var creationDate: Date {
let formatter = ISO8601DateFormatter()
return formatter.date(from: creation) ?? Date()
return DateFormatterCache.iso8601.date(from: creation) ?? Date()
}
var formattedText: String {

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

@ -2,6 +2,18 @@ import Foundation
import UserNotifications
import UIKit
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .sound, .badge])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
completionHandler()
}
}
class NotificationManager: ObservableObject {
static let shared = NotificationManager()
@ -17,11 +29,20 @@ class NotificationManager: ObservableObject {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
DispatchQueue.main.async {
self.isNotificationsEnabled = granted
if granted {
self.registerForRemoteNotifications()
}
completion(granted)
}
}
}
private func registerForRemoteNotifications() {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
func checkNotificationStatus() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
@ -33,36 +54,90 @@ class NotificationManager: ObservableObject {
func subscribeToBoard(_ boardCode: String) {
subscribedBoards.insert(boardCode)
saveSubscribedBoards()
BackgroundTaskManager.shared.scheduleBackgroundTask()
}
private func syncThreadsForBoard(_ boardCode: String) {
let url = URL(string: "https://mkch.pooziqo.xyz/api/board/\(boardCode)")!
var request = URLRequest(url: url)
request.setValue("MobileMkch/2.1.1-ios-alpha", forHTTPHeaderField: "User-Agent")
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let threads = try? JSONDecoder().decode([Thread].self, from: data) {
let savedThreadsKey = "savedThreads_\(boardCode)"
if let encodedData = try? JSONEncoder().encode(threads) {
UserDefaults.standard.set(encodedData, forKey: savedThreadsKey)
print("Синхронизировано \(threads.count) тредов для /\(boardCode)/")
}
}
}.resume()
}
func unsubscribeFromBoard(_ boardCode: String) {
subscribedBoards.remove(boardCode)
saveSubscribedBoards()
let savedThreadsKey = "savedThreads_\(boardCode)"
UserDefaults.standard.removeObject(forKey: savedThreadsKey)
}
func scheduleNotification(for thread: Thread, boardCode: String) {
guard isNotificationsEnabled else { return }
let content = UNMutableNotificationContent()
content.title = "Новый тред"
content.body = "\(thread.title) в /\(boardCode)/"
content.sound = .default
content.badge = 1
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false)
let request = UNNotificationRequest(identifier: "thread_\(thread.id)", content: content, trigger: trigger)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: "thread_\(thread.id)_\(boardCode)", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Ошибка планирования уведомления: \(error)")
}
}
}
func scheduleTestNotification() {
guard isNotificationsEnabled else { return }
let content = UNMutableNotificationContent()
content.title = "Тестовое уведомление"
content.body = "Новый тред: Тестовый тред в /test/"
content.sound = .default
content.badge = 1
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let request = UNNotificationRequest(identifier: "test_notification", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Ошибка планирования тестового уведомления: \(error)")
}
}
}
func clearAllNotifications() {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
}
func clearBadge() {
DispatchQueue.main.async {
UIApplication.shared.applicationIconBadgeNumber = 0
}
}
func clearAllSavedThreads() {
for boardCode in subscribedBoards {
let savedThreadsKey = "savedThreads_\(boardCode)"
UserDefaults.standard.removeObject(forKey: savedThreadsKey)
}
print("Очищены все сохраненные треды")
}
private func loadSubscribedBoards() {

View File

@ -7,6 +7,8 @@ struct NotificationSettingsView: View {
@State private var showingPermissionAlert = false
@State private var boards: [Board] = []
@State private var isLoadingBoards = false
@State private var isCheckingThreads = false
@State private var showingTestNotification = false
var body: some View {
VStack {
@ -40,7 +42,7 @@ struct NotificationSettingsView: View {
.cornerRadius(8)
.padding(.horizontal)
Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.")
Text("Уведомления находятся в бета-версии и могут работать нестабильно.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
@ -71,12 +73,59 @@ struct NotificationSettingsView: View {
}
.onChange(of: settings.notificationInterval) { _ in
settings.saveSettings()
BackgroundTaskManager.shared.scheduleBackgroundTask()
}
Button("Проверить новые треды сейчас") {
checkNewThreadsNow()
Button(action: {
showingTestNotification = true
notificationManager.scheduleTestNotification()
}) {
HStack {
Image(systemName: "bell.badge")
Text("Отправить тестовое уведомление")
}
}
.foregroundColor(.blue)
.disabled(!notificationManager.isNotificationsEnabled)
Button(action: {
isCheckingThreads = true
checkNewThreadsNow()
}) {
HStack {
if isCheckingThreads {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
Text("Проверить новые треды сейчас")
}
}
.foregroundColor(.blue)
.disabled(isCheckingThreads || notificationManager.subscribedBoards.isEmpty)
Button(action: {
syncAllBoards()
}) {
HStack {
Image(systemName: "arrow.triangle.2.circlepath")
Text("Синхронизировать все доски")
}
}
.foregroundColor(.orange)
.disabled(notificationManager.subscribedBoards.isEmpty)
Button(action: {
notificationManager.clearAllSavedThreads()
}) {
HStack {
Image(systemName: "trash")
Text("Очистить все сохраненные треды")
}
}
.foregroundColor(.red)
.disabled(notificationManager.subscribedBoards.isEmpty)
}
}
@ -89,6 +138,9 @@ struct NotificationSettingsView: View {
Text("Загрузка досок...")
.foregroundColor(.secondary)
}
} else if boards.isEmpty {
Text("Не удалось загрузить доски")
.foregroundColor(.secondary)
} else {
ForEach(boards) { board in
HStack {
@ -117,6 +169,22 @@ struct NotificationSettingsView: View {
}
}
}
Section(header: Text("Статус")) {
HStack {
Text("Разрешения")
Spacer()
Text(notificationManager.isNotificationsEnabled ? "Включены" : "Отключены")
.foregroundColor(notificationManager.isNotificationsEnabled ? .green : .red)
}
HStack {
Text("Подписки")
Spacer()
Text("\(notificationManager.subscribedBoards.count) досок")
.foregroundColor(.secondary)
}
}
}
}
}
@ -139,6 +207,11 @@ struct NotificationSettingsView: View {
} message: {
Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках")
}
.alert("Тестовое уведомление", isPresented: $showingTestNotification) {
Button("OK") { }
} message: {
Text("Тестовое уведомление отправлено. Проверьте, получили ли вы его.")
}
}
}
@ -154,24 +227,56 @@ extension NotificationSettingsView {
private func checkNewThreadsNow() {
guard !notificationManager.subscribedBoards.isEmpty else { return }
for boardCode in notificationManager.subscribedBoards {
let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)")
let group = DispatchGroup()
var foundNewThreads = false
apiClient.checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in
for boardCode in notificationManager.subscribedBoards {
group.enter()
apiClient.checkNewThreads(forBoard: boardCode, lastKnownThreadId: 0) { result in
DispatchQueue.main.async {
switch result {
case .success(let newThreads):
if !newThreads.isEmpty {
foundNewThreads = true
for thread in newThreads {
notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
}
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
}
case .failure:
break
case .failure(let error):
print("Ошибка проверки тредов для /\(boardCode)/: \(error)")
}
group.leave()
}
}
}
group.notify(queue: .main) {
isCheckingThreads = false
if !foundNewThreads {
print("Новых тредов не найдено")
}
}
}
private func syncAllBoards() {
for boardCode in notificationManager.subscribedBoards {
let savedThreadsKey = "savedThreads_\(boardCode)"
UserDefaults.standard.removeObject(forKey: savedThreadsKey)
let url = URL(string: "https://mkch.pooziqo.xyz/api/board/\(boardCode)")!
var request = URLRequest(url: url)
request.setValue("MobileMkch/2.1.1-ios-alpha", forHTTPHeaderField: "User-Agent")
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let threads = try? JSONDecoder().decode([Thread].self, from: data) {
if let encodedData = try? JSONEncoder().encode(threads) {
UserDefaults.standard.set(encodedData, forKey: savedThreadsKey)
print("Синхронизировано \(threads.count) тредов для /\(boardCode)/")
}
}
}.resume()
}
}

View File

@ -1,4 +1,5 @@
import Foundation
import WidgetKit
class Settings: ObservableObject {
@Published var theme: String = "dark"
@ -14,12 +15,22 @@ class Settings: ObservableObject {
@Published var notificationsEnabled: Bool = false
@Published var notificationInterval: Int = 300
@Published var favoriteThreads: [FavoriteThread] = []
@Published var offlineMode: Bool = false
@Published var liveActivityEnabled: Bool = false
@Published var liveActivityShowTitle: Bool = true
@Published var liveActivityShowLastComment: Bool = true
@Published var liveActivityShowCommentCount: Bool = true
@Published var liveActivityTickerEnabled: Bool = false
@Published var liveActivityTickerRandomBoard: Bool = true
@Published var liveActivityTickerBoardCode: String = "b"
@Published var liveActivityTickerInterval: Int = 15
private let userDefaults = UserDefaults.standard
private let settingsKey = "MobileMkchSettings"
init() {
loadSettings()
mirrorStateToAppGroup()
}
func loadSettings() {
@ -38,7 +49,17 @@ class Settings: ObservableObject {
self.notificationsEnabled = settings.notificationsEnabled
self.notificationInterval = settings.notificationInterval
self.favoriteThreads = settings.favoriteThreads
self.offlineMode = settings.offlineMode ?? false
self.liveActivityEnabled = settings.liveActivityEnabled ?? false
self.liveActivityShowTitle = settings.liveActivityShowTitle ?? true
self.liveActivityShowLastComment = settings.liveActivityShowLastComment ?? true
self.liveActivityShowCommentCount = settings.liveActivityShowCommentCount ?? true
self.liveActivityTickerEnabled = settings.liveActivityTickerEnabled ?? false
self.liveActivityTickerRandomBoard = settings.liveActivityTickerRandomBoard ?? true
self.liveActivityTickerBoardCode = settings.liveActivityTickerBoardCode ?? "b"
self.liveActivityTickerInterval = settings.liveActivityTickerInterval ?? 15
}
mirrorStateToAppGroup()
}
func saveSettings() {
@ -56,11 +77,24 @@ class Settings: ObservableObject {
notificationsEnabled: notificationsEnabled,
notificationInterval: notificationInterval,
favoriteThreads: favoriteThreads
,
offlineMode: offlineMode
,
liveActivityEnabled: liveActivityEnabled,
liveActivityShowTitle: liveActivityShowTitle,
liveActivityShowLastComment: liveActivityShowLastComment,
liveActivityShowCommentCount: liveActivityShowCommentCount,
liveActivityTickerEnabled: liveActivityTickerEnabled,
liveActivityTickerRandomBoard: liveActivityTickerRandomBoard,
liveActivityTickerBoardCode: liveActivityTickerBoardCode,
liveActivityTickerInterval: liveActivityTickerInterval
)
if let data = try? JSONEncoder().encode(settingsData) {
userDefaults.set(data, forKey: settingsKey)
}
mirrorStateToAppGroup()
WidgetCenter.shared.reloadAllTimelines()
}
func resetSettings() {
@ -77,6 +111,7 @@ class Settings: ObservableObject {
notificationsEnabled = false
notificationInterval = 300
favoriteThreads = []
offlineMode = false
saveSettings()
}
@ -100,6 +135,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 +162,13 @@ struct SettingsData: Codable {
let notificationsEnabled: Bool
let notificationInterval: Int
let favoriteThreads: [FavoriteThread]
let offlineMode: Bool?
let liveActivityEnabled: Bool?
let liveActivityShowTitle: Bool?
let liveActivityShowLastComment: Bool?
let liveActivityShowCommentCount: Bool?
let liveActivityTickerEnabled: Bool?
let liveActivityTickerRandomBoard: Bool?
let liveActivityTickerBoardCode: String?
let liveActivityTickerInterval: Int?
}

View File

@ -1,11 +1,14 @@
import SwiftUI
import Combine
import Darwin
import ActivityKit
struct SettingsView: View {
@EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient
@EnvironmentObject var networkMonitor: NetworkMonitor
@State private var isTickerRunning = false
@State private var showingAbout = false
@State private var showingInfo = false
@State private var testKeyResult: String?
@ -110,6 +113,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")
@ -182,6 +205,65 @@ struct SettingsView: View {
}
.padding(.trailing, 8)
)
if #available(iOS 16.1, *) {
Toggle("Live Activity", isOn: $settings.liveActivityEnabled)
.onReceive(Just(settings.liveActivityEnabled)) { _ in
settings.saveSettings()
}
if settings.liveActivityEnabled {
Toggle("Показывать заголовок", isOn: $settings.liveActivityShowTitle)
.onReceive(Just(settings.liveActivityShowTitle)) { _ in settings.saveSettings() }
Toggle("Показывать последний коммент", isOn: $settings.liveActivityShowLastComment)
.onReceive(Just(settings.liveActivityShowLastComment)) { _ in settings.saveSettings() }
Toggle("Показывать счётчик", isOn: $settings.liveActivityShowCommentCount)
.onReceive(Just(settings.liveActivityShowCommentCount)) { _ in settings.saveSettings() }
Toggle("Тикер случайных тредов", isOn: $settings.liveActivityTickerEnabled)
.onReceive(Just(settings.liveActivityTickerEnabled)) { _ in settings.saveSettings() }
if settings.liveActivityTickerEnabled {
Toggle("Случайная борда", isOn: $settings.liveActivityTickerRandomBoard)
.onReceive(Just(settings.liveActivityTickerRandomBoard)) { _ in settings.saveSettings() }
if !settings.liveActivityTickerRandomBoard {
HStack {
Text("Код борды")
TextField("b", text: $settings.liveActivityTickerBoardCode)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
}
.onReceive(Just(settings.liveActivityTickerBoardCode)) { _ in settings.saveSettings() }
}
HStack {
Text("Интервал, сек")
Spacer()
Stepper(value: $settings.liveActivityTickerInterval, in: 5...120, step: 5) {
Text("\(settings.liveActivityTickerInterval)")
}
}
.onReceive(Just(settings.liveActivityTickerInterval)) { _ in settings.saveSettings() }
HStack(spacing: 12) {
Button("Старт тикера") {
LiveActivityManager.shared.startTicker(settings: settings, apiClient: apiClient)
isTickerRunning = true
}
.buttonStyle(.bordered)
.tint(.green)
Button("Стоп тикера") {
LiveActivityManager.shared.stopTicker()
isTickerRunning = false
}
.buttonStyle(.bordered)
.tint(.red)
Spacer()
Text(isTickerRunning ? "Работает" : "Остановлен")
.font(.caption)
.foregroundColor(isTickerRunning ? .green : .secondary)
}
.onAppear { isTickerRunning = LiveActivityManager.shared.isTickerRunning }
}
Text("В фоне частые обновления ограничены системой")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
Section("Управление кэшем") {
@ -390,7 +472,7 @@ struct AboutView: View {
Divider()
VStack(alignment: .leading, spacing: 8) {
Text("Версия: 2.0.0-ios-alpha (Always in alpha lol)")
Text("Версия: 2.1.1-ios-alpha (Always in alpha lol)")
Text("Автор: w^x (лейн, платон, а похуй как угодно)")
Text("Разработано с <3 на Свифт")
}
@ -413,6 +495,7 @@ struct AboutView: View {
struct DebugMenuView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var notificationManager: NotificationManager
@State private var liveActivityStarted = false
var body: some View {
NavigationView {
@ -434,6 +517,25 @@ struct DebugMenuView: View {
}
.buttonStyle(.borderedProminent)
.foregroundColor(.blue)
if #available(iOS 16.1, *) {
Button(liveActivityStarted ? "Остановить Live Activity" : "Тест Live Activity") {
if liveActivityStarted {
LiveActivityManager.shared.end(threadId: 999999)
liveActivityStarted = false
} else {
let detail = ThreadDetail(id: 999999, creation: "2023-01-01T00:00:00Z", title: "Тестовый тред", text: "", board: "b", files: [])
let comments = [Comment(id: 1, text: "Привет из Live Activity", creation: "2023-01-01T00:00:00Z", files: [])]
var s = Settings()
s.liveActivityEnabled = true
s.liveActivityShowTitle = true
s.liveActivityShowLastComment = true
s.liveActivityShowCommentCount = true
LiveActivityManager.shared.start(for: detail, comments: comments, settings: s)
liveActivityStarted = true
}
}
.buttonStyle(.borderedProminent)
}
}
Spacer()

View File

@ -5,15 +5,26 @@ 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
@State private var errorMessage: String?
@State private var showingAddComment = false
@State private var activityOn = false
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()
@ -71,13 +82,14 @@ struct ThreadDetailView: View {
}
.padding()
}
.refreshable {
loadThreadDetail()
}
.navigationTitle("#\(thread.id)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Обновить") {
loadThreadDetail()
}
Button("Обновить") { loadThreadDetail() }
}
}
.sheet(isPresented: $showingAddComment) {
@ -104,6 +116,9 @@ struct ThreadDetailView: View {
case .success(let (detail, loadedComments)):
self.threadDetail = detail
self.comments = loadedComments
if #available(iOS 16.1, *), settings.liveActivityEnabled, activityOn {
LiveActivityManager.shared.update(threadId: thread.id, comments: loadedComments, settings: settings)
}
case .failure(let error):
self.errorMessage = error.localizedDescription
}

View File

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

View File

@ -2,7 +2,7 @@
Нативный iOS клиент для борды mkch.pooziqo.xyz
![Version](https://img.shields.io/badge/версия-2.0.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)
@ -18,7 +18,7 @@
- **Нативные анимации** и жесты iOS
- **SwiftUI интерфейс** для современного внешнего вида
### росмотр контента
### Просмотр контента
- **Просмотр всех досок** mkch с описаниями
- **Список тредов** с поддержкой сортировки по рейтингу и закрепленным
- **Детальный просмотр тредов** с комментариями
@ -26,6 +26,7 @@
- **Полноэкранный просмотр изображений** с зумом и жестами
- **Компактный/обычный режим** отображения для экономии места
- **Пагинация** с настраиваемым размером страницы (5-20 элементов)
- **Оффлайн режим**: просмотр сохранённых данных без сети
### Система избранного
- **Добавление тредов в избранное** одним тапом
@ -41,6 +42,7 @@
- **Размер страницы**: от 5 до 20 элементов
- **Пагинация**: включение/отключение
- **Нестабильные функции** с предупреждением
- **Принудительно оффлайн**: работа только с кэшем
### Полная поддержка постинга
- **Аутентификация по ключу** с тестированием подключения
@ -68,12 +70,17 @@
- **Оптимизация батареи** для фоновых задач
- **Ленивая загрузка** контента
- **Graceful error handling** с retry логикой
- **Дисковый кэш** с фолбэком на устаревшие данные при оффлайне
### Дополнительные функции
- **Crash Handler** с детальной диагностикой
- **Debug меню** (5 тапов по информации об устройстве):
- Тест краша приложения
- Тестовые уведомления
- **Live Activity (BETA)**:
- Отображение треда на экране блокировки/Dynamic Island
- Тикер случайных тредов по доскам с настраиваемым интервалом
- Гибкие опции: заголовок, последний коммент, счётчик
- **Управление кэшем**:
- Очистка кэша досок
- Очистка кэша тредов
@ -107,6 +114,26 @@
5. **Подпишитесь на нужные доски** переключателями
6. **Протестируйте** кнопкой "Проверить новые треды сейчас"
### Live Activity (BETA)
1. Требования: iPhone c iOS 16.1+ (на iPad до iPadOS 18 Live Activity не показываются; на симуляторе поддержка ограничена)
2. Включите: Настройки -> Уведомления -> Live Activity
3. Отдельный тред: откройте тред и включите тумблер в правом верхнем углу
4. Тикер:
- В Настройки -> Уведомления включите "Тикер случайных тредов"
- Выберите случайную борду или укажите код борды
- Задайте интервал (5120 сек)
- Кнопки "Старт тикера" / "Стоп тикера"
5. Ограничения платформы: бегущая строка в Live Activity недоступна. Контент обновляется дискретно через интервал. Частота обновлений в фоне ограничивается iOS.
### Оффлайн режим
1. Откройте вкладку "Настройки"
2. Включите тумблер "Принудительно оффлайн"
3. На экранах появится баннер "Оффлайн режим. Показаны сохранённые данные"
4. Загрузка из сети отключается, используются данные из кэша (если есть)
5. Отключите тумблер для возврата в онлайн
## Архитектура приложения
### Основные компоненты
@ -119,6 +146,7 @@
| `APIClient.swift` | HTTP клиент с CSRF и аутентификацией |
| `Settings.swift` | Система настроек с JSON сериализацией |
| `Cache.swift` | Многоуровневое кэширование с TTL |
| `LiveActivityManager.swift` | Управление Live Activity и тикером |
### UI компоненты
@ -133,6 +161,15 @@
| `FileView.swift` | Просмотр файлов с полноэкранным режимом |
| `NotificationSettingsView.swift` | BETA настройки уведомлений |
### Виджеты и Live Activity
| Файл | Описание |
|------|----------|
| `FavoritesWidget/FavoritesWidget.swift` | Конфигурация виджета и UI Live Activity |
| `FavoritesWidget/FavoritesWidgetBundle.swift` | Регистрация виджета и Live Activity |
| `FavoritesWidget/AppIntent.swift` | Intent-конфигурация виджета |
| `FavoritesWidget/Info.plist` | Ключ `NSSupportsLiveActivities` |
### Системные сервисы
| Файл | Описание |
@ -141,6 +178,8 @@
| `BackgroundTaskManager.swift` | Фоновое обновление |
| `CrashHandler.swift` | Обработка крашей |
| `ImageLoader.swift` | Асинхронная загрузка изображений |
| `NetworkMonitor.swift` | Монитор сети и принудительный оффлайн |
| `AppGroup.swift` | Общие UserDefaults для app ↔ widget |
## API интеграция
@ -162,6 +201,7 @@
- **Треды**: 5 минут (часто обновляются)
- **Детали**: 3 минуты (могут изменяться)
- **Изображения**: NSCache с лимитами памяти
- **Оффлайн фолбэк**: при ошибке сети или включённом оффлайне данные берутся из дискового кэша, если доступны
## Сборка проекта
@ -197,7 +237,23 @@ P.S. Костыль через Payload/MobileMkch.app в зипе и переи
## Версии и обновления
### Версия 2.0.0-ios-alpha (Текущая)
### Версия 2.1.1-ios-alpha (Текущая)
- Live Activity (BETA): тумблер в треде, тикер в "Настройки -> Уведомления"
- Добавлен оффлайн режим (тумблер в настройках)
- Дисковый кэш и фолбэк на сохранённые данные
- Оффлайн-баннеры в списках и деталях
### Версия 2.1.0-ios-alpha
- Добавлены push-уведомления
- Добавлены фоновые задачи
- Добавлены уведомления о новых тредах
- Добавлены уведомления о новых комментариях
- Добавлены уведомления о новых файлах
- Добавлены уведомления о новых досках
p.s. я не уверен работает ли оно, но оно работает по крайней мере на моем устройстве
### Версия 2.0.0-ios-alpha
- Полная переработка UI на SwiftUI
- Система избранного с локальным сохранением
- Push-уведомления с фоновым обновлением
@ -210,7 +266,6 @@ P.S. Костыль через Payload/MobileMkch.app в зипе и переи
### Планы развития
- Поддержка загрузки файлов при постинге
- Офлайн режим чтения
- Поиск по тредам и комментариям
- Темы оформления (кастомные цвета)
- Статистика использования (мб и не будет, я не знаю мне лень)
@ -253,7 +308,7 @@ P.S. Костыль через Payload/MobileMkch.app в зипе и переи
**Автор**: w^x (лейн, платон)
**Контакт**: mkch.pooziqo.xyz
**Версия**: 2.0.0-ios-alpha (Always in alpha lol)
**Версия**: 2.1.1-ios-alpha (Always in alpha lol)
**Дата**: Август 2025
*Разработано с <3 на Swift*