Compare commits
No commits in common. "8cab7bac36949b5114cd021165d01aa8bdf12eed" and "08119cd2092244a8242368eadb5c2d245e54970d" have entirely different histories.
8cab7bac36
...
08119cd209
@ -1,11 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -1,299 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
<?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>
|
||||
@ -1,10 +0,0 @@
|
||||
<?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>
|
||||
@ -6,52 +6,11 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1D2B9E3A2E4603660097A722 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D2B9E392E4603660097A722 /* WidgetKit.framework */; };
|
||||
1D2B9E3C2E4603660097A722 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D2B9E3B2E4603660097A722 /* SwiftUI.framework */; };
|
||||
1D2B9E4D2E4603660097A722 /* FavoritesWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
1D2B9E4B2E4603660097A722 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 1D8926E12E43CE4C00C5590A /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 1D2B9E362E4603660097A722;
|
||||
remoteInfo = FavoritesWidgetExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
1D2B9E4D2E4603660097A722 /* FavoritesWidgetExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FavoritesWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1D2B9E392E4603660097A722 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FavoritesWidgetExtension.entitlements; sourceTree = "<group>"; };
|
||||
1D8926E92E43CE4C00C5590A /* MobileMkch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileMkch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */;
|
||||
};
|
||||
1DBE8B722E4414C80052ED1B /* Exceptions for "MobileMkch" folder in "MobileMkch" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
@ -62,14 +21,6 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
1D2B9E3D2E4603660097A722 /* FavoritesWidget */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
1D2B9E512E4603660097A722 /* Exceptions for "FavoritesWidget" folder in "FavoritesWidgetExtension" target */,
|
||||
);
|
||||
path = FavoritesWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@ -81,15 +32,6 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
1D2B9E342E4603660097A722 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1D2B9E3C2E4603660097A722 /* SwiftUI.framework in Frameworks */,
|
||||
1D2B9E3A2E4603660097A722 /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1D8926E62E43CE4C00C5590A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -100,22 +42,10 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1D2B9E382E4603660097A722 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D2B9E392E4603660097A722 /* WidgetKit.framework */,
|
||||
1D2B9E3B2E4603660097A722 /* SwiftUI.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1D8926E02E43CE4C00C5590A = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D2B9E532E4605CA0097A722 /* FavoritesWidgetExtension.entitlements */,
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
||||
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
|
||||
1D2B9E382E4603660097A722 /* Frameworks */,
|
||||
1D8926EA2E43CE4C00C5590A /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@ -124,7 +54,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1D8926E92E43CE4C00C5590A /* MobileMkch.app */,
|
||||
1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -132,28 +61,6 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1D2B9E4E2E4603660097A722 /* Build configuration list for PBXNativeTarget "FavoritesWidgetExtension" */;
|
||||
buildPhases = (
|
||||
1D2B9E332E4603660097A722 /* Sources */,
|
||||
1D2B9E342E4603660097A722 /* Frameworks */,
|
||||
1D2B9E352E4603660097A722 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1D2B9E3D2E4603660097A722 /* FavoritesWidget */,
|
||||
);
|
||||
name = FavoritesWidgetExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = FavoritesWidgetExtension;
|
||||
productReference = 1D2B9E372E4603660097A722 /* FavoritesWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
1D8926E82E43CE4C00C5590A /* MobileMkch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1D8926F72E43CE4D00C5590A /* Build configuration list for PBXNativeTarget "MobileMkch" */;
|
||||
@ -161,12 +68,10 @@
|
||||
1D8926E52E43CE4C00C5590A /* Sources */,
|
||||
1D8926E62E43CE4C00C5590A /* Frameworks */,
|
||||
1D8926E72E43CE4C00C5590A /* Resources */,
|
||||
1D2B9E522E4603660097A722 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1D8926EB2E43CE4C00C5590A /* MobileMkch */,
|
||||
@ -188,9 +93,6 @@
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1620;
|
||||
TargetAttributes = {
|
||||
1D2B9E362E4603660097A722 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
1D8926E82E43CE4C00C5590A = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
@ -211,19 +113,11 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1D8926E82E43CE4C00C5590A /* MobileMkch */,
|
||||
1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
1D2B9E352E4603660097A722 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1D8926E72E43CE4C00C5590A /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -234,13 +128,6 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1D2B9E332E4603660097A722 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1D8926E52E43CE4C00C5590A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -250,73 +137,7 @@
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
1D2B9E4C2E4603660097A722 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1D2B9E362E4603660097A722 /* FavoritesWidgetExtension */;
|
||||
targetProxy = 1D2B9E4B2E4603660097A722 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1D2B9E4F2E4603660097A722 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = FavoritesWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 9U88M9D595;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = FavoritesWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FavoritesWidget;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch.FavoritesWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1D2B9E502E4603660097A722 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = FavoritesWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 9U88M9D595;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = FavoritesWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FavoritesWidget;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch.FavoritesWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1D8926F52E43CE4D00C5590A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@ -441,7 +262,6 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = MobileMkch/MobileMkch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "2-alpha";
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
||||
@ -461,7 +281,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "2.1.1-ios";
|
||||
MARKETING_VERSION = "2.0.0-ios";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@ -478,7 +298,6 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = MobileMkch/MobileMkch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "2-alpha";
|
||||
DEVELOPMENT_ASSET_PATHS = "\"MobileMkch/Preview Content\"";
|
||||
@ -498,7 +317,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "2.1.1-ios";
|
||||
MARKETING_VERSION = "2.0.0-ios";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mkch.MobileMkch;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@ -513,15 +332,6 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
1D2B9E4E2E4603660097A722 /* Build configuration list for PBXNativeTarget "FavoritesWidgetExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1D2B9E4F2E4603660097A722 /* Debug */,
|
||||
1D2B9E502E4603660097A722 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1D8926E42E43CE4C00C5590A /* Build configuration list for PBXProject "MobileMkch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
Binary file not shown.
@ -4,11 +4,6 @@
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>FavoritesWidgetExtension.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>MobileMkch.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
|
||||
@ -1,30 +1,12 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
struct UploadFile {
|
||||
let name: String
|
||||
let filename: String
|
||||
let mimeType: String
|
||||
let data: Data
|
||||
}
|
||||
|
||||
class APIClient: ObservableObject {
|
||||
private let baseURL = "https://mkch.pooziqo.xyz"
|
||||
private let apiURL = "https://mkch.pooziqo.xyz/api"
|
||||
private 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 let session = URLSession.shared
|
||||
private var authKey: String = ""
|
||||
private var passcode: String = ""
|
||||
private let userAgent = "MobileMkch/2.1.1-ios-alpha"
|
||||
private let userAgent = "MobileMkch/2.0.0-ios-alpha"
|
||||
|
||||
func authenticate(authKey: String, completion: @escaping (Error?) -> Void) {
|
||||
self.authKey = authKey
|
||||
@ -160,14 +142,6 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
func getBoards(completion: @escaping (Result<[Board], Error>) -> Void) {
|
||||
if NetworkMonitor.shared.offlineEffective {
|
||||
if let cached = Cache.shared.getBoardsStale(), !cached.isEmpty {
|
||||
completion(.success(cached))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Оффлайн: нет сохранённых данных", code: 0)))
|
||||
}
|
||||
return
|
||||
}
|
||||
if let cachedBoards = Cache.shared.getBoards() {
|
||||
completion(.success(cachedBoards))
|
||||
return
|
||||
@ -180,30 +154,18 @@ class APIClient: ObservableObject {
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Ошибка получения досок", code: 0)))
|
||||
}
|
||||
completion(.failure(APIError(message: "Ошибка получения досок", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
if let stale: [Board] = Cache.shared.getBoardsStale(), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
}
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
@ -219,14 +181,6 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
func getThreads(forBoard boardCode: String, completion: @escaping (Result<[Thread], Error>) -> Void) {
|
||||
if NetworkMonitor.shared.offlineEffective {
|
||||
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Оффлайн: нет сохранённых тредов", code: 0)))
|
||||
}
|
||||
return
|
||||
}
|
||||
if let cachedThreads = Cache.shared.getThreads(forBoard: boardCode) {
|
||||
completion(.success(cachedThreads))
|
||||
return
|
||||
@ -239,30 +193,18 @@ class APIClient: ObservableObject {
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Ошибка получения тредов", code: 0)))
|
||||
}
|
||||
completion(.failure(APIError(message: "Ошибка получения тредов", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
if let stale = Cache.shared.getThreadsStale(forBoard: boardCode), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
}
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
@ -278,14 +220,6 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
func getThreadDetail(boardCode: String, threadId: Int, completion: @escaping (Result<ThreadDetail, Error>) -> Void) {
|
||||
if NetworkMonitor.shared.offlineEffective {
|
||||
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Оффлайн: нет сохранённого треда", code: 0)))
|
||||
}
|
||||
return
|
||||
}
|
||||
if let cachedThread = Cache.shared.getThreadDetail(forThreadId: threadId) {
|
||||
completion(.success(cachedThread))
|
||||
return
|
||||
@ -298,30 +232,18 @@ class APIClient: ObservableObject {
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Ошибка получения треда", code: 0)))
|
||||
}
|
||||
completion(.failure(APIError(message: "Ошибка получения треда", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
if let stale = Cache.shared.getThreadDetailStale(forThreadId: threadId) {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
}
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
@ -337,14 +259,6 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
func getComments(boardCode: String, threadId: Int, completion: @escaping (Result<[Comment], Error>) -> Void) {
|
||||
if NetworkMonitor.shared.offlineEffective {
|
||||
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Оффлайн: нет сохранённых комментариев", code: 0)))
|
||||
}
|
||||
return
|
||||
}
|
||||
if let cachedComments = Cache.shared.getComments(forThreadId: threadId) {
|
||||
completion(.success(cachedComments))
|
||||
return
|
||||
@ -357,30 +271,18 @@ class APIClient: ObservableObject {
|
||||
session.dataTask(with: request) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Ошибка получения комментариев", code: 0)))
|
||||
}
|
||||
completion(.failure(APIError(message: "Ошибка получения комментариев", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
if let stale = Cache.shared.getCommentsStale(forThreadId: threadId), !stale.isEmpty {
|
||||
completion(.success(stale))
|
||||
} else {
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
}
|
||||
completion(.failure(APIError(message: "Нет данных", code: 0)))
|
||||
return
|
||||
}
|
||||
|
||||
@ -444,21 +346,21 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func createThread(boardCode: String, title: String, text: String, passcode: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
|
||||
func createThread(boardCode: String, title: String, text: String, passcode: String, completion: @escaping (Error?) -> Void) {
|
||||
if !passcode.isEmpty {
|
||||
loginWithPasscode(passcode: passcode) { error in
|
||||
if let error = error {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
self.performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion)
|
||||
self.performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion)
|
||||
}
|
||||
} else {
|
||||
performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion)
|
||||
performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private func performCreateThread(boardCode: String, title: String, text: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
|
||||
private func performCreateThread(boardCode: String, title: String, text: String, completion: @escaping (Error?) -> Void) {
|
||||
let formURL = "\(baseURL)/boards/board/\(boardCode)/new"
|
||||
let url = URL(string: formURL)!
|
||||
|
||||
@ -492,30 +394,19 @@ class APIClient: ObservableObject {
|
||||
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)
|
||||
postRequest.httpMethod = "POST"
|
||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
|
||||
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
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
|
||||
}
|
||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||
|
||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||
DispatchQueue.main.async {
|
||||
@ -538,21 +429,21 @@ class APIClient: ObservableObject {
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func addComment(boardCode: String, threadId: Int, text: String, passcode: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
|
||||
func addComment(boardCode: String, threadId: Int, text: String, passcode: String, completion: @escaping (Error?) -> Void) {
|
||||
if !passcode.isEmpty {
|
||||
loginWithPasscode(passcode: passcode) { error in
|
||||
if let error = error {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion)
|
||||
self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion)
|
||||
}
|
||||
} else {
|
||||
performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion)
|
||||
performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private func performAddComment(boardCode: String, threadId: Int, text: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) {
|
||||
private func performAddComment(boardCode: String, threadId: Int, text: String, completion: @escaping (Error?) -> Void) {
|
||||
let formURL = "\(baseURL)/boards/board/\(boardCode)/thread/\(threadId)/comment"
|
||||
let url = URL(string: formURL)!
|
||||
|
||||
@ -586,28 +477,18 @@ class APIClient: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
var formData = URLComponents()
|
||||
formData.queryItems = [
|
||||
URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken),
|
||||
URLQueryItem(name: "text", value: text)
|
||||
]
|
||||
|
||||
var postRequest = URLRequest(url: url)
|
||||
postRequest.httpMethod = "POST"
|
||||
postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
postRequest.setValue(formURL, forHTTPHeaderField: "Referer")
|
||||
postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
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
|
||||
}
|
||||
postRequest.httpBody = formData.query?.data(using: .utf8)
|
||||
|
||||
self.session.dataTask(with: postRequest) { _, postResponse, postError in
|
||||
DispatchQueue.main.async {
|
||||
@ -645,25 +526,6 @@ class APIClient: ObservableObject {
|
||||
return String(html[range])
|
||||
}
|
||||
|
||||
private func buildMultipartBody(parameters: [String: String], files: [UploadFile], boundary: String) -> Data {
|
||||
var body = Data()
|
||||
let boundaryPrefix = "--\(boundary)\r\n"
|
||||
for (key, value) in parameters {
|
||||
body.append(boundaryPrefix.data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append("\(value)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
for file in files {
|
||||
body.append(boundaryPrefix.data(using: .utf8)!)
|
||||
body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: \(file.mimeType)\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(file.data)
|
||||
body.append("\r\n".data(using: .utf8)!)
|
||||
}
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
return body
|
||||
}
|
||||
|
||||
func checkNewThreads(forBoard boardCode: String, lastKnownThreadId: Int, completion: @escaping (Result<[Thread], Error>) -> Void) {
|
||||
let url = URL(string: "\(apiURL)/board/\(boardCode)")!
|
||||
var request = URLRequest(url: url)
|
||||
@ -688,31 +550,9 @@ class APIClient: ObservableObject {
|
||||
}
|
||||
|
||||
do {
|
||||
let currentThreads = try JSONDecoder().decode([Thread].self, from: data)
|
||||
|
||||
let savedThreadsKey = "savedThreads_\(boardCode)"
|
||||
let savedThreadsData = UserDefaults.standard.data(forKey: savedThreadsKey)
|
||||
|
||||
if let savedThreadsData = savedThreadsData,
|
||||
let savedThreads = try? JSONDecoder().decode([Thread].self, from: savedThreadsData) {
|
||||
|
||||
let savedThreadIds = Set(savedThreads.map { $0.id })
|
||||
let newThreads = currentThreads.filter { !savedThreadIds.contains($0.id) }
|
||||
|
||||
if !newThreads.isEmpty {
|
||||
print("Найдено \(newThreads.count) новых тредов в /\(boardCode)/")
|
||||
}
|
||||
|
||||
completion(.success(newThreads))
|
||||
} else {
|
||||
print("Первая синхронизация для /\(boardCode)/ - сохраняем \(currentThreads.count) тредов")
|
||||
completion(.success([]))
|
||||
}
|
||||
|
||||
if let encodedData = try? JSONEncoder().encode(currentThreads) {
|
||||
UserDefaults.standard.set(encodedData, forKey: savedThreadsKey)
|
||||
}
|
||||
|
||||
let threads = try JSONDecoder().decode([Thread].self, from: data)
|
||||
let newThreads = threads.filter { $0.id > lastKnownThreadId }
|
||||
completion(.success(newThreads))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct AddCommentView: View {
|
||||
let boardCode: String
|
||||
@ -13,8 +12,6 @@ struct AddCommentView: View {
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingSuccess = false
|
||||
@FocusState private var isTextFocused: Bool
|
||||
@State private var pickedImages: [UIImage] = []
|
||||
@State private var showPicker: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@ -59,38 +56,6 @@ struct AddCommentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Фото")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Button {
|
||||
showPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "photo.on.rectangle")
|
||||
Text("Выбрать фото")
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
if !pickedImages.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(pickedImages.enumerated()), id: \.offset) { _, img in
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 64, height: 64)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: settings.passcode.isEmpty ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
|
||||
.foregroundColor(settings.passcode.isEmpty ? .orange : .green)
|
||||
@ -161,11 +126,6 @@ struct AddCommentView: View {
|
||||
} message: {
|
||||
Text("Комментарий добавлен")
|
||||
}
|
||||
.sheet(isPresented: $showPicker) {
|
||||
ImagePickerView(selectionLimit: 4) { images in
|
||||
pickedImages = images
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,8 +139,7 @@ struct AddCommentView: View {
|
||||
boardCode: boardCode,
|
||||
threadId: threadId,
|
||||
text: text,
|
||||
passcode: settings.passcode,
|
||||
files: buildUploadFiles()
|
||||
passcode: settings.passcode
|
||||
) { error in
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
@ -193,22 +152,6 @@ 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 {
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -6,9 +6,8 @@ class BackgroundTaskManager {
|
||||
static let shared = BackgroundTaskManager()
|
||||
|
||||
private var backgroundTaskIdentifier: String {
|
||||
if let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String],
|
||||
let first = identifiers.first {
|
||||
return first
|
||||
if let savedIdentifier = UserDefaults.standard.string(forKey: "BackgroundTaskIdentifier") {
|
||||
return savedIdentifier
|
||||
}
|
||||
return "com.mkch.MobileMkch.backgroundrefresh"
|
||||
}
|
||||
@ -25,12 +24,11 @@ class BackgroundTaskManager {
|
||||
func scheduleBackgroundTask() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier)
|
||||
let settings = Settings()
|
||||
let interval = TimeInterval(settings.notificationInterval * 60)
|
||||
let interval = TimeInterval(settings.notificationInterval)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
print("Фоновая задача запланирована на \(interval) секунд")
|
||||
} catch {
|
||||
print("Не удалось запланировать фоновую задачу: \(error)")
|
||||
}
|
||||
@ -53,18 +51,20 @@ class BackgroundTaskManager {
|
||||
for boardCode in notificationManager.subscribedBoards {
|
||||
group.enter()
|
||||
|
||||
APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: 0) { result in
|
||||
let lastKnownId = UserDefaults.standard.integer(forKey: "lastThreadId_\(boardCode)")
|
||||
|
||||
APIClient().checkNewThreads(forBoard: boardCode, lastKnownThreadId: lastKnownId) { result in
|
||||
switch result {
|
||||
case .success(let newThreads):
|
||||
if !newThreads.isEmpty {
|
||||
hasNewThreads = true
|
||||
print("Найдено \(newThreads.count) новых тредов в /\(boardCode)/")
|
||||
for thread in newThreads {
|
||||
self.notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
|
||||
}
|
||||
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
|
||||
}
|
||||
case .failure(let error):
|
||||
print("Ошибка проверки новых тредов для /\(boardCode)/: \(error)")
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import SwiftUI
|
||||
struct BoardsView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
@State private var boards: [Board] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@ -11,15 +10,6 @@ struct BoardsView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
if networkMonitor.offlineEffective {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.orange)
|
||||
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
@ -54,9 +44,6 @@ struct BoardsView: View {
|
||||
}
|
||||
.navigationTitle("Доски mkch")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.refreshable {
|
||||
loadBoards()
|
||||
}
|
||||
.onAppear {
|
||||
if boards.isEmpty {
|
||||
loadBoards()
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
class Cache {
|
||||
static let shared = Cache()
|
||||
|
||||
private var items: [String: CacheItem] = [:]
|
||||
private let queue = DispatchQueue(label: "cache.queue", attributes: .concurrent)
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private init() {
|
||||
startCleanupTimer()
|
||||
@ -22,57 +20,41 @@ class Cache {
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
saveToDisk(key: key, data: encodedData, ttl: ttl)
|
||||
} catch {
|
||||
print("Ошибка кодирования данных для кэша: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
|
||||
if let inMemory: T = queue.sync(execute: { () -> T? in
|
||||
return queue.sync {
|
||||
guard let item = items[key] else { return nil }
|
||||
|
||||
if Date().timeIntervalSince(item.timestamp) > item.ttl {
|
||||
items.removeValue(forKey: key)
|
||||
return nil
|
||||
}
|
||||
do { return try JSONDecoder().decode(type, from: item.data) } catch { return nil }
|
||||
}) {
|
||||
return inMemory
|
||||
}
|
||||
guard let diskItem = loadFromDisk(key: key) else { return nil }
|
||||
if Date().timeIntervalSince(diskItem.timestamp) > diskItem.ttl {
|
||||
delete(key)
|
||||
return nil
|
||||
}
|
||||
queue.async(flags: .barrier) {
|
||||
self.items[key] = diskItem
|
||||
}
|
||||
do { return try JSONDecoder().decode(type, from: diskItem.data) } catch { return nil }
|
||||
}
|
||||
|
||||
func getStale<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
|
||||
if let item = queue.sync(execute: { items[key] }) {
|
||||
return try? JSONDecoder().decode(type, from: item.data)
|
||||
let data = item.data
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
print("Ошибка декодирования данных из кэша: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
guard let diskItem = loadFromDisk(key: key) else { return nil }
|
||||
queue.async(flags: .barrier) {
|
||||
self.items[key] = diskItem
|
||||
}
|
||||
return try? JSONDecoder().decode(type, from: diskItem.data)
|
||||
}
|
||||
|
||||
func delete(_ key: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.items.removeValue(forKey: key)
|
||||
}
|
||||
deleteFromDisk(key: key)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.items.removeAll()
|
||||
}
|
||||
clearDisk()
|
||||
}
|
||||
|
||||
private func startCleanupTimer() {
|
||||
@ -91,55 +73,6 @@ class Cache {
|
||||
return true
|
||||
}
|
||||
}
|
||||
cleanupDisk()
|
||||
}
|
||||
|
||||
private func cachesDirectory() -> URL {
|
||||
fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
private func fileURL(forKey key: String) -> URL {
|
||||
let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return cachesDirectory().appendingPathComponent("Cache_\(hash).json")
|
||||
}
|
||||
|
||||
private func saveToDisk(key: String, data: Data, ttl: TimeInterval) {
|
||||
let item = PersistedCacheItem(data: data, timestamp: Date(), ttl: ttl)
|
||||
guard let encoded = try? JSONEncoder().encode(item) else { return }
|
||||
let url = fileURL(forKey: key)
|
||||
try? encoded.write(to: url, options: .atomic)
|
||||
}
|
||||
|
||||
private func loadFromDisk(key: String) -> CacheItem? {
|
||||
let url = fileURL(forKey: key)
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let persisted = try? JSONDecoder().decode(PersistedCacheItem.self, from: data) else { return nil }
|
||||
return CacheItem(data: persisted.data, timestamp: persisted.timestamp, ttl: persisted.ttl)
|
||||
}
|
||||
|
||||
private func deleteFromDisk(key: String) {
|
||||
let url = fileURL(forKey: key)
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
|
||||
private func clearDisk() {
|
||||
let dir = cachesDirectory()
|
||||
let contents = (try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||
for url in contents where url.lastPathComponent.hasPrefix("Cache_") && url.pathExtension == "json" {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupDisk() {
|
||||
let dir = cachesDirectory()
|
||||
let contents = (try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||
for url in contents where url.lastPathComponent.hasPrefix("Cache_") && url.pathExtension == "json" {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let persisted = try? JSONDecoder().decode(PersistedCacheItem.self, from: data) else { continue }
|
||||
if Date().timeIntervalSince(persisted.timestamp) > persisted.ttl {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,12 +82,6 @@ struct CacheItem {
|
||||
let ttl: TimeInterval
|
||||
}
|
||||
|
||||
struct PersistedCacheItem: Codable {
|
||||
let data: Data
|
||||
let timestamp: Date
|
||||
let ttl: TimeInterval
|
||||
}
|
||||
|
||||
extension Cache {
|
||||
func setBoards(_ boards: [Board]) {
|
||||
set(boards, forKey: "boards", ttl: 600)
|
||||
@ -164,10 +91,6 @@ extension Cache {
|
||||
return get([Board].self, forKey: "boards")
|
||||
}
|
||||
|
||||
func getBoardsStale() -> [Board]? {
|
||||
return getStale([Board].self, forKey: "boards")
|
||||
}
|
||||
|
||||
func setThreads(_ threads: [Thread], forBoard boardCode: String) {
|
||||
set(threads, forKey: "threads_\(boardCode)", ttl: 300)
|
||||
}
|
||||
@ -176,10 +99,6 @@ extension Cache {
|
||||
return get([Thread].self, forKey: "threads_\(boardCode)")
|
||||
}
|
||||
|
||||
func getThreadsStale(forBoard boardCode: String) -> [Thread]? {
|
||||
return getStale([Thread].self, forKey: "threads_\(boardCode)")
|
||||
}
|
||||
|
||||
func setThreadDetail(_ thread: ThreadDetail, forThreadId threadId: Int) {
|
||||
set(thread, forKey: "thread_detail_\(threadId)", ttl: 180)
|
||||
}
|
||||
@ -188,10 +107,6 @@ extension Cache {
|
||||
return get(ThreadDetail.self, forKey: "thread_detail_\(threadId)")
|
||||
}
|
||||
|
||||
func getThreadDetailStale(forThreadId threadId: Int) -> ThreadDetail? {
|
||||
return getStale(ThreadDetail.self, forKey: "thread_detail_\(threadId)")
|
||||
}
|
||||
|
||||
func setComments(_ comments: [Comment], forThreadId threadId: Int) {
|
||||
set(comments, forKey: "comments_\(threadId)", ttl: 180)
|
||||
}
|
||||
@ -199,8 +114,4 @@ extension Cache {
|
||||
func getComments(forThreadId threadId: Int) -> [Comment]? {
|
||||
return get([Comment].self, forKey: "comments_\(threadId)")
|
||||
}
|
||||
|
||||
func getCommentsStale(forThreadId threadId: Int) -> [Comment]? {
|
||||
return getStale([Comment].self, forKey: "comments_\(threadId)")
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct CreateThreadView: View {
|
||||
let boardCode: String
|
||||
@ -14,8 +13,6 @@ struct CreateThreadView: View {
|
||||
@State private var showingSuccess = false
|
||||
@FocusState private var titleFocused: Bool
|
||||
@FocusState private var textFocused: Bool
|
||||
@State private var pickedImages: [UIImage] = []
|
||||
@State private var showPicker: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@ -44,38 +41,6 @@ struct CreateThreadView: View {
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Фото")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Button {
|
||||
showPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "photo.on.rectangle")
|
||||
Text("Выбрать фото")
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
if !pickedImages.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(pickedImages.enumerated()), id: \.offset) { _, img in
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 64, height: 64)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Содержание")
|
||||
@ -184,11 +149,6 @@ struct CreateThreadView: View {
|
||||
} message: {
|
||||
Text("Тред создан")
|
||||
}
|
||||
.sheet(isPresented: $showPicker) {
|
||||
ImagePickerView(selectionLimit: 4) { images in
|
||||
pickedImages = images
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,8 +162,7 @@ struct CreateThreadView: View {
|
||||
boardCode: boardCode,
|
||||
title: title,
|
||||
text: text,
|
||||
passcode: settings.passcode,
|
||||
files: buildUploadFiles()
|
||||
passcode: settings.passcode
|
||||
) { error in
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
@ -216,22 +175,6 @@ 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 {
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,16 +6,10 @@
|
||||
<array>
|
||||
<string>com.mkch.MobileMkch.backgroundrefresh</string>
|
||||
</array>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Нужно для выбора фото и загрузки вложений</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>background-processing</string>
|
||||
<string>background-fetch</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -1,201 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
<?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>
|
||||
@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
struct MobileMkchApp: App {
|
||||
@ -14,7 +13,6 @@ struct MobileMkchApp: App {
|
||||
@StateObject private var apiClient = APIClient()
|
||||
@StateObject private var crashHandler = CrashHandler.shared
|
||||
@StateObject private var notificationManager = NotificationManager.shared
|
||||
@StateObject private var networkMonitor = NetworkMonitor.shared
|
||||
|
||||
private func setupBackgroundTasks() {
|
||||
if let bundleIdentifier = Bundle.main.bundleIdentifier {
|
||||
@ -24,25 +22,6 @@ struct MobileMkchApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||
notificationManager.requestPermission { granted in
|
||||
if granted {
|
||||
print("Разрешения на уведомления получены")
|
||||
} else {
|
||||
print("Разрешения на уведомления отклонены")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNotificationLaunch() {
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let _ = scene.session.userInfo {
|
||||
print("Приложение запущено из уведомления")
|
||||
}
|
||||
notificationManager.clearBadge()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
@ -53,15 +32,12 @@ struct MobileMkchApp: App {
|
||||
.environmentObject(settings)
|
||||
.environmentObject(apiClient)
|
||||
.environmentObject(notificationManager)
|
||||
.environmentObject(networkMonitor)
|
||||
.preferredColorScheme(settings.theme == "dark" ? .dark : .light)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
BackgroundTaskManager.shared.registerBackgroundTasks()
|
||||
setupBackgroundTasks()
|
||||
setupNotifications()
|
||||
handleNotificationLaunch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
private enum DateFormatterCache {
|
||||
static let iso8601 = ISO8601DateFormatter()
|
||||
}
|
||||
|
||||
struct Board: Codable, Identifiable {
|
||||
let code: String
|
||||
let description: String
|
||||
@ -22,7 +18,8 @@ struct Thread: Codable, Identifiable {
|
||||
let files: [String]
|
||||
|
||||
var creationDate: Date {
|
||||
return DateFormatterCache.iso8601.date(from: creation) ?? Date()
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.date(from: creation) ?? Date()
|
||||
}
|
||||
|
||||
var ratingValue: Int {
|
||||
@ -43,7 +40,8 @@ struct ThreadDetail: Codable, Identifiable {
|
||||
let files: [String]
|
||||
|
||||
var creationDate: Date {
|
||||
return DateFormatterCache.iso8601.date(from: creation) ?? Date()
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.date(from: creation) ?? Date()
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +52,8 @@ struct Comment: Codable, Identifiable {
|
||||
let files: [String]
|
||||
|
||||
var creationDate: Date {
|
||||
return DateFormatterCache.iso8601.date(from: creation) ?? Date()
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.date(from: creation) ?? Date()
|
||||
}
|
||||
|
||||
var formattedText: String {
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,18 +2,6 @@ import Foundation
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = NotificationDelegate()
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationManager: ObservableObject {
|
||||
static let shared = NotificationManager()
|
||||
|
||||
@ -29,20 +17,11 @@ class NotificationManager: ObservableObject {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
|
||||
DispatchQueue.main.async {
|
||||
self.isNotificationsEnabled = granted
|
||||
if granted {
|
||||
self.registerForRemoteNotifications()
|
||||
}
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func registerForRemoteNotifications() {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
func checkNotificationStatus() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
DispatchQueue.main.async {
|
||||
@ -54,90 +33,36 @@ class NotificationManager: ObservableObject {
|
||||
func subscribeToBoard(_ boardCode: String) {
|
||||
subscribedBoards.insert(boardCode)
|
||||
saveSubscribedBoards()
|
||||
|
||||
BackgroundTaskManager.shared.scheduleBackgroundTask()
|
||||
}
|
||||
|
||||
private func syncThreadsForBoard(_ boardCode: String) {
|
||||
let url = URL(string: "https://mkch.pooziqo.xyz/api/board/\(boardCode)")!
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("MobileMkch/2.1.1-ios-alpha", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let data = data,
|
||||
let threads = try? JSONDecoder().decode([Thread].self, from: data) {
|
||||
let savedThreadsKey = "savedThreads_\(boardCode)"
|
||||
if let encodedData = try? JSONEncoder().encode(threads) {
|
||||
UserDefaults.standard.set(encodedData, forKey: savedThreadsKey)
|
||||
print("Синхронизировано \(threads.count) тредов для /\(boardCode)/")
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func unsubscribeFromBoard(_ boardCode: String) {
|
||||
subscribedBoards.remove(boardCode)
|
||||
saveSubscribedBoards()
|
||||
|
||||
let savedThreadsKey = "savedThreads_\(boardCode)"
|
||||
UserDefaults.standard.removeObject(forKey: savedThreadsKey)
|
||||
}
|
||||
|
||||
func scheduleNotification(for thread: Thread, boardCode: String) {
|
||||
guard isNotificationsEnabled else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Новый тред"
|
||||
content.body = "\(thread.title) в /\(boardCode)/"
|
||||
content.sound = .default
|
||||
content.badge = 1
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "thread_\(thread.id)_\(boardCode)", content: content, trigger: trigger)
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "thread_\(thread.id)", content: content, trigger: trigger)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
print("Ошибка планирования уведомления: \(error)")
|
||||
}
|
||||
}
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func scheduleTestNotification() {
|
||||
guard isNotificationsEnabled else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Тестовое уведомление"
|
||||
content.body = "Новый тред: Тестовый тред в /test/"
|
||||
content.sound = .default
|
||||
content.badge = 1
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "test_notification", content: content, trigger: trigger)
|
||||
|
||||
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("Очищены все сохраненные треды")
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
private func loadSubscribedBoards() {
|
||||
|
||||
@ -7,8 +7,6 @@ struct NotificationSettingsView: View {
|
||||
@State private var showingPermissionAlert = false
|
||||
@State private var boards: [Board] = []
|
||||
@State private var isLoadingBoards = false
|
||||
@State private var isCheckingThreads = false
|
||||
@State private var showingTestNotification = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@ -42,7 +40,7 @@ struct NotificationSettingsView: View {
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal)
|
||||
|
||||
Text("Уведомления находятся в бета-версии и могут работать нестабильно.")
|
||||
Text("Уведомления находятся в бета-версии и могут работать нестабильно. Функция может работать нестабильно или не работать ВОВСЕ.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@ -73,59 +71,12 @@ struct NotificationSettingsView: View {
|
||||
}
|
||||
.onChange(of: settings.notificationInterval) { _ in
|
||||
settings.saveSettings()
|
||||
BackgroundTaskManager.shared.scheduleBackgroundTask()
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingTestNotification = true
|
||||
notificationManager.scheduleTestNotification()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "bell.badge")
|
||||
Text("Отправить тестовое уведомление")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
.disabled(!notificationManager.isNotificationsEnabled)
|
||||
|
||||
Button(action: {
|
||||
isCheckingThreads = true
|
||||
Button("Проверить новые треды сейчас") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,9 +89,6 @@ struct NotificationSettingsView: View {
|
||||
Text("Загрузка досок...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else if boards.isEmpty {
|
||||
Text("Не удалось загрузить доски")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(boards) { board in
|
||||
HStack {
|
||||
@ -169,22 +117,6 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -207,11 +139,6 @@ struct NotificationSettingsView: View {
|
||||
} message: {
|
||||
Text("Для получения уведомлений о новых тредах необходимо разрешить уведомления в настройках")
|
||||
}
|
||||
.alert("Тестовое уведомление", isPresented: $showingTestNotification) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text("Тестовое уведомление отправлено. Проверьте, получили ли вы его.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,56 +154,24 @@ extension NotificationSettingsView {
|
||||
private func checkNewThreadsNow() {
|
||||
guard !notificationManager.subscribedBoards.isEmpty else { return }
|
||||
|
||||
let group = DispatchGroup()
|
||||
var foundNewThreads = false
|
||||
|
||||
for boardCode in notificationManager.subscribedBoards {
|
||||
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
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let newThreads):
|
||||
if !newThreads.isEmpty {
|
||||
foundNewThreads = true
|
||||
for thread in newThreads {
|
||||
notificationManager.scheduleNotification(for: thread, boardCode: boardCode)
|
||||
}
|
||||
UserDefaults.standard.set(newThreads.first?.id ?? lastKnownId, forKey: "lastThreadId_\(boardCode)")
|
||||
}
|
||||
case .failure(let error):
|
||||
print("Ошибка проверки тредов для /\(boardCode)/: \(error)")
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
isCheckingThreads = false
|
||||
if !foundNewThreads {
|
||||
print("Новых тредов не найдено")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncAllBoards() {
|
||||
for boardCode in notificationManager.subscribedBoards {
|
||||
let savedThreadsKey = "savedThreads_\(boardCode)"
|
||||
UserDefaults.standard.removeObject(forKey: savedThreadsKey)
|
||||
|
||||
let url = URL(string: "https://mkch.pooziqo.xyz/api/board/\(boardCode)")!
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("MobileMkch/2.1.1-ios-alpha", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let data = data,
|
||||
let threads = try? JSONDecoder().decode([Thread].self, from: data) {
|
||||
if let encodedData = try? JSONEncoder().encode(threads) {
|
||||
UserDefaults.standard.set(encodedData, forKey: savedThreadsKey)
|
||||
print("Синхронизировано \(threads.count) тредов для /\(boardCode)/")
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
|
||||
class Settings: ObservableObject {
|
||||
@Published var theme: String = "dark"
|
||||
@ -15,22 +14,12 @@ class Settings: ObservableObject {
|
||||
@Published var notificationsEnabled: Bool = false
|
||||
@Published var notificationInterval: Int = 300
|
||||
@Published var favoriteThreads: [FavoriteThread] = []
|
||||
@Published var offlineMode: Bool = false
|
||||
@Published var liveActivityEnabled: Bool = false
|
||||
@Published var liveActivityShowTitle: Bool = true
|
||||
@Published var liveActivityShowLastComment: Bool = true
|
||||
@Published var liveActivityShowCommentCount: Bool = true
|
||||
@Published var liveActivityTickerEnabled: Bool = false
|
||||
@Published var liveActivityTickerRandomBoard: Bool = true
|
||||
@Published var liveActivityTickerBoardCode: String = "b"
|
||||
@Published var liveActivityTickerInterval: Int = 15
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let settingsKey = "MobileMkchSettings"
|
||||
|
||||
init() {
|
||||
loadSettings()
|
||||
mirrorStateToAppGroup()
|
||||
}
|
||||
|
||||
func loadSettings() {
|
||||
@ -49,17 +38,7 @@ class Settings: ObservableObject {
|
||||
self.notificationsEnabled = settings.notificationsEnabled
|
||||
self.notificationInterval = settings.notificationInterval
|
||||
self.favoriteThreads = settings.favoriteThreads
|
||||
self.offlineMode = settings.offlineMode ?? false
|
||||
self.liveActivityEnabled = settings.liveActivityEnabled ?? false
|
||||
self.liveActivityShowTitle = settings.liveActivityShowTitle ?? true
|
||||
self.liveActivityShowLastComment = settings.liveActivityShowLastComment ?? true
|
||||
self.liveActivityShowCommentCount = settings.liveActivityShowCommentCount ?? true
|
||||
self.liveActivityTickerEnabled = settings.liveActivityTickerEnabled ?? false
|
||||
self.liveActivityTickerRandomBoard = settings.liveActivityTickerRandomBoard ?? true
|
||||
self.liveActivityTickerBoardCode = settings.liveActivityTickerBoardCode ?? "b"
|
||||
self.liveActivityTickerInterval = settings.liveActivityTickerInterval ?? 15
|
||||
}
|
||||
mirrorStateToAppGroup()
|
||||
}
|
||||
|
||||
func saveSettings() {
|
||||
@ -77,24 +56,11 @@ class Settings: ObservableObject {
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
notificationInterval: notificationInterval,
|
||||
favoriteThreads: favoriteThreads
|
||||
,
|
||||
offlineMode: offlineMode
|
||||
,
|
||||
liveActivityEnabled: liveActivityEnabled,
|
||||
liveActivityShowTitle: liveActivityShowTitle,
|
||||
liveActivityShowLastComment: liveActivityShowLastComment,
|
||||
liveActivityShowCommentCount: liveActivityShowCommentCount,
|
||||
liveActivityTickerEnabled: liveActivityTickerEnabled,
|
||||
liveActivityTickerRandomBoard: liveActivityTickerRandomBoard,
|
||||
liveActivityTickerBoardCode: liveActivityTickerBoardCode,
|
||||
liveActivityTickerInterval: liveActivityTickerInterval
|
||||
)
|
||||
|
||||
if let data = try? JSONEncoder().encode(settingsData) {
|
||||
userDefaults.set(data, forKey: settingsKey)
|
||||
}
|
||||
mirrorStateToAppGroup()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
func resetSettings() {
|
||||
@ -111,7 +77,6 @@ class Settings: ObservableObject {
|
||||
notificationsEnabled = false
|
||||
notificationInterval = 300
|
||||
favoriteThreads = []
|
||||
offlineMode = false
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
@ -135,17 +100,6 @@ class Settings: ObservableObject {
|
||||
func isFavorite(_ threadId: Int, boardCode: String) -> Bool {
|
||||
return favoriteThreads.contains { $0.id == threadId && $0.board == boardCode }
|
||||
}
|
||||
|
||||
private func mirrorStateToAppGroup() {
|
||||
guard let shared = AppGroup.defaults else { return }
|
||||
let mapped = favoriteThreads.map { FavoriteThreadWidget(id: $0.id, title: $0.title, board: $0.board, boardDescription: $0.boardDescription, addedDate: $0.addedDate) }
|
||||
if let encodedFavorites = try? JSONEncoder().encode(mapped) {
|
||||
shared.set(encodedFavorites, forKey: "favoriteThreads")
|
||||
}
|
||||
shared.set(offlineMode, forKey: "offlineMode")
|
||||
shared.set(lastBoard, forKey: "lastBoard")
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget")
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsData: Codable {
|
||||
@ -162,13 +116,4 @@ struct SettingsData: Codable {
|
||||
let notificationsEnabled: Bool
|
||||
let notificationInterval: Int
|
||||
let favoriteThreads: [FavoriteThread]
|
||||
let offlineMode: Bool?
|
||||
let liveActivityEnabled: Bool?
|
||||
let liveActivityShowTitle: Bool?
|
||||
let liveActivityShowLastComment: Bool?
|
||||
let liveActivityShowCommentCount: Bool?
|
||||
let liveActivityTickerEnabled: Bool?
|
||||
let liveActivityTickerRandomBoard: Bool?
|
||||
let liveActivityTickerBoardCode: String?
|
||||
let liveActivityTickerInterval: Int?
|
||||
}
|
||||
@ -1,14 +1,11 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Darwin
|
||||
import ActivityKit
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
|
||||
@State private var isTickerRunning = false
|
||||
@State private var showingAbout = false
|
||||
@State private var showingInfo = false
|
||||
@State private var testKeyResult: String?
|
||||
@ -113,26 +110,6 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Оффлайн режим") {
|
||||
HStack {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 24)
|
||||
Toggle("Принудительно оффлайн", isOn: $settings.offlineMode)
|
||||
}
|
||||
.onChange(of: settings.offlineMode) { newValue in
|
||||
if networkMonitor.forceOffline != newValue {
|
||||
networkMonitor.forceOffline = newValue
|
||||
settings.saveSettings()
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(networkMonitor.offlineEffective ? "Сейчас оффлайн: показываем кэш" : "Онлайн: будут загружаться свежие данные")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Аутентификация") {
|
||||
HStack {
|
||||
Image(systemName: "lock.shield")
|
||||
@ -205,65 +182,6 @@ struct SettingsView: View {
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
)
|
||||
if #available(iOS 16.1, *) {
|
||||
Toggle("Live Activity", isOn: $settings.liveActivityEnabled)
|
||||
.onReceive(Just(settings.liveActivityEnabled)) { _ in
|
||||
settings.saveSettings()
|
||||
}
|
||||
if settings.liveActivityEnabled {
|
||||
Toggle("Показывать заголовок", isOn: $settings.liveActivityShowTitle)
|
||||
.onReceive(Just(settings.liveActivityShowTitle)) { _ in settings.saveSettings() }
|
||||
Toggle("Показывать последний коммент", isOn: $settings.liveActivityShowLastComment)
|
||||
.onReceive(Just(settings.liveActivityShowLastComment)) { _ in settings.saveSettings() }
|
||||
Toggle("Показывать счётчик", isOn: $settings.liveActivityShowCommentCount)
|
||||
.onReceive(Just(settings.liveActivityShowCommentCount)) { _ in settings.saveSettings() }
|
||||
Toggle("Тикер случайных тредов", isOn: $settings.liveActivityTickerEnabled)
|
||||
.onReceive(Just(settings.liveActivityTickerEnabled)) { _ in settings.saveSettings() }
|
||||
if settings.liveActivityTickerEnabled {
|
||||
Toggle("Случайная борда", isOn: $settings.liveActivityTickerRandomBoard)
|
||||
.onReceive(Just(settings.liveActivityTickerRandomBoard)) { _ in settings.saveSettings() }
|
||||
if !settings.liveActivityTickerRandomBoard {
|
||||
HStack {
|
||||
Text("Код борды")
|
||||
TextField("b", text: $settings.liveActivityTickerBoardCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
.onReceive(Just(settings.liveActivityTickerBoardCode)) { _ in settings.saveSettings() }
|
||||
}
|
||||
HStack {
|
||||
Text("Интервал, сек")
|
||||
Spacer()
|
||||
Stepper(value: $settings.liveActivityTickerInterval, in: 5...120, step: 5) {
|
||||
Text("\(settings.liveActivityTickerInterval)")
|
||||
}
|
||||
}
|
||||
.onReceive(Just(settings.liveActivityTickerInterval)) { _ in settings.saveSettings() }
|
||||
HStack(spacing: 12) {
|
||||
Button("Старт тикера") {
|
||||
LiveActivityManager.shared.startTicker(settings: settings, apiClient: apiClient)
|
||||
isTickerRunning = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.green)
|
||||
Button("Стоп тикера") {
|
||||
LiveActivityManager.shared.stopTicker()
|
||||
isTickerRunning = false
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
Spacer()
|
||||
Text(isTickerRunning ? "Работает" : "Остановлен")
|
||||
.font(.caption)
|
||||
.foregroundColor(isTickerRunning ? .green : .secondary)
|
||||
}
|
||||
.onAppear { isTickerRunning = LiveActivityManager.shared.isTickerRunning }
|
||||
}
|
||||
Text("В фоне частые обновления ограничены системой")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Управление кэшем") {
|
||||
@ -472,7 +390,7 @@ struct AboutView: View {
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Версия: 2.1.1-ios-alpha (Always in alpha lol)")
|
||||
Text("Версия: 2.0.0-ios-alpha (Always in alpha lol)")
|
||||
Text("Автор: w^x (лейн, платон, а похуй как угодно)")
|
||||
Text("Разработано с <3 на Свифт")
|
||||
}
|
||||
@ -495,7 +413,6 @@ struct AboutView: View {
|
||||
struct DebugMenuView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var notificationManager: NotificationManager
|
||||
@State private var liveActivityStarted = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@ -517,25 +434,6 @@ struct DebugMenuView: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.foregroundColor(.blue)
|
||||
if #available(iOS 16.1, *) {
|
||||
Button(liveActivityStarted ? "Остановить Live Activity" : "Тест Live Activity") {
|
||||
if liveActivityStarted {
|
||||
LiveActivityManager.shared.end(threadId: 999999)
|
||||
liveActivityStarted = false
|
||||
} else {
|
||||
let detail = ThreadDetail(id: 999999, creation: "2023-01-01T00:00:00Z", title: "Тестовый тред", text: "", board: "b", files: [])
|
||||
let comments = [Comment(id: 1, text: "Привет из Live Activity", creation: "2023-01-01T00:00:00Z", files: [])]
|
||||
var s = Settings()
|
||||
s.liveActivityEnabled = true
|
||||
s.liveActivityShowTitle = true
|
||||
s.liveActivityShowLastComment = true
|
||||
s.liveActivityShowCommentCount = true
|
||||
LiveActivityManager.shared.start(for: detail, comments: comments, settings: s)
|
||||
liveActivityStarted = true
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@ -5,26 +5,15 @@ struct ThreadDetailView: View {
|
||||
let thread: Thread
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
@State private var threadDetail: ThreadDetail?
|
||||
@State private var comments: [Comment] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showingAddComment = false
|
||||
@State private var activityOn = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if networkMonitor.offlineEffective {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.orange)
|
||||
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
@ -82,14 +71,13 @@ struct ThreadDetailView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.refreshable {
|
||||
loadThreadDetail()
|
||||
}
|
||||
.navigationTitle("#\(thread.id)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Обновить") { loadThreadDetail() }
|
||||
Button("Обновить") {
|
||||
loadThreadDetail()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddComment) {
|
||||
@ -116,9 +104,6 @@ struct ThreadDetailView: View {
|
||||
case .success(let (detail, loadedComments)):
|
||||
self.threadDetail = detail
|
||||
self.comments = loadedComments
|
||||
if #available(iOS 16.1, *), settings.liveActivityEnabled, activityOn {
|
||||
LiveActivityManager.shared.update(threadId: thread.id, comments: loadedComments, settings: settings)
|
||||
}
|
||||
case .failure(let error):
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ struct ThreadsView: View {
|
||||
@EnvironmentObject var settings: Settings
|
||||
@EnvironmentObject var apiClient: APIClient
|
||||
@EnvironmentObject var notificationManager: NotificationManager
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
@State private var threads: [Thread] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@ -22,15 +21,6 @@ struct ThreadsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if networkMonitor.offlineEffective {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.orange)
|
||||
Text("Оффлайн режим. Показаны сохранённые данные")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
@ -63,9 +53,6 @@ struct ThreadsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
loadThreads()
|
||||
}
|
||||
|
||||
if settings.enablePagination && totalPages > 1 {
|
||||
HStack {
|
||||
|
||||
65
README.md
65
README.md
@ -2,7 +2,7 @@
|
||||
|
||||
Нативный iOS клиент для борды mkch.pooziqo.xyz
|
||||
|
||||

|
||||

|
||||

|
||||

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