diff --git a/Shared/Models/WriteFreelyModel.swift b/Shared/Models/WriteFreelyModel.swift index f5b1011..d392e02 100644 --- a/Shared/Models/WriteFreelyModel.swift +++ b/Shared/Models/WriteFreelyModel.swift @@ -1,74 +1,76 @@ import Foundation import WriteFreely import Security import Network // MARK: - WriteFreelyModel final class WriteFreelyModel: ObservableObject { @Published var account = AccountModel() @Published var preferences = PreferencesModel() @Published var posts = PostListModel() @Published var editor = PostEditorModel() @Published var isLoggingIn: Bool = false @Published var isProcessingRequest: Bool = false @Published var hasNetworkConnection: Bool = true @Published var selectedPost: WFAPost? @Published var selectedCollection: WFACollection? @Published var showAllPosts: Bool = true @Published var isPresentingDeleteAlert: Bool = false @Published var isPresentingLoginErrorAlert: Bool = false @Published var isPresentingNetworkErrorAlert: Bool = false @Published var postToDelete: WFAPost? #if os(iOS) @Published var isPresentingSettingsView: Bool = false #endif + static var shared = WriteFreelyModel() + var loginErrorMessage: String? // swiftlint:disable line_length let helpURL = URL(string: "https://discuss.write.as/c/help/5")! let howToURL = URL(string: "https://discuss.write.as/t/using-the-writefreely-ios-app/1946")! let reviewURL = URL(string: "https://apps.apple.com/app/id1531530896?action=write-review")! let licensesURL = URL(string: "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses")! // swiftlint:enable line_length internal var client: WFClient? private let defaults = UserDefaults.standard private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") internal var postToUpdate: WFAPost? init() { DispatchQueue.main.async { self.preferences.appearance = self.defaults.integer(forKey: self.preferences.colorSchemeIntegerKey) self.preferences.font = self.defaults.integer(forKey: self.preferences.defaultFontIntegerKey) 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 self.fetchUserCollections() self.fetchUserPosts() } } monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.hasNetworkConnection = path.status == .satisfied } } monitor.start(queue: queue) } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 09d389f..a60322f 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,98 +1,94 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { NavigationView { #if os(macOS) CollectionListView() .toolbar { Button( action: { NSApp.keyWindow?.contentViewController?.tryToPerform( #selector(NSSplitViewController.toggleSidebar(_:)), with: nil ) }, label: { Image(systemName: "sidebar.left") } ) .help("Toggle the sidebar's visibility.") Spacer() Button(action: { withAnimation { // Un-set the currently selected post self.model.selectedPost = nil - - // Navigate to the Drafts list - self.model.showAllPosts = false - self.model.selectedCollection = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { DispatchQueue.main.asyncAfter(deadline: .now()) { // Load the new post in the editor self.model.selectedPost = managedPost } } }, label: { Image(systemName: "square.and.pencil") }) .help("Create a new local draft.") } #else CollectionListView() #endif #if os(macOS) ZStack { PostListView() if model.isProcessingRequest { ZStack { Color(NSColor.controlBackgroundColor).opacity(0.75) ProgressView() } } } #else PostListView() #endif Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) } .environmentObject(model) #if os(iOS) EmptyView() .sheet( isPresented: $model.isPresentingSettingsView, onDismiss: { model.isPresentingSettingsView = false }, content: { SettingsView() .environmentObject(model) } ) .alert(isPresented: $model.isPresentingNetworkErrorAlert, content: { Alert( title: Text("Connection Error"), message: Text(""" There is no internet connection at the moment. Please reconnect or try again later. """), dismissButton: .default(Text("OK"), action: { model.isPresentingNetworkErrorAlert = false }) ) }) #endif } } struct ContentView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() return ContentView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostEditor/PostEditorModel.swift b/Shared/PostEditor/PostEditorModel.swift index 014681f..8219ead 100644 --- a/Shared/PostEditor/PostEditorModel.swift +++ b/Shared/PostEditor/PostEditorModel.swift @@ -1,63 +1,63 @@ import SwiftUI import CoreData enum PostAppearance: String { case sans = "OpenSans-Regular" case mono = "Hack-Regular" case serif = "Lora-Regular" } struct PostEditorModel { @AppStorage("showAllPostsFlag") var showAllPostsFlag: Bool = false @AppStorage("selectedCollectionURL") var selectedCollectionURL: URL? @AppStorage("lastDraftURL") var lastDraftURL: URL? func saveLastDraft(_ post: WFAPost) { self.lastDraftURL = post.status != PostStatus.published.rawValue ? post.objectID.uriRepresentation() : nil } func clearLastDraft() { self.lastDraftURL = nil } func fetchLastDraftFromAppStorage() -> WFAPost? { guard let postURL = lastDraftURL else { return nil } guard let post = fetchManagedObject(from: postURL) as? WFAPost else { return nil } return post } func generateNewLocalPost(withFont appearance: Int) -> WFAPost { let managedPost = WFAPost(context: LocalStorageManager.persistentContainer.viewContext) managedPost.createdDate = Date() managedPost.title = "" managedPost.body = "" managedPost.status = PostStatus.local.rawValue - managedPost.collectionAlias = nil + managedPost.collectionAlias = WriteFreelyModel.shared.selectedCollection?.alias switch appearance { 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 } return managedPost } func fetchSelectedCollectionFromAppStorage() -> WFACollection? { guard let collectionURL = selectedCollectionURL else { return nil } guard let collection = fetchManagedObject(from: collectionURL) as? WFACollection else { return nil } return collection } private func fetchManagedObject(from objectURL: URL) -> NSManagedObject? { let coordinator = LocalStorageManager.persistentContainer.persistentStoreCoordinator guard let managedObjectID = coordinator.managedObjectID(forURIRepresentation: objectURL) else { return nil } let object = LocalStorageManager.persistentContainer.viewContext.object(with: managedObjectID) return object } } diff --git a/Shared/WriteFreely_MultiPlatformApp.swift b/Shared/WriteFreely_MultiPlatformApp.swift index bca2dd9..900a267 100644 --- a/Shared/WriteFreely_MultiPlatformApp.swift +++ b/Shared/WriteFreely_MultiPlatformApp.swift @@ -1,149 +1,145 @@ import SwiftUI #if os(macOS) import Sparkle #endif @main struct CheckForDebugModifier { static func main() { #if os(macOS) if NSEvent.modifierFlags.contains(.shift) { // Clear the launch-to-last-draft values to load a new draft. UserDefaults.standard.setValue(false, forKey: "showAllPostsFlag") UserDefaults.standard.setValue(nil, forKey: "selectedCollectionURL") UserDefaults.standard.setValue(nil, forKey: "lastDraftURL") } else { // No-op } #endif WriteFreely_MultiPlatformApp.main() } } struct WriteFreely_MultiPlatformApp: App { - @StateObject private var model = WriteFreelyModel() + @StateObject private var model = WriteFreelyModel.shared #if os(macOS) // swiftlint:disable:next weak_delegate @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @State private var selectedTab = 0 #endif var body: some Scene { WindowGroup { ContentView() .onAppear(perform: { if model.editor.showAllPostsFlag { DispatchQueue.main.async { self.model.selectedCollection = nil self.model.showAllPosts = true } } else { DispatchQueue.main.async { self.model.selectedCollection = model.editor.fetchSelectedCollectionFromAppStorage() self.model.showAllPosts = false } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if model.editor.lastDraftURL != nil { self.model.selectedPost = model.editor.fetchLastDraftFromAppStorage() } else { createNewLocalPost() } } }) .environmentObject(model) .environment(\.managedObjectContext, LocalStorageManager.persistentContainer.viewContext) // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } .commands { #if os(macOS) CommandGroup(after: .appInfo, addition: { Button("Check For Updates") { SUUpdater.shared()?.checkForUpdates(self) } }) #endif CommandGroup(replacing: .newItem, addition: { Button("New Post") { createNewLocalPost() } .keyboardShortcut("n", modifiers: [.command]) }) CommandGroup(after: .newItem) { Button("Refresh Posts") { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } } .disabled(!model.account.isLoggedIn) .keyboardShortcut("r", modifiers: [.command]) } SidebarCommands() #if os(macOS) PostCommands(model: model) #endif CommandGroup(after: .help) { Button("Visit Support Forum") { #if os(macOS) NSWorkspace().open(model.helpURL) #else UIApplication.shared.open(model.helpURL) #endif } } ToolbarCommands() TextEditingCommands() } #if os(macOS) Settings { TabView(selection: $selectedTab) { MacAccountView() .environmentObject(model) .tabItem { Image(systemName: "person.crop.circle") Text("Account") } .tag(0) MacPreferencesView(preferences: model.preferences) .tabItem { Image(systemName: "gear") Text("Preferences") } .tag(1) MacUpdatesView() .tabItem { Image(systemName: "arrow.down.circle") Text("Updates") } .tag(2) } .frame(minWidth: 500, maxWidth: 500, minHeight: 200) .padding() // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } #endif } private func createNewLocalPost() { withAnimation { // Un-set the currently selected post self.model.selectedPost = nil - - // Navigate to the Drafts list - self.model.showAllPosts = false - self.model.selectedCollection = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { // Set it as the selectedPost DispatchQueue.main.asyncAfter(deadline: .now()) { self.model.selectedPost = managedPost } } } } diff --git a/macOS/PostEditor/PostTextEditingView.swift b/macOS/PostEditor/PostTextEditingView.swift index feb6e5c..fabae24 100644 --- a/macOS/PostEditor/PostTextEditingView.swift +++ b/macOS/PostEditor/PostTextEditingView.swift @@ -1,99 +1,115 @@ import SwiftUI struct PostTextEditingView: View { @ObservedObject var post: WFAPost @Binding var updatingFromServer: Bool @State private var appearance: PostAppearance = .serif @State private var combinedText = "" + @State private var hasBeenEdited: Bool = false + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { ZStack(alignment: .topLeading) { if combinedText.count == 0 { Text("Write…") .foregroundColor(Color(NSColor.placeholderTextColor)) .padding(.horizontal, 5) .font(.custom(appearance.rawValue, size: 17, relativeTo: .body)) } if post.appearance == "sans" { MacEditorTextView( text: $combinedText, isFirstResponder: combinedText.isEmpty, isEditable: true, font: NSFont(name: PostAppearance.sans.rawValue, size: 17), onEditingChanged: onEditingChanged, onCommit: onCommit, onTextChange: onTextChange ) } else if post.appearance == "wrap" || post.appearance == "mono" || post.appearance == "code" { MacEditorTextView( text: $combinedText, isFirstResponder: combinedText.isEmpty, isEditable: true, font: NSFont(name: PostAppearance.mono.rawValue, size: 17), onEditingChanged: onEditingChanged, onCommit: onCommit, onTextChange: onTextChange ) } else { MacEditorTextView( text: $combinedText, isFirstResponder: combinedText.isEmpty, isEditable: true, font: NSFont(name: PostAppearance.serif.rawValue, size: 17), onEditingChanged: onEditingChanged, onCommit: onCommit, onTextChange: onTextChange ) } } .background(Color(NSColor.controlBackgroundColor)) .onAppear(perform: { if post.title.isEmpty { self.combinedText = post.body } else { self.combinedText = "# \(post.title)\n\n\(post.body)" } }) + .onReceive(timer) { _ in + if !post.body.isEmpty && hasBeenEdited { + DispatchQueue.main.async { + LocalStorageManager().saveContext() + hasBeenEdited = false + } + } + } } private func onEditingChanged() { - // Add code here to take action when the user first starts typing. + hasBeenEdited = true } private func onTextChange(_ text: String) { extractTitle(text) if post.status == PostStatus.published.rawValue && !updatingFromServer { post.status = PostStatus.edited.rawValue } if updatingFromServer { self.updatingFromServer = false } + hasBeenEdited = true } private func onCommit() { - // Add code here to take action when the user navigates away from the post. + if !post.body.isEmpty && hasBeenEdited { + DispatchQueue.main.async { + LocalStorageManager().saveContext() + } + } + hasBeenEdited = false } private func extractTitle(_ text: String) { var detectedTitle: String if text.hasPrefix("# ") { let endOfTitleIndex = text.firstIndex(of: "\n") ?? text.endIndex detectedTitle = String(text[..