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

|

|
||||||

|

|
||||||

|

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