Source code for the WriteFreely SwiftUI app for iOS, iPadOS, and macOS
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 

276 wiersze
11 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 {
  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. }
  94. Button(action: {
  95. sharePost()
  96. }, label: {
  97. Label("Share", systemImage: "square.and.arrow.up")
  98. })
  99. .accessibilityHint(Text("Open the system share sheet to share a link to this post"))
  100. .disabled(post.postId == nil)
  101. if model.account.isLoggedIn && post.status != PostStatus.local.rawValue {
  102. Section(header: Text("Move To Collection")) {
  103. Label("Move to:", systemImage: "arrowshape.zigzag.right")
  104. Picker(selection: $selectedCollection, label: Text("Move to…")) {
  105. Text(
  106. " \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")"
  107. ).tag(nil as WFACollection?)
  108. ForEach(collections) { collection in
  109. Text(" \(collection.title)").tag(collection as WFACollection?)
  110. }
  111. }
  112. }
  113. }
  114. }, label: {
  115. ZStack {
  116. Image("does.not.exist")
  117. .accessibilityHidden(true)
  118. Image(systemName: "ellipsis.circle")
  119. .imageScale(.large)
  120. .accessibilityHidden(true)
  121. }
  122. })
  123. .accessibilityLabel(Text("Menu"))
  124. .accessibilityHint(Text("Opens a context menu to publish, share, or move the post"))
  125. .onTapGesture {
  126. hideKeyboard()
  127. }
  128. .disabled(post.body.count == 0)
  129. }
  130. }
  131. }
  132. .onChange(of: post.hasNewerRemoteCopy, perform: { _ in
  133. if !post.hasNewerRemoteCopy {
  134. updatingTitleFromServer = true
  135. updatingBodyFromServer = true
  136. }
  137. })
  138. .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in
  139. if post.collectionAlias == newCollection?.alias {
  140. return
  141. } else {
  142. post.collectionAlias = newCollection?.alias
  143. model.move(post: post, from: selectedCollection, to: newCollection)
  144. }
  145. })
  146. .onChange(of: post.status, perform: { value in
  147. if value != PostStatus.published.rawValue {
  148. self.model.editor.saveLastDraft(post)
  149. } else {
  150. self.model.editor.clearLastDraft()
  151. }
  152. DispatchQueue.main.async {
  153. LocalStorageManager.standard.saveContext()
  154. }
  155. })
  156. .onAppear(perform: {
  157. self.selectedCollection = collections.first { $0.alias == post.collectionAlias }
  158. if post.status != PostStatus.published.rawValue {
  159. DispatchQueue.main.async {
  160. self.model.editor.saveLastDraft(post)
  161. }
  162. } else {
  163. self.model.editor.clearLastDraft()
  164. }
  165. })
  166. .onChange(of: model.hasError) { value in
  167. if value {
  168. if let error = model.currentError {
  169. self.errorHandling.handle(error: error)
  170. } else {
  171. self.errorHandling.handle(error: AppError.genericError())
  172. }
  173. model.hasError = false
  174. }
  175. }
  176. .onDisappear(perform: {
  177. self.model.editor.clearLastDraft()
  178. if post.title.count == 0
  179. && post.body.count == 0
  180. && post.status == PostStatus.local.rawValue
  181. && post.updatedDate == nil
  182. && post.postId == nil {
  183. DispatchQueue.main.async {
  184. model.posts.remove(post)
  185. }
  186. } else if post.status != PostStatus.published.rawValue {
  187. DispatchQueue.main.async {
  188. LocalStorageManager.standard.saveContext()
  189. }
  190. }
  191. })
  192. }
  193. private func publishPost() {
  194. DispatchQueue.main.async {
  195. LocalStorageManager.standard.saveContext()
  196. model.publish(post: post)
  197. }
  198. #if os(iOS)
  199. self.hideKeyboard()
  200. #endif
  201. }
  202. private func sharePost() {
  203. // If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early.
  204. guard let postId = post.postId else { return }
  205. var urlString: String
  206. if let postSlug = post.slug,
  207. let postCollectionAlias = post.collectionAlias {
  208. // This post is in a collection, so share the URL as baseURL/postSlug.
  209. let urls = collections.filter { $0.alias == postCollectionAlias }
  210. let baseURL = urls.first?.url ?? "\(model.account.server)/\(postCollectionAlias)/"
  211. urlString = "\(baseURL)\(postSlug)"
  212. } else {
  213. // This is a draft post, so share the URL as server/postID
  214. urlString = "\(model.account.server)/\(postId)"
  215. }
  216. guard let data = URL(string: urlString) else { return }
  217. let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil)
  218. UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil)
  219. if UIDevice.current.userInterfaceIdiom == .pad {
  220. activityView.popoverPresentationController?.permittedArrowDirections = .up
  221. activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first
  222. activityView.popoverPresentationController?.sourceRect = CGRect(
  223. x: UIScreen.main.bounds.width,
  224. y: -125,
  225. width: 200,
  226. height: 200
  227. )
  228. }
  229. }
  230. }
  231. struct PostEditorView_EmptyPostPreviews: PreviewProvider {
  232. static var previews: some View {
  233. let context = LocalStorageManager.standard.container.viewContext
  234. let testPost = WFAPost(context: context)
  235. testPost.createdDate = Date()
  236. testPost.appearance = "norm"
  237. let model = WriteFreelyModel()
  238. return PostEditorView(post: testPost)
  239. .environment(\.managedObjectContext, context)
  240. .environmentObject(model)
  241. }
  242. }
  243. struct PostEditorView_ExistingPostPreviews: PreviewProvider {
  244. static var previews: some View {
  245. let context = LocalStorageManager.standard.container.viewContext
  246. let testPost = WFAPost(context: context)
  247. testPost.title = "Test Post Title"
  248. testPost.body = "Here's some cool sample body text."
  249. testPost.createdDate = Date()
  250. testPost.appearance = "code"
  251. testPost.hasNewerRemoteCopy = true
  252. let model = WriteFreelyModel()
  253. return PostEditorView(post: testPost)
  254. .environment(\.managedObjectContext, context)
  255. .environmentObject(model)
  256. }
  257. }