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

View File

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

View File

@ -1,12 +1,30 @@
import Foundation import Foundation
import Network
struct UploadFile {
let name: String
let filename: String
let mimeType: String
let data: Data
}
class APIClient: ObservableObject { class APIClient: ObservableObject {
private let baseURL = "https://mkch.pooziqo.xyz" private let baseURL = "https://mkch.pooziqo.xyz"
private let apiURL = "https://mkch.pooziqo.xyz/api" 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 authKey: String = ""
private var passcode: 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) { func authenticate(authKey: String, completion: @escaping (Error?) -> Void) {
self.authKey = authKey self.authKey = authKey
@ -142,6 +160,14 @@ class APIClient: ObservableObject {
} }
func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) { func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let cached = Cache.shared.getBoardsStale(), !cached.isEmpty {
completion(.success(cached))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённых данных", code: 0)))
}
return
}
if let cachedBoards = Cache.shared.getBoards() { if let cachedBoards = Cache.shared.getBoards() {
completion(.success(cachedBoards)) completion(.success(cachedBoards))
return return
@ -154,18 +180,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
completion(.failure(error)) if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(error))
}
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { httpResponse.statusCode == 200 else {
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 return
} }
guard let data = data else { 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 return
} }
@ -181,6 +219,14 @@ class APIClient: ObservableObject {
} }
func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) { func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённых тредов", code: 0)))
}
return
}
if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) { if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) {
completion(.success(cachedThreads)) completion(.success(cachedThreads))
return return
@ -193,18 +239,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
completion(.failure(error)) if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(error))
}
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { httpResponse.statusCode == 200 else {
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 return
} }
guard let data = data else { 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 return
} }
@ -220,6 +278,14 @@ class APIClient: ObservableObject {
} }
func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) { func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённого треда", code: 0)))
}
return
}
if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) { if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) {
completion(.success(cachedThread)) completion(.success(cachedThread))
return return
@ -232,18 +298,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
completion(.failure(error)) if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
completion(.success(stale))
} else {
completion(.failure(error))
}
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { httpResponse.statusCode == 200 else {
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 return
} }
guard let data = data else { 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 return
} }
@ -259,6 +337,14 @@ class APIClient: ObservableObject {
} }
func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) { func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) {
if NetworkMonitor.shared.offlineEffective {
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(APIError(message: "Оффлайн: нет сохранённых комментариев", code: 0)))
}
return
}
if let cachedComments = Cache.shared.getComments(forThreadId: threadId) { if let cachedComments = Cache.shared.getComments(forThreadId: threadId) {
completion(.success(cachedComments)) completion(.success(cachedComments))
return return
@ -271,18 +357,30 @@ class APIClient: ObservableObject {
session.dataTask(with: request) { data, response, error in session.dataTask(with: request) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
completion(.failure(error)) if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
completion(.success(stale))
} else {
completion(.failure(error))
}
return return
} }
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else { httpResponse.statusCode == 200 else {
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 return
} }
guard let data = data else { 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 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 { if !passcode.isEmpty {
loginWithPasscode(passcode: passcode) { error in loginWithPasscode(passcode: passcode) { error in
if let error = error { if let error = error {
completion(error) completion(error)
return return
} }
self.performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion) self.performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion)
} }
} else { } 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 formURL = "\(baseURL)/boards/board/\(boardCode)/new"
let url = URL(string: formURL)! let url = URL(string: formURL)!
@ -394,19 +492,30 @@ class APIClient: ObservableObject {
return return
} }
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) var postRequest = URLRequest(url: url)
postRequest.httpMethod = "POST" postRequest.httpMethod = "POST"
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue(formURL, forHTTPHeaderField: "Referer") postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
postRequest.httpBody = formData.query?.data(using: .utf8)
if files.isEmpty {
var formData = URLComponents()
formData.queryItems = [
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
URLQueryItem(name: "title", value: title),
URLQueryItem(name: "text", value: text)
]
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
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 self.session.dataTask(with: postRequest) { _, postResponse, postError in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -429,21 +538,21 @@ class APIClient: ObservableObject {
}.resume() }.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 { if !passcode.isEmpty {
loginWithPasscode(passcode: passcode) { error in loginWithPasscode(passcode: passcode) { error in
if let error = error { if let error = error {
completion(error) completion(error)
return return
} }
self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion) self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion)
} }
} else { } 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 formURL = "\(baseURL)/boards/board/\(boardCode)/thread/\(threadId)/comment"
let url = URL(string: formURL)! let url = URL(string: formURL)!
@ -477,18 +586,28 @@ class APIClient: ObservableObject {
return return
} }
var formData = URLComponents()
formData.queryItems = [
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
URLQueryItem(name: "text", value: text)
]
var postRequest = URLRequest(url: url) var postRequest = URLRequest(url: url)
postRequest.httpMethod = "POST" postRequest.httpMethod = "POST"
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
postRequest.setValue(formURL, forHTTPHeaderField: "Referer") postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
postRequest.httpBody = formData.query?.data(using: .utf8)
if files.isEmpty {
var formData = URLComponents()
formData.queryItems = [
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
URLQueryItem(name: "text", value: text)
]
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
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 self.session.dataTask(with: postRequest) { _, postResponse, postError in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -526,6 +645,25 @@ class APIClient: ObservableObject {
return String(html[range]) 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) { func checkNewThreads(forBoard boardCode: String, lastKnownThreadId: Int, completion: @escaping (Result<[Thread], Error>) -> Void) {
let url = URL(string: "\(apiURL)/board/\(boardCode)")! let url = URL(string: "\(apiURL)/board/\(boardCode)")!
var request = URLRequest(url: url) var request = URLRequest(url: url)
@ -550,9 +688,31 @@ class APIClient: ObservableObject {
} }
do { do {
let threads = try JSONDecoder().decode([Thread].self, from: data) let currentThreads = try JSONDecoder().decode([Thread].self, from: data)
let newThreads = threads.filter { $0.id > lastKnownThreadId }
completion(.success(newThreads)) 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 { } catch {
completion(.failure(error)) completion(.failure(error))
} }

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import PhotosUI
struct AddCommentView: View { struct AddCommentView: View {
let boardCode: String let boardCode: String
@ -12,6 +13,8 @@ struct AddCommentView: View {
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showingSuccess = false @State private var showingSuccess = false
@FocusState private var isTextFocused: Bool @FocusState private var isTextFocused: Bool
@State private var pickedImages: [UIImage] = []
@State private var showPicker: Bool = false
var body: some View { var body: some View {
NavigationView { 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) { HStack(spacing: 8) {
Image(systemName: settings.passcode.isEmpty ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") Image(systemName: settings.passcode.isEmpty ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.foregroundColor(settings.passcode.isEmpty ? .orange : .green) .foregroundColor(settings.passcode.isEmpty ? .orange : .green)
@ -126,6 +161,11 @@ struct AddCommentView: View {
} message: { } message: {
Text("Комментарий добавлен") Text("Комментарий добавлен")
} }
.sheet(isPresented: $showPicker) {
ImagePickerView(selectionLimit: 4) { images in
pickedImages = images
}
}
} }
} }
@ -139,7 +179,8 @@ struct AddCommentView: View {
boardCode: boardCode, boardCode: boardCode,
threadId: threadId, threadId: threadId,
text: text, text: text,
passcode: settings.passcode passcode: settings.passcode,
files: buildUploadFiles()
) { error in ) { error in
DispatchQueue.main.async { DispatchQueue.main.async {
self.isLoading = false 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 { #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() static let shared = BackgroundTaskManager()
private var backgroundTaskIdentifier: String { private var backgroundTaskIdentifier: String {
if let savedIdentifier = UserDefaults.standard.string(forKey: "BackgroundTaskIdentifier") { if let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String],
return savedIdentifier let first = identifiers.first {
return first
} }
return "com.mkch.MobileMkch.backgroundrefresh" return "com.mkch.MobileMkch.backgroundrefresh"
} }
@ -24,11 +25,12 @@ class BackgroundTaskManager {
func scheduleBackgroundTask() { func scheduleBackgroundTask() {
let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier) let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier)
let settings = Settings() let settings = Settings()
let interval = TimeInterval(settings.notificationInterval) let interval = TimeInterval(settings.notificationInterval * 60)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval) request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
do { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
print("Фоновая задача запланирована на \(interval) секунд")
} catch { } catch {
print("Не удалось запланировать фоновую задачу: \(error)") print("Не удалось запланировать фоновую задачу: \(error)")
} }
@ -51,20 +53,18 @@ class BackgroundTaskManager {
for boardCode in notificationManager.subscribedBoards { for boardCode in notificationManager.subscribedBoards {
group.enter() group.enter()
let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)") APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: 0) { result in
APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in
switch result { switch result {
case .success(let newThreads): case .success(let newThreads):
if !newThreads.isEmpty { if !newThreads.isEmpty {
hasNewThreads = true hasNewThreads = true
print("Найдено \(newThreads.count) новых тредов в /\(boardCode)/")
for thread in newThreads { for thread in newThreads {
self.notificationManager.scheduleNotification(for: thread, boardCode: boardCode) self.notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
} }
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
} }
case .failure: case .failure(let error):
break print("Ошибка проверки новых тредов для /\(boardCode)/: \(error)")
} }
group.leave() group.leave()
} }

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import PhotosUI
struct CreateThreadView: View { struct CreateThreadView: View {
let boardCode: String let boardCode: String
@ -13,6 +14,8 @@ struct CreateThreadView: View {
@State private var showingSuccess = false @State private var showingSuccess = false
@FocusState private var titleFocused: Bool @FocusState private var titleFocused: Bool
@FocusState private var textFocused: Bool @FocusState private var textFocused: Bool
@State private var pickedImages: [UIImage] = []
@State private var showPicker: Bool = false
var body: some View { var body: some View {
NavigationView { 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) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("Содержание") Text("Содержание")
@ -149,6 +184,11 @@ struct CreateThreadView: View {
} message: { } message: {
Text("Тред создан") Text("Тред создан")
} }
.sheet(isPresented: $showPicker) {
ImagePickerView(selectionLimit: 4) { images in
pickedImages = images
}
}
} }
} }
@ -162,7 +202,8 @@ struct CreateThreadView: View {
boardCode: boardCode, boardCode: boardCode,
title: title, title: title,
text: text, text: text,
passcode: settings.passcode passcode: settings.passcode,
files: buildUploadFiles()
) { error in ) { error in
DispatchQueue.main.async { DispatchQueue.main.async {
self.isLoading = false 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 { #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> <array>
<string>com.mkch.MobileMkch.backgroundrefresh</string> <string>com.mkch.MobileMkch.backgroundrefresh</string>
</array> </array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Нужно для выбора фото и загрузки вложений</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>background-processing</string> <string>background-processing</string>
<string>background-fetch</string> <string>background-fetch</string>
<string>fetch</string>
<string>processing</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View File

@ -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 SwiftUI
import UserNotifications
@main @main
struct MobileMkchApp: App { struct MobileMkchApp: App {
@ -13,6 +14,7 @@ struct MobileMkchApp: App {
@StateObject private var apiClient = APIClient() @StateObject private var apiClient = APIClient()
@StateObject private var crashHandler = CrashHandler.shared @StateObject private var crashHandler = CrashHandler.shared
@StateObject private var notificationManager = NotificationManager.shared @StateObject private var notificationManager = NotificationManager.shared
@StateObject private var networkMonitor = NetworkMonitor.shared
private func setupBackgroundTasks() { private func setupBackgroundTasks() {
if let bundleIdentifier = Bundle.main.bundleIdentifier { if let bundleIdentifier = Bundle.main.bundleIdentifier {
@ -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 { var body: some Scene {
WindowGroup { WindowGroup {
Group { Group {
@ -32,12 +53,15 @@ struct MobileMkchApp: App {
.environmentObject(settings) .environmentObject(settings)
.environmentObject(apiClient) .environmentObject(apiClient)
.environmentObject(notificationManager) .environmentObject(notificationManager)
.environmentObject(networkMonitor)
.preferredColorScheme(settings.theme == "dark" ? .dark : .light) .preferredColorScheme(settings.theme == "dark" ? .dark : .light)
} }
} }
.onAppear { .onAppear {
BackgroundTaskManager.shared.registerBackgroundTasks() BackgroundTaskManager.shared.registerBackgroundTasks()
setupBackgroundTasks() setupBackgroundTasks()
setupNotifications()
handleNotificationLaunch()
} }
} }
} }

View File

@ -1,5 +1,9 @@
import Foundation import Foundation
private enum DateFormatterCache {
static let iso8601 = ISO8601DateFormatter()
}
struct Board: Codable, Identifiable { struct Board: Codable, Identifiable {
let code: String let code: String
let description: String let description: String
@ -18,8 +22,7 @@ struct Thread: Codable, Identifiable {
let files: [String] let files: [String]
var creationDate: Date { var creationDate: Date {
let formatter = ISO8601DateFormatter() return DateFormatterCache.iso8601.date(from: creation) ?? Date()
return formatter.date(from: creation) ?? Date()
} }
var ratingValue: Int { var ratingValue: Int {
@ -40,8 +43,7 @@ struct ThreadDetail: Codable, Identifiable {
let files: [String] let files: [String]
var creationDate: Date { var creationDate: Date {
let formatter = ISO8601DateFormatter() return DateFormatterCache.iso8601.date(from: creation) ?? Date()
return formatter.date(from: creation) ?? Date()
} }
} }
@ -52,8 +54,7 @@ struct Comment: Codable, Identifiable {
let files: [String] let files: [String]
var creationDate: Date { var creationDate: Date {
let formatter = ISO8601DateFormatter() return DateFormatterCache.iso8601.date(from: creation) ?? Date()
return formatter.date(from: creation) ?? Date()
} }
var formattedText: String { 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 UserNotifications
import UIKit 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 { class NotificationManager: ObservableObject {
static let shared = NotificationManager() static let shared = NotificationManager()
@ -17,11 +29,20 @@ class NotificationManager: ObservableObject {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
DispatchQueue.main.async { DispatchQueue.main.async {
self.isNotificationsEnabled = granted self.isNotificationsEnabled = granted
if granted {
self.registerForRemoteNotifications()
}
completion(granted) completion(granted)
} }
} }
} }
private func registerForRemoteNotifications() {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
func checkNotificationStatus() { func checkNotificationStatus() {
UNUserNotificationCenter.current().getNotificationSettings { settings in UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -33,36 +54,90 @@ class NotificationManager: ObservableObject {
func subscribeToBoard(_ boardCode: String) { func subscribeToBoard(_ boardCode: String) {
subscribedBoards.insert(boardCode) subscribedBoards.insert(boardCode)
saveSubscribedBoards() saveSubscribedBoards()
BackgroundTaskManager.shared.scheduleBackgroundTask() 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) { func unsubscribeFromBoard(_ boardCode: String) {
subscribedBoards.remove(boardCode) subscribedBoards.remove(boardCode)
saveSubscribedBoards() saveSubscribedBoards()
let savedThreadsKey = "savedThreads_\(boardCode)"
UserDefaults.standard.removeObject(forKey: savedThreadsKey)
} }
func scheduleNotification(for thread: Thread, boardCode: String) { func scheduleNotification(for thread: Thread, boardCode: String) {
guard isNotificationsEnabled else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Новый тред" content.title = "Новый тред"
content.body = "\(thread.title) в /\(boardCode)/" content.body = "\(thread.title) в /\(boardCode)/"
content.sound = .default content.sound = .default
content.badge = 1
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false) let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: "thread_\(thread.id)", content: content, trigger: trigger) 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() { func scheduleTestNotification() {
guard isNotificationsEnabled else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Тестовое уведомление" content.title = "Тестовое уведомление"
content.body = "Новый тред: Тестовый тред в /test/" content.body = "Новый тред: Тестовый тред в /test/"
content.sound = .default 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) 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() { private func loadSubscribedBoards() {

View File

@ -7,6 +7,8 @@ struct NotificationSettingsView: View {
@State private var showingPermissionAlert = false @State private var showingPermissionAlert = false
@State private var boards: [Board] = [] @State private var boards: [Board] = []
@State private var isLoadingBoards = false @State private var isLoadingBoards = false
@State private var isCheckingThreads = false
@State private var showingTestNotification = false
var body: some View { var body: some View {
VStack { VStack {
@ -40,7 +42,7 @@ struct NotificationSettingsView: View {
.cornerRadius(8) .cornerRadius(8)
.padding(.horizontal) .padding(.horizontal)
Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.") Text("Уведомления находятся в бета-версии и могут работать нестабильно.")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -71,12 +73,59 @@ struct NotificationSettingsView: View {
} }
.onChange(of: settings.notificationInterval) { _ in .onChange(of: settings.notificationInterval) { _ in
settings.saveSettings() settings.saveSettings()
BackgroundTaskManager.shared.scheduleBackgroundTask()
} }
Button("Проверить новые треды сейчас") { Button(action: {
checkNewThreadsNow() showingTestNotification = true
notificationManager.scheduleTestNotification()
}) {
HStack {
Image(systemName: "bell.badge")
Text("Отправить тестовое уведомление")
}
} }
.foregroundColor(.blue) .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("Загрузка досок...") Text("Загрузка досок...")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} else if boards.isEmpty {
Text("Не удалось загрузить доски")
.foregroundColor(.secondary)
} else { } else {
ForEach(boards) { board in ForEach(boards) { board in
HStack { 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: { } message: {
Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках") Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках")
} }
.alert("Тестовое уведомление", isPresented: $showingTestNotification) {
Button("OK") { }
} message: {
Text("Тестовое уведомление отправлено. Проверьте, получили ли вы его.")
}
} }
} }
@ -154,25 +227,57 @@ extension NotificationSettingsView {
private func checkNewThreadsNow() { private func checkNewThreadsNow() {
guard !notificationManager.subscribedBoards.isEmpty else { return } guard !notificationManager.subscribedBoards.isEmpty else { return }
for boardCode in notificationManager.subscribedBoards { let group = DispatchGroup()
let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)") 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 { DispatchQueue.main.async {
switch result { switch result {
case .success(let newThreads): case .success(let newThreads):
if !newThreads.isEmpty { if !newThreads.isEmpty {
foundNewThreads = true
for thread in newThreads { for thread in newThreads {
notificationManager.scheduleNotification(for: thread, boardCode: boardCode) notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
} }
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
} }
case .failure: case .failure(let error):
break 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()
}
} }
private func loadBoards() { private func loadBoards() {

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import WidgetKit
class Settings: ObservableObject { class Settings: ObservableObject {
@Published var theme: String = "dark" @Published var theme: String = "dark"
@ -14,12 +15,22 @@ class Settings: ObservableObject {
@Published var notificationsEnabled: Bool = false @Published var notificationsEnabled: Bool = false
@Published var notificationInterval: Int = 300 @Published var notificationInterval: Int = 300
@Published var favoriteThreads: [FavoriteThread] = [] @Published var favoriteThreads: [FavoriteThread] = []
@Published var offlineMode: Bool = false
@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 userDefaults = UserDefaults.standard
private let settingsKey = "MobileMkchSettings" private let settingsKey = "MobileMkchSettings"
init() { init() {
loadSettings() loadSettings()
mirrorStateToAppGroup()
} }
func loadSettings() { func loadSettings() {
@ -38,7 +49,17 @@ class Settings: ObservableObject {
self.notificationsEnabled = settings.notificationsEnabled self.notificationsEnabled = settings.notificationsEnabled
self.notificationInterval = settings.notificationInterval self.notificationInterval = settings.notificationInterval
self.favoriteThreads = settings.favoriteThreads self.favoriteThreads = settings.favoriteThreads
self.offlineMode = settings.offlineMode ?? false
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() { func saveSettings() {
@ -56,11 +77,24 @@ class Settings: ObservableObject {
notificationsEnabled: notificationsEnabled, notificationsEnabled: notificationsEnabled,
notificationInterval: notificationInterval, notificationInterval: notificationInterval,
favoriteThreads: favoriteThreads 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) { if let data = try? JSONEncoder().encode(settingsData) {
userDefaults.set(data, forKey: settingsKey) userDefaults.set(data, forKey: settingsKey)
} }
mirrorStateToAppGroup()
WidgetCenter.shared.reloadAllTimelines()
} }
func resetSettings() { func resetSettings() {
@ -77,6 +111,7 @@ class Settings: ObservableObject {
notificationsEnabled = false notificationsEnabled = false
notificationInterval = 300 notificationInterval = 300
favoriteThreads = [] favoriteThreads = []
offlineMode = false
saveSettings() saveSettings()
} }
@ -100,6 +135,17 @@ class Settings: ObservableObject {
func isFavorite(_ threadId: Int, boardCode: String) -> Bool { func isFavorite(_ threadId: Int, boardCode: String) -> Bool {
return favoriteThreads.contains { $0.id == threadId && $0.board == boardCode } return favoriteThreads.contains { $0.id == threadId && $0.board == boardCode }
} }
private func mirrorStateToAppGroup() {
guard let shared = AppGroup.defaults else { return }
let mapped = favoriteThreads.map { FavoriteThreadWidget(id: $0.id, title: $0.title, board: $0.board, boardDescription: $0.boardDescription, addedDate: $0.addedDate) }
if let encodedFavorites = try? JSONEncoder().encode(mapped) {
shared.set(encodedFavorites, forKey: "favoriteThreads")
}
shared.set(offlineMode, forKey: "offlineMode")
shared.set(lastBoard, forKey: "lastBoard")
WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget")
}
} }
struct SettingsData: Codable { struct SettingsData: Codable {
@ -116,4 +162,13 @@ struct SettingsData: Codable {
let notificationsEnabled: Bool let notificationsEnabled: Bool
let notificationInterval: Int let notificationInterval: Int
let favoriteThreads: [FavoriteThread] 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 SwiftUI
import Combine import Combine
import Darwin import Darwin
import ActivityKit
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var settings: Settings @EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient @EnvironmentObject var apiClient: APIClient
@EnvironmentObject var networkMonitor: NetworkMonitor
@State private var isTickerRunning = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingInfo = false @State private var showingInfo = false
@State private var testKeyResult: String? @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("Аутентификация") { Section("Аутентификация") {
HStack { HStack {
Image(systemName: "lock.shield") Image(systemName: "lock.shield")
@ -182,6 +205,65 @@ struct SettingsView: View {
} }
.padding(.trailing, 8) .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("Управление кэшем") { Section("Управление кэшем") {
@ -390,7 +472,7 @@ struct AboutView: View {
Divider() Divider()
VStack(alignment: .leading, spacing: 8) { 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("Автор: w^x (лейн, платон, а похуй как угодно)")
Text("Разработано с <3 на Свифт") Text("Разработано с <3 на Свифт")
} }
@ -413,6 +495,7 @@ struct AboutView: View {
struct DebugMenuView: View { struct DebugMenuView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject var notificationManager: NotificationManager @EnvironmentObject var notificationManager: NotificationManager
@State private var liveActivityStarted = false
var body: some View { var body: some View {
NavigationView { NavigationView {
@ -434,6 +517,25 @@ struct DebugMenuView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.foregroundColor(.blue) .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() Spacer()

View File

@ -5,15 +5,26 @@ struct ThreadDetailView: View {
let thread: Thread let thread: Thread
@EnvironmentObject var settings: Settings @EnvironmentObject var settings: Settings
@EnvironmentObject var apiClient: APIClient @EnvironmentObject var apiClient: APIClient
@EnvironmentObject var networkMonitor: NetworkMonitor
@State private var threadDetail: ThreadDetail? @State private var threadDetail: ThreadDetail?
@State private var comments: [Comment] = [] @State private var comments: [Comment] = []
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showingAddComment = false @State private var showingAddComment = false
@State private var activityOn = false
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if networkMonitor.offlineEffective {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundColor(.orange)
Text("Оффлайн режим. Показаны сохранённые данные")
.foregroundColor(.secondary)
.font(.caption)
}
}
if isLoading { if isLoading {
VStack { VStack {
ProgressView() ProgressView()
@ -71,13 +82,14 @@ struct ThreadDetailView: View {
} }
.padding() .padding()
} }
.refreshable {
loadThreadDetail()
}
.navigationTitle("#\(thread.id)") .navigationTitle("#\(thread.id)")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Обновить") { Button("Обновить") { loadThreadDetail() }
loadThreadDetail()
}
} }
} }
.sheet(isPresented: $showingAddComment) { .sheet(isPresented: $showingAddComment) {
@ -104,6 +116,9 @@ struct ThreadDetailView: View {
case .success(let (detail, loadedComments)): case .success(let (detail, loadedComments)):
self.threadDetail = detail self.threadDetail = detail
self.comments = loadedComments 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): case .failure(let error):
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
} }

View File

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

View File

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