diff --git a/Shared/Account/AccountLoginView.swift b/Shared/Account/AccountLoginView.swift index 02c32f9..de7bb97 100644 --- a/Shared/Account/AccountLoginView.swift +++ b/Shared/Account/AccountLoginView.swift @@ -55,7 +55,9 @@ struct AccountLoginView: View { .padding() } else { Button(action: { + #if os(iOS) hideKeyboard() + #endif model.login( to: URL(string: server)!, as: username, password: password diff --git a/Shared/Account/AccountLogoutView.swift b/Shared/Account/AccountLogoutView.swift index 8613d6b..9f69a50 100644 --- a/Shared/Account/AccountLogoutView.swift +++ b/Shared/Account/AccountLogoutView.swift @@ -2,12 +2,12 @@ import SwiftUI struct AccountLogoutView: View { @EnvironmentObject var model: WriteFreelyModel - @Environment(\.managedObjectContext) var moc @State private var isPresentingLogoutConfirmation: Bool = false @State private var editedPostsWarningString: String = "" var body: some View { + #if os(iOS) VStack { Spacer() VStack { @@ -31,6 +31,36 @@ struct AccountLogoutView: View { ] ) }) + #else + VStack { + Spacer() + VStack { + Text("Logged in as \(model.account.username)") + Text("on \(model.account.server)") + } + Spacer() + Button(action: logoutHandler, label: { + Text("Log Out") + }) + } + .sheet(isPresented: $isPresentingLogoutConfirmation) { + VStack { + Text("Log Out?") + .font(.title) + Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?") + HStack { + Button(action: model.logout, label: { + Text("Log Out") + }) + Button(action: { + self.isPresentingLogoutConfirmation = false + }, label: { + Text("Cancel") + }).keyboardShortcut(.cancelAction) + } + } + } + #endif } func logoutHandler() { diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index ca3764b..c079cf5 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -13,19 +13,7 @@ class WriteFreelyModel: ObservableObject { @Published var isLoggingIn: Bool = false @Published var isProcessingRequest: Bool = false @Published var hasNetworkConnection: Bool = true - @Published var selectedPost: WFAPost? { - didSet { - if let post = selectedPost { - if post.status != PostStatus.published.rawValue { - editor.setLastDraft(post) - } else { - editor.clearLastDraft() - } - } else { - editor.clearLastDraft() - } - } - } + @Published var selectedPost: WFAPost? @Published var isPresentingDeleteAlert: Bool = false @Published var isPresentingLoginErrorAlert: Bool = false @Published var isPresentingNetworkErrorAlert: Bool = false @@ -347,44 +335,49 @@ private extension WriteFreelyModel { DispatchQueue.main.async { self.isProcessingRequest = false } + let request = WFAPost.createFetchRequest() do { - var postsToDelete = posts.userPosts.filter { $0.status != PostStatus.local.rawValue } - let fetchedPosts = try result.get() - for fetchedPost in fetchedPosts { - if let managedPost = posts.userPosts.first(where: { $0.postId == fetchedPost.postId }) { - managedPost.wasDeletedFromServer = false - if let fetchedPostUpdatedDate = fetchedPost.updatedDate, - let localPostUpdatedDate = managedPost.updatedDate { - managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate + let locallyCachedPosts = try LocalStorageManager.persistentContainer.viewContext.fetch(request) + do { + var postsToDelete = locallyCachedPosts.filter { $0.status != PostStatus.local.rawValue } + let fetchedPosts = try result.get() + for fetchedPost in fetchedPosts { + if let managedPost = locallyCachedPosts.first(where: { $0.postId == fetchedPost.postId }) { + DispatchQueue.main.async { + managedPost.wasDeletedFromServer = false + if let fetchedPostUpdatedDate = fetchedPost.updatedDate, + let localPostUpdatedDate = managedPost.updatedDate { + managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate + } else { print("Error: could not determine which copy of post is newer") } + postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) + } } else { - print("Error: could not determine which copy of post is newer") + DispatchQueue.main.async { + let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) + managedPost.postId = fetchedPost.postId + managedPost.slug = fetchedPost.slug + managedPost.appearance = fetchedPost.appearance + managedPost.language = fetchedPost.language + managedPost.rtl = fetchedPost.rtl ?? false + managedPost.createdDate = fetchedPost.createdDate + managedPost.updatedDate = fetchedPost.updatedDate + managedPost.title = fetchedPost.title ?? "" + managedPost.body = fetchedPost.body + managedPost.collectionAlias = fetchedPost.collectionAlias + managedPost.status = PostStatus.published.rawValue + managedPost.wasDeletedFromServer = false + } } - postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) - } else { - let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) - managedPost.postId = fetchedPost.postId - managedPost.slug = fetchedPost.slug - managedPost.appearance = fetchedPost.appearance - managedPost.language = fetchedPost.language - managedPost.rtl = fetchedPost.rtl ?? false - managedPost.createdDate = fetchedPost.createdDate - managedPost.updatedDate = fetchedPost.updatedDate - managedPost.title = fetchedPost.title ?? "" - managedPost.body = fetchedPost.body - managedPost.collectionAlias = fetchedPost.collectionAlias - managedPost.status = PostStatus.published.rawValue - managedPost.wasDeletedFromServer = false } - } - for post in postsToDelete { - post.wasDeletedFromServer = true - } - DispatchQueue.main.async { - LocalStorageManager().saveContext() - self.posts.loadCachedPosts() + DispatchQueue.main.async { + for post in postsToDelete { post.wasDeletedFromServer = true } + LocalStorageManager().saveContext() + } + } catch { + print(error) } } catch { - print(error) + print("Error: Failed to fetch cached posts") } } @@ -399,23 +392,37 @@ private extension WriteFreelyModel { // See: https://github.com/writeas/writefreely-swift/issues/20 do { let fetchedPost = try result.get() - let foundPostIndex = posts.userPosts.firstIndex(where: { - $0.title == fetchedPost.title && $0.body == fetchedPost.body - }) - guard let index = foundPostIndex else { return } - let cachedPost = self.posts.userPosts[index] - cachedPost.appearance = fetchedPost.appearance - cachedPost.body = fetchedPost.body - cachedPost.createdDate = fetchedPost.createdDate - cachedPost.language = fetchedPost.language - cachedPost.postId = fetchedPost.postId - cachedPost.rtl = fetchedPost.rtl ?? false - cachedPost.slug = fetchedPost.slug - cachedPost.status = PostStatus.published.rawValue - cachedPost.title = fetchedPost.title ?? "" - cachedPost.updatedDate = fetchedPost.updatedDate - DispatchQueue.main.async { - LocalStorageManager().saveContext() + let request = WFAPost.createFetchRequest() + let matchBodyPredicate = NSPredicate(format: "body == %@", fetchedPost.body) + if let fetchedPostTitle = fetchedPost.title { + let matchTitlePredicate = NSPredicate(format: "title == %@", fetchedPostTitle) + request.predicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [ + matchTitlePredicate, + matchBodyPredicate + ] + ) + } else { + request.predicate = matchBodyPredicate + } + do { + let cachedPostsResults = try LocalStorageManager.persistentContainer.viewContext.fetch(request) + guard let cachedPost = cachedPostsResults.first else { return } + cachedPost.appearance = fetchedPost.appearance + cachedPost.body = fetchedPost.body + cachedPost.createdDate = fetchedPost.createdDate + cachedPost.language = fetchedPost.language + cachedPost.postId = fetchedPost.postId + cachedPost.rtl = fetchedPost.rtl ?? false + cachedPost.slug = fetchedPost.slug + cachedPost.status = PostStatus.published.rawValue + cachedPost.title = fetchedPost.title ?? "" + cachedPost.updatedDate = fetchedPost.updatedDate + DispatchQueue.main.async { + LocalStorageManager().saveContext() + } + } catch { + print("Error: Failed to fetch cached posts") } } catch { print(error) @@ -447,7 +454,6 @@ private extension WriteFreelyModel { cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager().saveContext() - self.posts.loadCachedPosts() } } catch { print(error) diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 426b112..6831b7c 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -12,30 +12,6 @@ struct ContentView: View { Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) } - .onAppear(perform: { - if let lastDraft = self.model.editor.fetchLastDraft() { - model.selectedPost = lastDraft - } else { - let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) - managedPost.createdDate = Date() - managedPost.title = "" - managedPost.body = "" - managedPost.status = PostStatus.local.rawValue - switch self.model.preferences.font { - case 1: - managedPost.appearance = "sans" - case 2: - managedPost.appearance = "wrap" - default: - managedPost.appearance = "serif" - } - if let languageCode = Locale.current.languageCode { - managedPost.language = languageCode - managedPost.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft - } - model.selectedPost = managedPost - } - }) .environmentObject(model) .alert(isPresented: $model.isPresentingDeleteAlert) { Alert( @@ -44,7 +20,7 @@ struct ContentView: View { primaryButton: .destructive(Text("Delete"), action: { if let postToDelete = model.postToDelete { model.selectedPost = nil - withAnimation { + DispatchQueue.main.async { model.posts.remove(postToDelete) } model.postToDelete = nil diff --git a/Shared/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift index 9dbcf68..23a4aa5 100644 --- a/Shared/PostCollection/CollectionListView.swift +++ b/Shared/PostCollection/CollectionListView.swift @@ -2,7 +2,6 @@ import SwiftUI struct CollectionListView: View { @EnvironmentObject var model: WriteFreelyModel - @Environment(\.managedObjectContext) var moc @FetchRequest( entity: WFACollection.entity(), diff --git a/Shared/PostEditor/PostEditorModel.swift b/Shared/PostEditor/PostEditorModel.swift index 84e774c..8d83713 100644 --- a/Shared/PostEditor/PostEditorModel.swift +++ b/Shared/PostEditor/PostEditorModel.swift @@ -1,4 +1,4 @@ -import Foundation +import SwiftUI import CoreData enum PostAppearance: String { @@ -8,31 +8,25 @@ enum PostAppearance: String { } struct PostEditorModel { - let lastDraftObjectURLKey = "lastDraftObjectURLKey" - private(set) var lastDraft: WFAPost? + @AppStorage("lastDraftURL") private var lastDraftURL: URL? - mutating func setLastDraft(_ post: WFAPost) { - lastDraft = post - UserDefaults.standard.set(post.objectID.uriRepresentation(), forKey: lastDraftObjectURLKey) + func saveLastDraft(_ post: WFAPost) { + self.lastDraftURL = post.status != PostStatus.published.rawValue ? post.objectID.uriRepresentation() : nil } - mutating func fetchLastDraft() -> WFAPost? { - let coordinator = LocalStorageManager.persistentContainer.persistentStoreCoordinator - - // See if we have a lastDraftObjectURI - guard let lastDraftObjectURI = UserDefaults.standard.url(forKey: lastDraftObjectURLKey) else { return nil } + func clearLastDraft() { + self.lastDraftURL = nil + } - // See if we can get an ObjectID from the URI representation - guard let lastDraftObjectID = coordinator.managedObjectID(forURIRepresentation: lastDraftObjectURI) else { - return nil - } + func fetchLastDraftFromUserDefaults() -> WFAPost? { + guard let postURL = lastDraftURL else { return nil } - lastDraft = LocalStorageManager.persistentContainer.viewContext.object(with: lastDraftObjectID) as? WFAPost - return lastDraft - } + let coordinator = LocalStorageManager.persistentContainer.persistentStoreCoordinator + guard let postManagedObjectID = coordinator.managedObjectID(forURIRepresentation: postURL) else { return nil } + guard let post = LocalStorageManager.persistentContainer.viewContext.object( + with: postManagedObjectID + ) as? WFAPost else { return nil } - mutating func clearLastDraft() { - lastDraft = nil - UserDefaults.standard.removeObject(forKey: lastDraftObjectURLKey) + return post } } diff --git a/Shared/PostList/PostListFilteredView.swift b/Shared/PostList/PostListFilteredView.swift index bb42ba3..6b37511 100644 --- a/Shared/PostList/PostListFilteredView.swift +++ b/Shared/PostList/PostListFilteredView.swift @@ -2,12 +2,12 @@ import SwiftUI struct PostListFilteredView: View { @EnvironmentObject var model: WriteFreelyModel - + @Binding var postCount: Int @FetchRequest(entity: WFACollection.entity(), sortDescriptors: []) var collections: FetchedResults var fetchRequest: FetchRequest var showAllPosts: Bool - init(filter: String?, showAllPosts: Bool) { + init(filter: String?, showAllPosts: Bool, postCount: Binding) { self.showAllPosts = showAllPosts if showAllPosts { fetchRequest = FetchRequest( @@ -29,6 +29,7 @@ struct PostListFilteredView: View { ) } } + _postCount = postCount } var body: some View { @@ -60,6 +61,12 @@ struct PostListFilteredView: View { } }) } + .onAppear(perform: { + self.postCount = fetchRequest.wrappedValue.count + }) + .onChange(of: fetchRequest.wrappedValue.count, perform: { value in + self.postCount = value + }) #else List { ForEach(fetchRequest.wrappedValue, id: \.self) { post in @@ -79,6 +86,12 @@ struct PostListFilteredView: View { } }) } + .onAppear(perform: { + self.postCount = fetchRequest.wrappedValue.count + }) + .onChange(of: fetchRequest.wrappedValue.count, perform: { value in + self.postCount = value + }) .onDeleteCommand(perform: { guard let selectedPost = model.selectedPost else { return } if selectedPost.status == PostStatus.local.rawValue { @@ -96,6 +109,6 @@ struct PostListFilteredView: View { struct PostListFilteredView_Previews: PreviewProvider { static var previews: some View { - return PostListFilteredView(filter: nil, showAllPosts: false) + return PostListFilteredView(filter: nil, showAllPosts: false, postCount: .constant(999)) } } diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index 774a451..e6464e4 100644 --- a/Shared/PostList/PostListModel.swift +++ b/Shared/PostList/PostListModel.swift @@ -2,40 +2,18 @@ import SwiftUI import CoreData class PostListModel: ObservableObject { - @Published var userPosts = [WFAPost]() - - init() { - loadCachedPosts() - } - - func loadCachedPosts() { - let request = WFAPost.createFetchRequest() - let sort = NSSortDescriptor(key: "createdDate", ascending: false) - request.sortDescriptors = [sort] - - userPosts = [] - do { - let cachedPosts = try LocalStorageManager.persistentContainer.viewContext.fetch(request) - userPosts.append(contentsOf: cachedPosts) - } catch { - print("Error: Failed to fetch cached posts.") - } - } - func remove(_ post: WFAPost) { LocalStorageManager.persistentContainer.viewContext.delete(post) LocalStorageManager().saveContext() } func purgePublishedPosts() { - userPosts = [] let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFAPost") fetchRequest.predicate = NSPredicate(format: "status != %i", 0) let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try LocalStorageManager.persistentContainer.viewContext.executeAndMergeChanges(using: deleteRequest) - loadCachedPosts() } catch { print("Error: Failed to purge cached posts.") } diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift index bdaad24..dddca79 100644 --- a/Shared/PostList/PostListView.swift +++ b/Shared/PostList/PostListView.swift @@ -1,16 +1,18 @@ import SwiftUI +import Combine struct PostListView: View { @EnvironmentObject var model: WriteFreelyModel - @Environment(\.managedObjectContext) var moc + @Environment(\.managedObjectContext) var managedObjectContext @State var selectedCollection: WFACollection? @State var showAllPosts: Bool = false + @State private var postCount: Int = 0 var body: some View { #if os(iOS) GeometryReader { geometry in - PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts) + PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts, postCount: $postCount) .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" @@ -32,7 +34,7 @@ struct PostListView: View { Image(systemName: "gear") }) Spacer() - Text(pluralizedPostCount(for: showPosts(for: selectedCollection))) + Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") .foregroundColor(.secondary) Spacer() if model.isProcessingRequest { @@ -52,47 +54,27 @@ struct PostListView: View { } } #else //if os(macOS) - PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts) - .navigationTitle( - showAllPosts ? "All Posts" : selectedCollection?.title ?? ( - model.account.server == "https://write.as" ? "Anonymous" : "Drafts" + PostListFilteredView(filter: selectedCollection?.alias, showAllPosts: showAllPosts, postCount: $postCount) + .navigationTitle( + showAllPosts ? "All Posts" : selectedCollection?.title ?? ( + model.account.server == "https://write.as" ? "Anonymous" : "Drafts" + ) ) - ) - .navigationSubtitle(pluralizedPostCount(for: showPosts(for: selectedCollection))) - .toolbar { - Button(action: { - createNewLocalDraft() - }, label: { - Image(systemName: "square.and.pencil") - }) - Button(action: { - reloadFromServer() - }, label: { - Image(systemName: "arrow.clockwise") - }) - .disabled(!model.account.isLoggedIn) - } - #endif - } - - private func pluralizedPostCount(for posts: [WFAPost]) -> String { - if posts.count == 1 { - return "1 post" - } else { - return "\(posts.count) posts" - } - } - - private func showPosts(for collection: WFACollection?) -> [WFAPost] { - if showAllPosts { - return model.posts.userPosts - } else { - if let selectedCollection = collection { - return model.posts.userPosts.filter { $0.collectionAlias == selectedCollection.alias } - } else { - return model.posts.userPosts.filter { $0.collectionAlias == nil } + .navigationSubtitle(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") + .toolbar { + Button(action: { + createNewLocalDraft() + }, label: { + Image(systemName: "square.and.pencil") + }) + Button(action: { + reloadFromServer() + }, label: { + Image(systemName: "arrow.clockwise") + }) + .disabled(!model.account.isLoggedIn) } - } + #endif } private func reloadFromServer() { @@ -103,7 +85,7 @@ struct PostListView: View { } private func createNewLocalDraft() { - let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) + let managedPost = WFAPost(context: self.managedObjectContext) managedPost.createdDate = Date() managedPost.title = "" managedPost.body = "" diff --git a/iOS/PostEditor/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift index 36d05f6..a5699f4 100644 --- a/iOS/PostEditor/PostEditorView.swift +++ b/iOS/PostEditor/PostEditorView.swift @@ -2,6 +2,7 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel + @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.managedObjectContext) var moc @Environment(\.presentationMode) var presentationMode @@ -127,17 +128,6 @@ struct PostEditorView: View { updatingBodyFromServer = true } }) - .onChange(of: post.status, perform: { _ in - if post.status != PostStatus.published.rawValue { - DispatchQueue.main.async { - model.editor.setLastDraft(post) - } - } else { - DispatchQueue.main.async { - model.editor.clearLastDraft() - } - } - }) .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in if post.collectionAlias == newCollection?.alias { return @@ -148,6 +138,11 @@ struct PostEditorView: View { }) .onAppear(perform: { self.selectedCollection = collections.first { $0.alias == post.collectionAlias } + if post.status != PostStatus.published.rawValue { + self.model.editor.saveLastDraft(post) + } else { + self.model.editor.clearLastDraft() + } }) .onDisappear(perform: { if post.title.count == 0 @@ -157,7 +152,6 @@ struct PostEditorView: View { && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) - model.posts.loadCachedPosts() } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { @@ -170,7 +164,6 @@ struct PostEditorView: View { private func publishPost() { DispatchQueue.main.async { LocalStorageManager().saveContext() - model.posts.loadCachedPosts() model.publish(post: post) } #if os(iOS) diff --git a/macOS/PostEditor/PostEditorView.swift b/macOS/PostEditor/PostEditorView.swift index ba7cb53..6d2b238 100644 --- a/macOS/PostEditor/PostEditorView.swift +++ b/macOS/PostEditor/PostEditorView.swift @@ -41,17 +41,6 @@ struct PostEditorView: View { post.status = PostStatus.published.rawValue } }) - .onChange(of: post.status, perform: { _ in - if post.status != PostStatus.published.rawValue { - DispatchQueue.main.async { - model.editor.setLastDraft(post) - } - } else { - DispatchQueue.main.async { - model.editor.clearLastDraft() - } - } - }) .onDisappear(perform: { if post.title.count == 0 && post.body.count == 0 @@ -60,7 +49,6 @@ struct PostEditorView: View { && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) - model.posts.loadCachedPosts() } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { @@ -73,7 +61,6 @@ struct PostEditorView: View { private func publishPost() { DispatchQueue.main.async { LocalStorageManager().saveContext() - model.posts.loadCachedPosts() model.publish(post: post) } }