diff --git a/Shared/Models/LocalStorageModel.xcdatamodeld/LocalStorageModel.xcdatamodel/contents b/Shared/Models/LocalStorageModel.xcdatamodeld/LocalStorageModel.xcdatamodel/contents index 92bd8c1..ff8b974 100644 --- a/Shared/Models/LocalStorageModel.xcdatamodeld/LocalStorageModel.xcdatamodel/contents +++ b/Shared/Models/LocalStorageModel.xcdatamodeld/LocalStorageModel.xcdatamodel/contents @@ -1,39 +1,40 @@ + - + \ No newline at end of file diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index 1fdb457..baf1dd3 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,335 +1,360 @@ import Foundation import WriteFreely import Security // MARK: - WriteFreelyModel class WriteFreelyModel: ObservableObject { @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var store = PostListModel() @Published var collections = CollectionListModel() @Published var isLoggingIn: Bool = false - @Published var selectedPost: Post? + @Published var selectedPost: WFAPost? private var client: WFClient? private let defaults = UserDefaults.standard init() { // Set the color scheme based on what's been saved in UserDefaults. DispatchQueue.main.async { self.preferences.appearance = self.defaults.integer(forKey: self.preferences.colorSchemeIntegerKey) } #if DEBUG // for post in testPostData { store.add(post) } #endif DispatchQueue.main.async { self.account.restoreState() if self.account.isLoggedIn { guard let serverURL = URL(string: self.account.server) else { print("Server URL not found") return } guard let token = self.fetchTokenFromKeychain( username: self.account.username, server: self.account.server ) else { print("Could not fetch token from Keychain") return } self.account.login(WFUser(token: token, username: self.account.username)) self.client = WFClient(for: serverURL) self.client?.user = self.account.user if self.collections.userCollections.count == 0 { self.fetchUserCollections() } self.fetchUserPosts() } } } } // MARK: - WriteFreelyModel API extension WriteFreelyModel { func login(to server: URL, as username: String, password: String) { isLoggingIn = true account.server = server.absoluteString client = WFClient(for: server) client?.login(username: username, password: password, completion: loginHandler) } func logout() { guard let loggedInClient = client else { do { try purgeTokenFromKeychain(username: account.username, server: account.server) account.logout() } catch { fatalError("Failed to log out persisted state") } return } loggedInClient.logout(completion: logoutHandler) } func fetchUserCollections() { guard let loggedInClient = client else { return } loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler) } func fetchUserPosts() { guard let loggedInClient = client else { return } loggedInClient.getPosts(completion: fetchUserPostsHandler) } - func publish(post: Post) { + func publish(post: WFAPost) { guard let loggedInClient = client else { return } - if let existingPostId = post.wfPost.postId { + var wfPost = WFPost( + body: post.body ?? "", + title: post.title, + appearance: post.appearance, + language: post.language, + rtl: post.rtl, + createdDate: post.createdDate + ) + + if let existingPostId = post.postId { // This is an existing post. + wfPost.postId = post.postId + wfPost.slug = post.slug + wfPost.updatedDate = post.updatedDate + wfPost.collectionAlias = post.collectionAlias + loggedInClient.updatePost( postId: existingPostId, - updatedPost: post.wfPost, + updatedPost: wfPost, completion: publishHandler ) } else { // This is a new local draft. loggedInClient.createPost( - post: post.wfPost, in: post.collection?.alias, completion: publishHandler + post: wfPost, in: post.collectionAlias, completion: publishHandler ) } } - func updateFromServer(post: Post) { + func updateFromServer(post: WFAPost) { guard let loggedInClient = client else { return } - guard let postId = post.wfPost.postId else { return } + guard let postId = post.postId else { return } DispatchQueue.main.async { self.selectedPost = post } loggedInClient.getPost(byId: postId, completion: updateFromServerHandler) } } private extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() fetchUserCollections() fetchUserPosts() saveTokenToKeychain(user.token, username: user.username, server: account.server) DispatchQueue.main.async { self.account.login(user) } } catch WFError.notFound { DispatchQueue.main.async { self.account.currentError = AccountError.usernameNotFound } } catch WFError.unauthorized { DispatchQueue.main.async { self.account.currentError = AccountError.invalidPassword } } catch { if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == -1003 { DispatchQueue.main.async { self.account.currentError = AccountError.serverNotFound } } } } func logoutHandler(result: Result) { do { _ = try result.get() do { try purgeTokenFromKeychain(username: account.user?.username, server: account.server) client = nil DispatchQueue.main.async { self.account.logout() self.collections.clearUserCollection() self.store.purgeAllPosts() } } catch { print("Something went wrong purging the token from the Keychain.") } } catch WFError.notFound { // The user token is invalid or doesn't exist, so it's been invalidated by the server. Proceed with // purging the token from the Keychain, destroying the client object, and setting the AccountModel to its // logged-out state. do { try purgeTokenFromKeychain(username: account.user?.username, server: account.server) client = nil DispatchQueue.main.async { self.collections.clearUserCollection() self.account.logout() self.store.purgeAllPosts() } } catch { print("Something went wrong purging the token from the Keychain.") } } catch { // We get a 'cannot parse response' (similar to what we were seeing in the Swift package) NSURLError here, // so we're using a hacky workaround — if we get the NSURLError, but the AccountModel still thinks we're // logged in, try calling the logout function again and see what we get. // Conditional cast from 'Error' to 'NSError' always succeeds but is the only way to check error properties. if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == NSURLErrorCannotParseResponse { if account.isLoggedIn { self.logout() } } } } func fetchUserCollectionsHandler(result: Result<[WFCollection], Error>) { do { let fetchedCollections = try result.get() for fetchedCollection in fetchedCollections { DispatchQueue.main.async { let localCollection = WFACollection(context: PersistenceManager.persistentContainer.viewContext) localCollection.alias = fetchedCollection.alias localCollection.blogDescription = fetchedCollection.description localCollection.email = fetchedCollection.email localCollection.isPublic = fetchedCollection.isPublic ?? false localCollection.styleSheet = fetchedCollection.styleSheet localCollection.title = fetchedCollection.title localCollection.url = fetchedCollection.url } } DispatchQueue.main.async { PersistenceManager().saveContext() } } catch { print(error) } } func fetchUserPostsHandler(result: Result<[WFPost], Error>) { do { let fetchedPosts = try result.get() - var fetchedPostsArray: [Post] = [] for fetchedPost in fetchedPosts { - var post: Post - if let matchingAlias = fetchedPost.collectionAlias { - let matchingCachedCollection = ( - collections.userCollections.filter { $0.alias == matchingAlias } - ).first - post = Post(wfPost: fetchedPost, in: matchingCachedCollection) - } else { - post = Post(wfPost: fetchedPost) - } - fetchedPostsArray.append(post) let managedPost = WFAPost(context: PersistenceManager.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 // 0 = local, 1 = edited, 2 = published + managedPost.status = PostStatus.published.rawValue } DispatchQueue.main.async { - self.store.updateStore(with: fetchedPostsArray) PersistenceManager().saveContext() } } catch { print(error) } } func publishHandler(result: Result) { do { - let wfPost = try result.get() + let fetchedPost = try result.get() let foundPostIndex = store.posts.firstIndex(where: { - $0.wfPost.title == wfPost.title && $0.wfPost.body == wfPost.body + $0.title == fetchedPost.title && $0.body == fetchedPost.body }) guard let index = foundPostIndex else { return } + let cachedPost = self.store.posts[index] + cachedPost.appearance = fetchedPost.appearance + cachedPost.body = fetchedPost.body + cachedPost.collectionAlias = fetchedPost.collectionAlias + 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 { - self.store.posts[index].wfPost = wfPost + PersistenceManager().saveContext() } } catch { print(error) } } func updateFromServerHandler(result: Result) { do { let fetchedPost = try result.get() + guard let cachedPost = self.selectedPost else { return } + cachedPost.appearance = fetchedPost.appearance + cachedPost.body = fetchedPost.body + cachedPost.collectionAlias = fetchedPost.collectionAlias + 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 { - guard let selectedPost = self.selectedPost else { return } - self.store.replace(post: selectedPost, with: fetchedPost) + PersistenceManager().saveContext() } } catch { print(error) } } } private extension WriteFreelyModel { // MARK: - Keychain Helpers func saveTokenToKeychain(_ token: String, username: String?, server: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecValueData as String: token.data(using: .utf8)!, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecDuplicateItem || status == errSecSuccess else { fatalError("Error storing in Keychain with OSStatus: \(status)") } } func purgeTokenFromKeychain(username: String?, server: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server ] let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { fatalError("Error deleting from Keychain with OSStatus: \(status)") } } func fetchTokenFromKeychain(username: String?, server: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: username ?? "anonymous", kSecAttrService as String: server, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, kSecReturnData as String: true ] var secItem: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &secItem) guard status != errSecItemNotFound else { return nil } guard status == errSecSuccess else { fatalError("Error fetching from Keychain with OSStatus: \(status)") } guard let existingSecItem = secItem as? [String: Any], let tokenData = existingSecItem[kSecValueData as String] as? Data, let token = String(data: tokenData, encoding: .utf8) else { return nil } return token } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index d95911c..88e9cf6 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,38 +1,38 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { NavigationView { SidebarView() PostListView(selectedCollection: nil, showAllPosts: true) Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) } .environmentObject(model) } } -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - let userCollection1 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) - let userCollection2 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) - let userCollection3 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) - userCollection1.title = "Collection 1" - userCollection2.title = "Collection 2" - userCollection3.title = "Collection 3" - - let model = WriteFreelyModel() - model.collections = CollectionListModel() - - for post in testPostData { - model.store.add(post) - } - return ContentView() - .environmentObject(model) - .environment(\.managedObjectContext, PersistenceManager.persistentContainer.viewContext) - } -} +//struct ContentView_Previews: PreviewProvider { +// static var previews: some View { +// let userCollection1 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +// let userCollection2 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +// let userCollection3 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +// userCollection1.title = "Collection 1" +// userCollection2.title = "Collection 2" +// userCollection3.title = "Collection 3" +// +// let model = WriteFreelyModel() +// model.collections = CollectionListModel() +// +// for post in testPostData { +// model.store.add(post) +// } +// return ContentView() +// .environmentObject(model) +// .environment(\.managedObjectContext, PersistenceManager.persistentContainer.viewContext) +// } +//} diff --git a/Shared/PostEditor/PostEditorStatusToolbarView.swift b/Shared/PostEditor/PostEditorStatusToolbarView.swift index 8c09962..85c5e65 100644 --- a/Shared/PostEditor/PostEditorStatusToolbarView.swift +++ b/Shared/PostEditor/PostEditorStatusToolbarView.swift @@ -1,136 +1,136 @@ import SwiftUI struct PostEditorStatusToolbarView: View { #if os(iOS) @Environment(\.horizontalSizeClass) var horizontalSizeClass #endif @EnvironmentObject var model: WriteFreelyModel - @ObservedObject var post: Post + @ObservedObject var post: WFAPost var body: some View { if post.hasNewerRemoteCopy { #if os(iOS) if horizontalSizeClass == .compact { VStack { PostStatusBadgeView(post: post) HStack { Text("⚠️ Newer copy on server. Replace local copy?") .font(.caption) .foregroundColor(.secondary) Button(action: { model.updateFromServer(post: post) }, label: { Image(systemName: "square.and.arrow.down") }) } .padding(.bottom) } .padding(.top) } else { HStack { PostStatusBadgeView(post: post) .padding(.trailing) Text("⚠️ Newer copy on server. Replace local copy?") .font(.callout) .foregroundColor(.secondary) Button(action: { model.updateFromServer(post: post) }, label: { Image(systemName: "square.and.arrow.down") }) } } #else HStack { PostStatusBadgeView(post: post) .padding(.trailing) Text("⚠️ Newer copy on server. Replace local copy?") .font(.callout) .foregroundColor(.secondary) Button(action: { model.updateFromServer(post: post) }, label: { Image(systemName: "square.and.arrow.down") }) } #endif } else { PostStatusBadgeView(post: post) } } } -#if DEBUG -let testPost = Post( - title: "Test Post Title", - body: """ - Here's some cool sample body text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ultrices \ - posuere dignissim. Vestibulum a libero tempor, lacinia nulla vitae, congue purus. Nunc ac nulla quam. Duis \ - tincidunt eros augue, et volutpat tortor pulvinar ut. Nullam sit amet maximus urna. Phasellus non dignissim lacus.\ - Nulla ac posuere ex. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec \ - non molestie mauris. Suspendisse potenti. Vivamus at erat turpis. - - Pellentesque porttitor gravida tincidunt. Sed vitae eros non metus aliquam hendrerit. Aliquam sed risus suscipit \ - turpis dictum dictum. Duis lacus lectus, dictum vel felis in, rhoncus fringilla felis. Nunc id dolor nisl. Aliquam \ - euismod purus elit. Nullam egestas neque leo, sed aliquet ligula ultrices nec. - """, - createdDate: Date() -) -#endif - -struct ToolbarView_LocalPreviews: PreviewProvider { - static var previews: some View { - let model = WriteFreelyModel() - let post = testPost - return PostEditorStatusToolbarView(post: post) - .environmentObject(model) - } -} - -struct ToolbarView_RemotePreviews: PreviewProvider { - static var previews: some View { - let model = WriteFreelyModel() - let newerRemotePost = Post( - title: testPost.wfPost.title ?? "", - body: testPost.wfPost.body, - createdDate: testPost.wfPost.createdDate ?? Date(), - status: testPost.status, - collection: testPost.collection - ) - newerRemotePost.hasNewerRemoteCopy = true - return PostEditorStatusToolbarView(post: newerRemotePost) - .environmentObject(model) - } -} - -#if os(iOS) -struct ToolbarView_CompactLocalPreviews: PreviewProvider { - static var previews: some View { - let model = WriteFreelyModel() - let post = testPost - return PostEditorStatusToolbarView(post: post) - .environmentObject(model) - .environment(\.horizontalSizeClass, .compact) - } -} -#endif - -#if os(iOS) -struct ToolbarView_CompactRemotePreviews: PreviewProvider { - static var previews: some View { - let model = WriteFreelyModel() - let newerRemotePost = Post( - title: testPost.wfPost.title ?? "", - body: testPost.wfPost.body, - createdDate: testPost.wfPost.createdDate ?? Date(), - status: testPost.status, - collection: testPost.collection - ) - newerRemotePost.hasNewerRemoteCopy = true - return PostEditorStatusToolbarView(post: newerRemotePost) - .environmentObject(model) - .environment(\.horizontalSizeClass, .compact) - } -} -#endif +//#if DEBUG +//let testPost = Post( +// title: "Test Post Title", +// body: """ +// Here's some cool sample body text. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ultrices \ +// posuere dignissim. Vestibulum a libero tempor, lacinia nulla vitae, congue purus. Nunc ac nulla quam. Duis \ +// tincidunt eros augue, et volutpat tortor pulvinar ut. Nullam sit amet maximus urna. Phasellus non dignissim \ +// lacus. Nulla ac posuere ex. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus \ +// mus. Donec non molestie mauris. Suspendisse potenti. Vivamus at erat turpis. +// +// Pellentesque porttitor gravida tincidunt. Sed vitae eros non metus aliquam hendrerit. Aliquam sed risus suscipit \ +// turpis dictum dictum. Duis lacus lectus, dictum vel felis in, rhoncus fringilla felis. Nunc id dolor nisl. \ +// Aliquam euismod purus elit. Nullam egestas neque leo, sed aliquet ligula ultrices nec. +// """, +// createdDate: Date() +//) +//#endif +// +//struct ToolbarView_LocalPreviews: PreviewProvider { +// static var previews: some View { +// let model = WriteFreelyModel() +// let post = testPost +// return PostEditorStatusToolbarView(post: post) +// .environmentObject(model) +// } +//} +// +//struct ToolbarView_RemotePreviews: PreviewProvider { +// static var previews: some View { +// let model = WriteFreelyModel() +// let newerRemotePost = Post( +// title: testPost.wfPost.title ?? "", +// body: testPost.wfPost.body, +// createdDate: testPost.wfPost.createdDate ?? Date(), +// status: testPost.status, +// collection: testPost.collection +// ) +// newerRemotePost.hasNewerRemoteCopy = true +// return PostEditorStatusToolbarView(post: newerRemotePost) +// .environmentObject(model) +// } +//} +// +//#if os(iOS) +//struct ToolbarView_CompactLocalPreviews: PreviewProvider { +// static var previews: some View { +// let model = WriteFreelyModel() +// let post = testPost +// return PostEditorStatusToolbarView(post: post) +// .environmentObject(model) +// .environment(\.horizontalSizeClass, .compact) +// } +//} +//#endif +// +//#if os(iOS) +//struct ToolbarView_CompactRemotePreviews: PreviewProvider { +// static var previews: some View { +// let model = WriteFreelyModel() +// let newerRemotePost = Post( +// title: testPost.wfPost.title ?? "", +// body: testPost.wfPost.body, +// createdDate: testPost.wfPost.createdDate ?? Date(), +// status: testPost.status, +// collection: testPost.collection +// ) +// newerRemotePost.hasNewerRemoteCopy = true +// return PostEditorStatusToolbarView(post: newerRemotePost) +// .environmentObject(model) +// .environment(\.horizontalSizeClass, .compact) +// } +//} +//#endif diff --git a/Shared/PostEditor/PostEditorView.swift b/Shared/PostEditor/PostEditorView.swift index 6b9911b..9fcd55d 100644 --- a/Shared/PostEditor/PostEditorView.swift +++ b/Shared/PostEditor/PostEditorView.swift @@ -1,98 +1,87 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel + @Environment(\.managedObjectContext) var moc - @ObservedObject var post: Post + @ObservedObject var post: WFAPost + + @State private var postTitle = "" + @State private var postBody = "" - @State private var isNewPost = false - @State private var title = "" var body: some View { VStack { - TextEditor(text: $title) + TextEditor(text: $postTitle) .font(.title) .frame(height: 100) - .onChange(of: title) { _ in - if post.status == .published && post.wfPost.title != title { - post.status = .edited + .onChange(of: postTitle) { _ in + if post.status == PostStatus.published.rawValue && post.title != postTitle { + post.status = PostStatus.edited.rawValue } - post.wfPost.title = title + post.title = postTitle } - TextEditor(text: $post.wfPost.body) + TextEditor(text: $postBody) .font(.body) - .onChange(of: post.wfPost.body) { _ in - if post.status == .published { - post.status = .edited + .onChange(of: postBody) { _ in + if post.status == PostStatus.published.rawValue { + post.status = PostStatus.edited.rawValue } + post.body = postBody } } .padding() .toolbar { ToolbarItem(placement: .status) { PostEditorStatusToolbarView(post: post) } ToolbarItem(placement: .primaryAction) { Button(action: { model.publish(post: post) - post.status = .published + post.status = PostStatus.published.rawValue }, label: { Image(systemName: "paperplane") }) } } .onAppear(perform: { - title = post.wfPost.title ?? "" - checkIfNewPost() - if self.isNewPost { - addNewPostToStore() - } + postTitle = post.title ?? "" + postBody = post.body ?? "" }) .onDisappear(perform: { - if post.status == .edited { + if post.status == PostStatus.edited.rawValue { DispatchQueue.main.async { - model.store.update(post) + PersistenceManager().saveContext() } } }) } - - private func checkIfNewPost() { - self.isNewPost = !model.store.posts.contains(where: { $0.id == post.id }) - } - - private func addNewPostToStore() { - withAnimation { - model.store.add(post) - self.isNewPost = false - } - } } -struct PostEditorView_NewLocalDraftPreviews: PreviewProvider { - static var previews: some View { - PostEditorView(post: Post()) - .environmentObject(WriteFreelyModel()) - } -} - -struct PostEditorView_NewerLocalPostPreviews: PreviewProvider { - static var previews: some View { - return PostEditorView(post: testPost) - .environmentObject(WriteFreelyModel()) - } -} - -struct PostEditorView_NewerRemotePostPreviews: PreviewProvider { - static var previews: some View { - let newerRemotePost = Post( - title: testPost.wfPost.title ?? "", - body: testPost.wfPost.body, - createdDate: testPost.wfPost.createdDate ?? Date(), - status: testPost.status, - collection: testPost.collection - ) - newerRemotePost.hasNewerRemoteCopy = true - return PostEditorView(post: newerRemotePost) - .environmentObject(WriteFreelyModel()) - } -} +//struct PostEditorView_NewLocalDraftPreviews: PreviewProvider { +// static var previews: some View { +// PostEditorView(post: Post()) +// .environmentObject(WriteFreelyModel()) +// } +//} +// +//struct PostEditorView_NewerLocalPostPreviews: PreviewProvider { +// static var previews: some View { +// return PostEditorView(post: testPost) +// .environmentObject(WriteFreelyModel()) +// } +//} +// +//struct PostEditorView_NewerRemotePostPreviews: PreviewProvider { +// static var previews: some View { +// let newerRemotePost = Post( +// title: testPost.wfPost.title ?? "", +// body: testPost.wfPost.body, +// createdDate: testPost.wfPost.createdDate ?? Date(), +// status: testPost.status, +// collection: testPost.collection +// ) +// newerRemotePost.hasNewerRemoteCopy = true +// return PostEditorView(post: newerRemotePost) +// .environmentObject(WriteFreelyModel()) +// } +//} diff --git a/Shared/PostList/PostCellView.swift b/Shared/PostList/PostCellView.swift index 00122ff..535260e 100644 --- a/Shared/PostList/PostCellView.swift +++ b/Shared/PostList/PostCellView.swift @@ -1,40 +1,40 @@ import SwiftUI struct PostCellView: View { - @ObservedObject var post: Post + @ObservedObject var post: WFAPost var body: some View { HStack { VStack(alignment: .leading) { - Text(post.wfPost.title ?? "") + Text(post.title ?? "") .font(.headline) .lineLimit(1) - Text(buildDateString(from: post.wfPost.createdDate ?? Date())) + Text(buildDateString(from: post.createdDate ?? Date())) .font(.caption) .lineLimit(1) } Spacer() PostStatusBadgeView(post: post) } .padding(5) } func buildDateString(from date: Date) -> String { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .long dateFormatter.timeStyle = .short return dateFormatter.string(from: date) } } -struct PostCell_Previews: PreviewProvider { - static var previews: some View { - let testPost = Post( - title: "Test Post Title", - body: "Here's some cool sample body text.", - createdDate: Date() - ) - return PostCellView(post: testPost) - } -} +//struct PostCell_Previews: PreviewProvider { +// static var previews: some View { +// let testPost = Post( +// title: "Test Post Title", +// body: "Here's some cool sample body text.", +// createdDate: Date() +// ) +// return PostCellView(post: testPost) +// } +//} diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index 442c692..3f2d9c9 100644 --- a/Shared/PostList/PostListModel.swift +++ b/Shared/PostList/PostListModel.swift @@ -1,77 +1,91 @@ import Foundation import WriteFreely import CoreData -struct PostListModel { - var posts: [Post] +class PostListModel: ObservableObject { + @Published var posts = [WFAPost]() - init(posts: [Post] = []) { - self.posts = posts + init() { + loadCachedPosts() } - mutating func add(_ post: Post) { - posts.append(post) + func loadCachedPosts() { + let request = WFAPost.createFetchRequest() + let sort = NSSortDescriptor(key: "createdDate", ascending: false) + request.sortDescriptors = [sort] + + posts = [] + do { + let cachedPosts = try PersistenceManager.persistentContainer.viewContext.fetch(request) + posts.append(contentsOf: cachedPosts) + } catch { + print("Error: Failed to fetch cached posts.") + } } - mutating func purgeAllPosts() { + func purgeAllPosts() { posts = [] let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFAPost") let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try PersistenceManager.persistentContainer.persistentStoreCoordinator.execute( deleteRequest, with: PersistenceManager.persistentContainer.viewContext ) } catch { print("Error: Failed to purge cached posts.") } } - mutating func update(_ post: Post) { - // Find the local copy in the store - let localCopy = posts.first(where: { $0.id == post.id }) +// func add(_ post: WFAPost) { +// posts.append(post) +// } - // If there's a local copy, update the updatedDate property of its WFPost - if let localCopy = localCopy { - localCopy.wfPost.updatedDate = Date() - } else { - print("Error: Local copy not found") - } - } +// func update(_ post: WFAPost) { +// // Find the local copy in the store +// let localCopy = posts.first(where: { $0.id == post.id }) +// +// // If there's a local copy, update the updatedDate property of its WFPost +// if let localCopy = localCopy { +// localCopy.wfPost.updatedDate = Date() +// } else { +// print("Error: Local copy not found") +// } +// } - mutating func replace(post: Post, with fetchedPost: WFPost) { - // Find the local copy in the store. - let localCopy = posts.first(where: { $0.id == post.id }) +// func replace(post: Post, with fetchedPost: WFPost) { +// // Find the local copy in the store. +// let localCopy = posts.first(where: { $0.id == post.id }) +// +// // Replace the local copy's wfPost property with the fetched copy. +// if let localCopy = localCopy { +// localCopy.wfPost = fetchedPost +// DispatchQueue.main.async { +// localCopy.hasNewerRemoteCopy = false +// localCopy.status = .published +// } +// } else { +// print("Error: Local copy not found") +// } +// } - // Replace the local copy's wfPost property with the fetched copy. - if let localCopy = localCopy { - localCopy.wfPost = fetchedPost - DispatchQueue.main.async { - localCopy.hasNewerRemoteCopy = false - localCopy.status = .published - } - } else { - print("Error: Local copy not found") - } - } - - mutating func updateStore(with fetchedPosts: [Post]) { - for fetchedPost in fetchedPosts { - // Find the local copy in the store. - let localCopy = posts.first(where: { $0.wfPost.postId == fetchedPost.wfPost.postId }) - - // If there's a local copy, check which is newer; if not, add the fetched post to the store. - if let localCopy = localCopy { - // We do not discard the local copy; we simply set the hasNewerRemoteCopy flag accordingly. - if let remoteCopyUpdatedDate = fetchedPost.wfPost.updatedDate, - let localCopyUpdatedDate = localCopy.wfPost.updatedDate { - localCopy.hasNewerRemoteCopy = remoteCopyUpdatedDate > localCopyUpdatedDate - } else { - print("Error: could not determine which copy of post is newer") - } - } else { - add(fetchedPost) - } - } - } +// func updateStore(with fetchedPosts: [Post]) { +// for fetchedPost in fetchedPosts { +// // Find the local copy in the store. +// let localCopy = posts.first(where: { $0.wfPost.postId == fetchedPost.wfPost.postId }) +// +// // If there's a local copy, check which is newer; if not, add the fetched post to the store. +// if let localCopy = localCopy { +// // We do not discard the local copy; we simply set the hasNewerRemoteCopy flag accordingly. +// if let remoteCopyUpdatedDate = fetchedPost.wfPost.updatedDate, +// let localCopyUpdatedDate = localCopy.wfPost.updatedDate { +// localCopy.hasNewerRemoteCopy = remoteCopyUpdatedDate > localCopyUpdatedDate +// } else { +// print("Error: could not determine which copy of post is newer") +// } +// } else { +// add(fetchedPost) +// } +// } +// } } diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift index 48c5500..81d23b6 100644 --- a/Shared/PostList/PostListView.swift +++ b/Shared/PostList/PostListView.swift @@ -1,209 +1,206 @@ import SwiftUI struct PostListView: View { @EnvironmentObject var model: WriteFreelyModel + @Environment(\.managedObjectContext) var moc + + @FetchRequest( + entity: WFAPost.entity(), + sortDescriptors: [NSSortDescriptor(keyPath: \WFAPost.createdDate, ascending: true)] + ) var posts: FetchedResults + @State var selectedCollection: WFACollection? @State var showAllPosts: Bool = false #if os(iOS) @State private var isPresentingSettings = false #endif var body: some View { #if os(iOS) GeometryReader { geometry in List { ForEach(showPosts(for: selectedCollection)) { post in - NavigationLink( - destination: PostEditorView(post: post) - ) { - PostCellView( - post: post - ) + NavigationLink(destination: PostEditorView(post: post)) { + PostCellView(post: post) } } } .environmentObject(model) .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) .toolbar { ToolbarItem(placement: .primaryAction) { Button(action: { createNewLocalDraft() }, label: { Image(systemName: "square.and.pencil") }) } ToolbarItem(placement: .bottomBar) { HStack { Button(action: { isPresentingSettings = true }, label: { Image(systemName: "gear") }).sheet( isPresented: $isPresentingSettings, onDismiss: { isPresentingSettings = false }, content: { SettingsView(isPresented: $isPresentingSettings) } ) .padding(.leading) Spacer() Text(pluralizedPostCount(for: showPosts(for: selectedCollection))) .foregroundColor(.secondary) Spacer() Button(action: { reloadFromServer() }, label: { Image(systemName: "arrow.clockwise") }) .disabled(!model.account.isLoggedIn) } .padding() .frame(width: geometry.size.width) } } } #else //if os(macOS) List { ForEach(showPosts(for: selectedCollection)) { post in - NavigationLink( - destination: PostEditorView(post: post) - ) { - PostCellView( - post: post - ) + NavigationLink(destination: PostEditorView(post: post)) { + PostCellView(post: post) } } } .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: [Post]) -> String { + 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?) -> [Post] { + private func showPosts(for collection: WFACollection?) -> [WFAPost] { if showAllPosts { return model.store.posts } else { - var posts: [Post] if let selectedCollection = collection { - posts = model.store.posts.filter { $0.wfPost.collectionAlias == selectedCollection.alias } + return model.store.posts.filter { $0.collectionAlias == selectedCollection.alias } } else { - posts = model.store.posts.filter { $0.wfPost.collectionAlias == nil } + return model.store.posts.filter { $0.collectionAlias == nil } } - return posts } } private func reloadFromServer() { DispatchQueue.main.async { model.collections.clearUserCollection() model.fetchUserCollections() model.fetchUserPosts() } } private func createNewLocalDraft() { let post = Post() let managedPost = WFAPost(context: PersistenceManager.persistentContainer.viewContext) managedPost.createdDate = post.wfPost.createdDate managedPost.title = post.wfPost.title managedPost.body = post.wfPost.body managedPost.status = PostStatus.local.rawValue DispatchQueue.main.async { - model.store.add(post) +// model.store.add(post) PersistenceManager().saveContext() } } } -struct PostList_Previews: PreviewProvider { - static var previews: some View { - let userCollection1 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) - let userCollection2 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) - let userCollection3 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) - - userCollection1.title = "Collection 1" - userCollection2.title = "Collection 2" - userCollection3.title = "Collection 3" - - let testPostData = [ - Post( - title: "My First Post", - body: "Look at me, creating a first post! That's cool.", - createdDate: Date(timeIntervalSince1970: 1595429452), - status: .published, - collection: userCollection1 - ), - Post( - title: "Post 2: The Quickening", - body: "See, here's the rule about Highlander jokes: _there can be only one_.", - createdDate: Date(timeIntervalSince1970: 1595514125), - status: .edited, - collection: userCollection1 - ), - Post( - title: "The Post Revolutions", - body: "I can never keep the Matrix movie order straight. Why not just call them part 2 and part 3?", - createdDate: Date(timeIntervalSince1970: 1595600006) - ), - Post( - title: "Episode IV: A New Post", - body: "How many movies does this person watch? How many movie-title jokes will they make?", - createdDate: Date(timeIntervalSince1970: 1596219877), - status: .published, - collection: userCollection2 - ), - Post( - title: "Fast (Post) Five", - body: "Look, it was either a Fast and the Furious reference, or a Resident Evil reference." - ), - Post( - title: "Post: The Final Chapter", - body: "And there you have it, a Resident Evil movie reference.", - createdDate: Date(timeIntervalSince1970: 1596043684), - status: .edited, - collection: userCollection3 - ) - ] - - let model = WriteFreelyModel() - for post in testPostData { - model.store.add(post) - } - return Group { - PostListView(selectedCollection: userCollection1) - .environmentObject(model) - } - } -} +//struct PostList_Previews: PreviewProvider { +// static var previews: some View { +// let userCollection1 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +// let userCollection2 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +// let userCollection3 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +// +// userCollection1.title = "Collection 1" +// userCollection2.title = "Collection 2" +// userCollection3.title = "Collection 3" +// +// let testPostData = [ +// Post( +// title: "My First Post", +// body: "Look at me, creating a first post! That's cool.", +// createdDate: Date(timeIntervalSince1970: 1595429452), +// status: .published, +// collection: userCollection1 +// ), +// Post( +// title: "Post 2: The Quickening", +// body: "See, here's the rule about Highlander jokes: _there can be only one_.", +// createdDate: Date(timeIntervalSince1970: 1595514125), +// status: .edited, +// collection: userCollection1 +// ), +// Post( +// title: "The Post Revolutions", +// body: "I can never keep the Matrix movie order straight. Why not just call them part 2 and part 3?", +// createdDate: Date(timeIntervalSince1970: 1595600006) +// ), +// Post( +// title: "Episode IV: A New Post", +// body: "How many movies does this person watch? How many movie-title jokes will they make?", +// createdDate: Date(timeIntervalSince1970: 1596219877), +// status: .published, +// collection: userCollection2 +// ), +// Post( +// title: "Fast (Post) Five", +// body: "Look, it was either a Fast and the Furious reference, or a Resident Evil reference." +// ), +// Post( +// title: "Post: The Final Chapter", +// body: "And there you have it, a Resident Evil movie reference.", +// createdDate: Date(timeIntervalSince1970: 1596043684), +// status: .edited, +// collection: userCollection3 +// ) +// ] +// +// let model = WriteFreelyModel() +// for post in testPostData { +// model.store.add(post) +// } +// return Group { +// PostListView(selectedCollection: userCollection1) +// .environmentObject(model) +// } +// } +//} diff --git a/Shared/PostList/PostStatusBadgeView.swift b/Shared/PostList/PostStatusBadgeView.swift index d7f0ac9..6681ced 100644 --- a/Shared/PostList/PostStatusBadgeView.swift +++ b/Shared/PostList/PostStatusBadgeView.swift @@ -1,103 +1,103 @@ import SwiftUI struct PostStatusBadgeView: View { - @ObservedObject var post: Post + @ObservedObject var post: WFAPost var body: some View { - let (badgeLabel, badgeColor) = setupBadgeProperties(for: post.status) + let (badgeLabel, badgeColor) = setupBadgeProperties(for: PostStatus(rawValue: post.status)!) Text(badgeLabel) .font(.caption) .fontWeight(.semibold) .foregroundColor(.white) .textCase(.uppercase) .lineLimit(1) .padding(EdgeInsets(top: 2.5, leading: 7.5, bottom: 2.5, trailing: 7.5)) .background(badgeColor) .clipShape(RoundedRectangle(cornerRadius: 5.0, style: .circular)) } func setupBadgeProperties(for status: PostStatus) -> (String, Color) { var badgeLabel: String var badgeColor: Color switch status { case .local: badgeLabel = "local" badgeColor = Color(red: 0.75, green: 0.5, blue: 0.85, opacity: 1.0) case .edited: badgeLabel = "edited" badgeColor = Color(red: 0.75, green: 0.7, blue: 0.1, opacity: 1.0) case .published: badgeLabel = "published" badgeColor = .gray } return (badgeLabel, badgeColor) } } -#if DEBUG -let userCollection1 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) -let userCollection2 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) -let userCollection3 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) - -let testPostData = [ - Post( - title: "My First Post", - body: "Look at me, creating a first post! That's cool.", - createdDate: Date(timeIntervalSince1970: 1595429452), - status: .published, - collection: userCollection1 - ), - Post( - title: "Post 2: The Quickening", - body: "See, here's the rule about Highlander jokes: _there can be only one_.", - createdDate: Date(timeIntervalSince1970: 1595514125), - status: .edited, - collection: userCollection1 - ), - Post( - title: "The Post Revolutions", - body: "I can never keep the Matrix movie order straight. Why not just call them part 2 and part 3?", - createdDate: Date(timeIntervalSince1970: 1595600006) - ), - Post( - title: "Episode IV: A New Post", - body: "How many movies does this person watch? How many movie-title jokes will they make?", - createdDate: Date(timeIntervalSince1970: 1596219877), - status: .published, - collection: userCollection2 - ), - Post( - title: "Fast (Post) Five", - body: "Look, it was either a Fast and the Furious reference, or a Resident Evil reference." - ), - Post( - title: "Post: The Final Chapter", - body: "And there you have it, a Resident Evil movie reference.", - createdDate: Date(timeIntervalSince1970: 1596043684), - status: .edited, - collection: userCollection3 - ) -] -#endif - -struct PostStatusBadge_LocalDraftPreviews: PreviewProvider { - static var previews: some View { - userCollection1.title = "Collection 1" - return PostStatusBadgeView(post: testPostData[2]) - } -} - -struct PostStatusBadge_EditedPreviews: PreviewProvider { - static var previews: some View { - userCollection1.title = "Collection 1" - return PostStatusBadgeView(post: testPostData[1]) - } -} - -struct PostStatusBadge_PublishedPreviews: PreviewProvider { - static var previews: some View { - PostStatusBadgeView(post: testPostData[0]) - } -} +//#if DEBUG +//let userCollection1 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +//let userCollection2 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +//let userCollection3 = WFACollection(context: PersistenceManager.persistentContainer.viewContext) +// +//let testPostData = [ +// Post( +// title: "My First Post", +// body: "Look at me, creating a first post! That's cool.", +// createdDate: Date(timeIntervalSince1970: 1595429452), +// status: .published, +// collection: userCollection1 +// ), +// Post( +// title: "Post 2: The Quickening", +// body: "See, here's the rule about Highlander jokes: _there can be only one_.", +// createdDate: Date(timeIntervalSince1970: 1595514125), +// status: .edited, +// collection: userCollection1 +// ), +// Post( +// title: "The Post Revolutions", +// body: "I can never keep the Matrix movie order straight. Why not just call them part 2 and part 3?", +// createdDate: Date(timeIntervalSince1970: 1595600006) +// ), +// Post( +// title: "Episode IV: A New Post", +// body: "How many movies does this person watch? How many movie-title jokes will they make?", +// createdDate: Date(timeIntervalSince1970: 1596219877), +// status: .published, +// collection: userCollection2 +// ), +// Post( +// title: "Fast (Post) Five", +// body: "Look, it was either a Fast and the Furious reference, or a Resident Evil reference." +// ), +// Post( +// title: "Post: The Final Chapter", +// body: "And there you have it, a Resident Evil movie reference.", +// createdDate: Date(timeIntervalSince1970: 1596043684), +// status: .edited, +// collection: userCollection3 +// ) +//] +//#endif +// +//struct PostStatusBadge_LocalDraftPreviews: PreviewProvider { +// static var previews: some View { +// userCollection1.title = "Collection 1" +// return PostStatusBadgeView(post: testPostData[2]) +// } +//} +// +//struct PostStatusBadge_EditedPreviews: PreviewProvider { +// static var previews: some View { +// userCollection1.title = "Collection 1" +// return PostStatusBadgeView(post: testPostData[1]) +// } +//} +// +//struct PostStatusBadge_PublishedPreviews: PreviewProvider { +// static var previews: some View { +// PostStatusBadgeView(post: testPostData[0]) +// } +//} diff --git a/WFAPost+CoreDataProperties.swift b/WFAPost+CoreDataProperties.swift index ba31720..d7ef424 100644 --- a/WFAPost+CoreDataProperties.swift +++ b/WFAPost+CoreDataProperties.swift @@ -1,26 +1,35 @@ +// +// WFAPost+CoreDataProperties.swift +// WriteFreely-MultiPlatform +// +// Created by Angelo Stavrow on 2020-09-08. +// +// + import Foundation import CoreData extension WFAPost { @nonobjc public class func createFetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "WFAPost") } @NSManaged public var appearance: String? @NSManaged public var body: String? @NSManaged public var collectionAlias: String? @NSManaged public var createdDate: Date? @NSManaged public var language: String? @NSManaged public var postId: String? @NSManaged public var rtl: Bool @NSManaged public var slug: String? @NSManaged public var status: Int32 @NSManaged public var title: String? @NSManaged public var updatedDate: Date? + @NSManaged public var hasNewerRemoteCopy: Bool } extension WFAPost: Identifiable { } diff --git a/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist b/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist index 2723ebe..6cd8075 100644 --- a/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist @@ -1,19 +1,19 @@ SchemeUserState WriteFreely-MultiPlatform (iOS).xcscheme_^#shared#^_ orderHint - 0 + 1 WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_ orderHint - 1 + 0