Source code for the WriteFreely SwiftUI app for iOS, iPadOS, and macOS
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 

284 řádky
12 KiB

  1. import SwiftUI
  2. struct PostEditorView: View {
  3. @EnvironmentObject var model: WriteFreelyModel
  4. @EnvironmentObject var errorHandling: ErrorHandling
  5. @Environment(\.horizontalSizeClass) var horizontalSizeClass
  6. @Environment(\.managedObjectContext) var moc
  7. @Environment(\.presentationMode) var presentationMode
  8. @ObservedObject var post: WFAPost
  9. @State private var updatingTitleFromServer: Bool = false
  10. @State private var updatingBodyFromServer: Bool = false
  11. @State private var selectedCollection: WFACollection?
  12. @FetchRequest(
  13. entity: WFACollection.entity(),
  14. sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)]
  15. ) var collections: FetchedResults<WFACollection>
  16. var body: some View {
  17. VStack {
  18. if post.hasNewerRemoteCopy {
  19. RemoteChangePromptView(
  20. remoteChangeType: .remoteCopyUpdated,
  21. buttonHandler: { model.updateFromServer(post: post) }
  22. )
  23. } else if post.wasDeletedFromServer {
  24. RemoteChangePromptView(
  25. remoteChangeType: .remoteCopyDeleted,
  26. buttonHandler: {
  27. self.presentationMode.wrappedValue.dismiss()
  28. DispatchQueue.main.async {
  29. model.posts.remove(post)
  30. }
  31. }
  32. )
  33. }
  34. PostTextEditingView(
  35. post: _post,
  36. updatingTitleFromServer: $updatingTitleFromServer,
  37. updatingBodyFromServer: $updatingBodyFromServer
  38. )
  39. .withErrorHandling()
  40. }
  41. .navigationBarTitleDisplayMode(.inline)
  42. .padding()
  43. .toolbar {
  44. ToolbarItem(placement: .principal) {
  45. PostEditorStatusToolbarView(post: post)
  46. }
  47. ToolbarItem(placement: .primaryAction) {
  48. if model.isProcessingRequest {
  49. ProgressView()
  50. } else {
  51. Menu(content: {
  52. if post.status == PostStatus.local.rawValue {
  53. Menu(content: {
  54. Label("Publish to…", systemImage: "paperplane")
  55. Button(action: {
  56. if model.account.isLoggedIn {
  57. post.collectionAlias = nil
  58. publishPost()
  59. } else {
  60. self.model.isPresentingSettingsView = true
  61. }
  62. }, label: {
  63. Text(" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")")
  64. })
  65. ForEach(collections) { collection in
  66. Button(action: {
  67. if model.account.isLoggedIn {
  68. post.collectionAlias = collection.alias
  69. publishPost()
  70. } else {
  71. self.model.isPresentingSettingsView = true
  72. }
  73. }, label: {
  74. Text(" \(collection.title)")
  75. })
  76. }
  77. }, label: {
  78. Label("Publish…", systemImage: "paperplane")
  79. })
  80. .accessibilityHint(Text("Choose the blog you want to publish this post to"))
  81. .disabled(post.body.count == 0)
  82. } else if post.status == PostStatus.edited.rawValue {
  83. Button(action: {
  84. if model.account.isLoggedIn {
  85. publishPost()
  86. } else {
  87. self.model.isPresentingSettingsView = true
  88. }
  89. }, label: {
  90. Label("Publish", systemImage: "paperplane")
  91. })
  92. .disabled(post.status == PostStatus.published.rawValue || post.body.count == 0)
  93. Button(action: {
  94. model.updateFromServer(post: post)
  95. }, label: {
  96. Label("Revert", systemImage: "clock.arrow.circlepath")
  97. })
  98. .accessibilityHint(Text("Replace the edited post with the published version from the server"))
  99. .disabled(post.status != PostStatus.edited.rawValue)
  100. }
  101. Button(action: {
  102. sharePost()
  103. }, label: {
  104. Label("Share", systemImage: "square.and.arrow.up")
  105. })
  106. .accessibilityHint(Text("Open the system share sheet to share a link to this post"))
  107. .disabled(post.postId == nil)
  108. if model.account.isLoggedIn && post.status != PostStatus.local.rawValue {
  109. Section(header: Text("Move To Collection")) {
  110. Label("Move to:", systemImage: "arrowshape.zigzag.right")
  111. Picker(selection: $selectedCollection, label: Text("Move to…")) {
  112. Text(
  113. " \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")"
  114. ).tag(nil as WFACollection?)
  115. ForEach(collections) { collection in
  116. Text(" \(collection.title)").tag(collection as WFACollection?)
  117. }
  118. }
  119. }
  120. }
  121. }, label: {
  122. ZStack {
  123. Image("does.not.exist")
  124. .accessibilityHidden(true)
  125. Image(systemName: "ellipsis.circle")
  126. .imageScale(.large)
  127. .accessibilityHidden(true)
  128. }
  129. })
  130. .accessibilityLabel(Text("Menu"))
  131. .accessibilityHint(Text("Opens a context menu to publish, share, or move the post"))
  132. .onTapGesture {
  133. hideKeyboard()
  134. }
  135. .disabled(post.body.count == 0)
  136. }
  137. }
  138. }
  139. .onChange(of: post.hasNewerRemoteCopy, perform: { _ in
  140. if !post.hasNewerRemoteCopy {
  141. updatingTitleFromServer = true
  142. updatingBodyFromServer = true
  143. }
  144. })
  145. .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in
  146. if post.collectionAlias == newCollection?.alias {
  147. return
  148. } else {
  149. post.collectionAlias = newCollection?.alias
  150. model.move(post: post, from: selectedCollection, to: newCollection)
  151. }
  152. })
  153. .onChange(of: post.status, perform: { value in
  154. if value != PostStatus.published.rawValue {
  155. self.model.editor.saveLastDraft(post)
  156. } else {
  157. self.model.editor.clearLastDraft()
  158. }
  159. DispatchQueue.main.async {
  160. LocalStorageManager.standard.saveContext()
  161. }
  162. })
  163. .onAppear(perform: {
  164. self.selectedCollection = collections.first { $0.alias == post.collectionAlias }
  165. model.editor.setInitialValues(for: post)
  166. if post.status != PostStatus.published.rawValue {
  167. DispatchQueue.main.async {
  168. self.model.editor.saveLastDraft(post)
  169. }
  170. } else {
  171. self.model.editor.clearLastDraft()
  172. }
  173. })
  174. .onChange(of: model.hasError) { value in
  175. if value {
  176. if let error = model.currentError {
  177. self.errorHandling.handle(error: error)
  178. } else {
  179. self.errorHandling.handle(error: AppError.genericError())
  180. }
  181. model.hasError = false
  182. }
  183. }
  184. .onDisappear(perform: {
  185. self.model.editor.clearLastDraft()
  186. if post.title.count == 0
  187. && post.body.count == 0
  188. && post.status == PostStatus.local.rawValue
  189. && post.updatedDate == nil
  190. && post.postId == nil {
  191. DispatchQueue.main.async {
  192. model.posts.remove(post)
  193. }
  194. } else if post.status != PostStatus.published.rawValue {
  195. DispatchQueue.main.async {
  196. LocalStorageManager.standard.saveContext()
  197. }
  198. }
  199. })
  200. }
  201. private func publishPost() {
  202. DispatchQueue.main.async {
  203. LocalStorageManager.standard.saveContext()
  204. model.publish(post: post)
  205. }
  206. model.editor.setInitialValues(for: post)
  207. self.hideKeyboard()
  208. }
  209. private func sharePost() {
  210. // If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early.
  211. guard let postId = post.postId else { return }
  212. var urlString: String
  213. if let postSlug = post.slug,
  214. let postCollectionAlias = post.collectionAlias {
  215. // This post is in a collection, so share the URL as baseURL/postSlug.
  216. let urls = collections.filter { $0.alias == postCollectionAlias }
  217. let baseURL = urls.first?.url ?? "\(model.account.server)/\(postCollectionAlias)/"
  218. urlString = "\(baseURL)\(postSlug)"
  219. } else {
  220. // This is a draft post, so share the URL as server/postID
  221. urlString = "\(model.account.server)/\(postId)"
  222. }
  223. guard let data = URL(string: urlString) else { return }
  224. let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil)
  225. UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil)
  226. if UIDevice.current.userInterfaceIdiom == .pad {
  227. activityView.popoverPresentationController?.permittedArrowDirections = .up
  228. activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first
  229. activityView.popoverPresentationController?.sourceRect = CGRect(
  230. x: UIScreen.main.bounds.width,
  231. y: -125,
  232. width: 200,
  233. height: 200
  234. )
  235. }
  236. }
  237. }
  238. struct PostEditorView_EmptyPostPreviews: PreviewProvider {
  239. static var previews: some View {
  240. let context = LocalStorageManager.standard.container.viewContext
  241. let testPost = WFAPost(context: context)
  242. testPost.createdDate = Date()
  243. testPost.appearance = "norm"
  244. let model = WriteFreelyModel()
  245. return PostEditorView(post: testPost)
  246. .environment(\.managedObjectContext, context)
  247. .environmentObject(model)
  248. }
  249. }
  250. struct PostEditorView_ExistingPostPreviews: PreviewProvider {
  251. static var previews: some View {
  252. let context = LocalStorageManager.standard.container.viewContext
  253. let testPost = WFAPost(context: context)
  254. testPost.title = "Test Post Title"
  255. testPost.body = "Here's some cool sample body text."
  256. testPost.createdDate = Date()
  257. testPost.appearance = "code"
  258. testPost.hasNewerRemoteCopy = true
  259. let model = WriteFreelyModel()
  260. return PostEditorView(post: testPost)
  261. .environment(\.managedObjectContext, context)
  262. .environmentObject(model)
  263. }
  264. }