diff --git a/Shared/Assets.xcassets/does.not.exist.imageset/Contents.json b/Shared/Assets.xcassets/does.not.exist.imageset/Contents.json new file mode 100644 index 0000000..f89ec02 --- /dev/null +++ b/Shared/Assets.xcassets/does.not.exist.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "does.not.exist.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "does.not.exist@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "does.not.exist@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist.png b/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist.png new file mode 100644 index 0000000..6238655 Binary files /dev/null and b/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist.png differ diff --git a/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@2x.png b/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@2x.png new file mode 100644 index 0000000..170275e Binary files /dev/null and b/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@2x.png differ diff --git a/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@3x.png b/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@3x.png new file mode 100644 index 0000000..364bf5d Binary files /dev/null and b/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@3x.png differ diff --git a/Shared/PostEditor/PostEditorStatusToolbarView.swift b/Shared/PostEditor/PostEditorStatusToolbarView.swift index 7d8cac4..cc02858 100644 --- a/Shared/PostEditor/PostEditorStatusToolbarView.swift +++ b/Shared/PostEditor/PostEditorStatusToolbarView.swift @@ -1,98 +1,102 @@ import SwiftUI struct PostEditorStatusToolbarView: View { @EnvironmentObject var model: WriteFreelyModel @ObservedObject var post: WFAPost var body: some View { if post.hasNewerRemoteCopy { #if os(iOS) PostStatusBadgeView(post: post) #else HStack { HStack { 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") }) + .accessibilityLabel(Text("Update post")) + .accessibilityHint(Text("Replace this post with the server version")) } .padding(.horizontal) .background(Color.primary.opacity(0.1)) .clipShape(Capsule()) .padding(.trailing) PostStatusBadgeView(post: post) } #endif } else if post.wasDeletedFromServer && post.status != PostStatus.local.rawValue { #if os(iOS) PostStatusBadgeView(post: post) #else HStack { HStack { Text("⚠️ Post deleted from server. Delete local copy?") .font(.callout) .foregroundColor(.secondary) Button(action: { model.selectedPost = nil DispatchQueue.main.async { model.posts.remove(post) } }, label: { Image(systemName: "trash") }) + .accessibilityLabel(Text("Delete")) + .accessibilityHint(Text("Delete this post from your Mac")) } .padding(.horizontal) .background(Color.primary.opacity(0.1)) .clipShape(Capsule()) .padding(.trailing) PostStatusBadgeView(post: post) } #endif } else { PostStatusBadgeView(post: post) } } } struct PESTView_StandardPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() let testPost = WFAPost(context: context) testPost.status = PostStatus.published.rawValue return PostEditorStatusToolbarView(post: testPost) .environmentObject(model) } } struct PESTView_OutdatedLocalCopyPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() let updatedPost = WFAPost(context: context) updatedPost.status = PostStatus.published.rawValue updatedPost.hasNewerRemoteCopy = true return PostEditorStatusToolbarView(post: updatedPost) .environmentObject(model) } } struct PESTView_DeletedRemoteCopyPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() let deletedPost = WFAPost(context: context) deletedPost.status = PostStatus.published.rawValue deletedPost.wasDeletedFromServer = true return PostEditorStatusToolbarView(post: deletedPost) .environmentObject(model) } } diff --git a/Shared/PostList/PostListView.swift b/Shared/PostList/PostListView.swift index e20651a..6f7d4fa 100644 --- a/Shared/PostList/PostListView.swift +++ b/Shared/PostList/PostListView.swift @@ -1,131 +1,160 @@ import SwiftUI import Combine struct PostListView: View { @EnvironmentObject var model: WriteFreelyModel @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(collection: selectedCollection, showAllPosts: showAllPosts, postCount: $postCount) .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) .toolbar { ToolbarItem(placement: .primaryAction) { - Button(action: { - let managedPost = WFAPost(context: self.managedObjectContext) - managedPost.createdDate = Date() - managedPost.title = "" - managedPost.body = "" - managedPost.status = PostStatus.local.rawValue - managedPost.collectionAlias = nil - switch 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 - } - withAnimation { - self.selectedCollection = nil - self.showAllPosts = false - self.model.selectedPost = managedPost - } - }, label: { - Image(systemName: "square.and.pencil") - }) + // We have to add a Spacer as a sibling view to the Button in some kind of Stack, so that any a11y + // modifiers are applied as expected: bug report filed as FB8956392. + ZStack { + Spacer() + Button(action: { + let managedPost = WFAPost(context: self.managedObjectContext) + managedPost.createdDate = Date() + managedPost.title = "" + managedPost.body = "" + managedPost.status = PostStatus.local.rawValue + managedPost.collectionAlias = nil + switch 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 + } + withAnimation { + self.selectedCollection = nil + self.showAllPosts = false + self.model.selectedPost = managedPost + } + }, label: { + ZStack { + Image("does.not.exist") + .accessibilityHidden(true) + Image(systemName: "square.and.pencil") + .accessibilityHidden(true) + .imageScale(.large) // These modifiers compensate for the resizing + .padding(.vertical, 12) // done to the Image (and the button tap target) + .padding(.leading, 12) // by the SwiftUI layout system from adding a + .padding(.trailing, 8) // Spacer in this ZStack (FB8956392). + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + }) + .accessibilityLabel(Text("Compose")) + .accessibilityHint(Text("Compose a new local draft")) + } } ToolbarItem(placement: .bottomBar) { HStack { Button(action: { model.isPresentingSettingsView = true }, label: { Image(systemName: "gear") + .imageScale(.large) + .padding(.vertical, 12) + .padding(.leading, 8) + .padding(.trailing, 12) }) + .accessibilityLabel(Text("Settings")) + .accessibilityHint(Text("Open the Settings sheet")) Spacer() Text(postCount == 1 ? "\(postCount) post" : "\(postCount) posts") .foregroundColor(.secondary) Spacer() if model.isProcessingRequest { ProgressView() } else { Button(action: { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } }, label: { Image(systemName: "arrow.clockwise") + .imageScale(.large) + .padding(.vertical, 12) + .padding(.leading, 12) + .padding(.trailing, 8) }) + .accessibilityLabel(Text("Refresh Posts")) + .accessibilityHint(Text("Fetch changes from the server")) .disabled(!model.account.isLoggedIn) } } .padding() .frame(width: geometry.size.width) } } } #else //if os(macOS) PostListFilteredView( collection: selectedCollection, showAllPosts: showAllPosts, postCount: $postCount ) .toolbar { ToolbarItemGroup(placement: .primaryAction) { if let selectedPost = model.selectedPost { ActivePostToolbarView(activePost: selectedPost) .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 }) ) }) } } } .onDisappear { DispatchQueue.main.async { self.model.selectedCollection = nil self.model.showAllPosts = true self.model.selectedPost = nil } } .navigationTitle( showAllPosts ? "All Posts" : selectedCollection?.title ?? ( model.account.server == "https://write.as" ? "Anonymous" : "Drafts" ) ) #endif } } struct PostListView_Previews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let model = WriteFreelyModel() return PostListView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/iOS/PostEditor/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift index e5dfbd7..d866cc7 100644 --- a/iOS/PostEditor/PostEditorView.swift +++ b/iOS/PostEditor/PostEditorView.swift @@ -1,251 +1,266 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.managedObjectContext) var moc @Environment(\.presentationMode) var presentationMode @ObservedObject var post: WFAPost @State private var updatingTitleFromServer: Bool = false @State private var updatingBodyFromServer: Bool = false @State private var selectedCollection: WFACollection? @FetchRequest( entity: WFACollection.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] ) var collections: FetchedResults var body: some View { VStack { if post.hasNewerRemoteCopy { RemoteChangePromptView( remoteChangeType: .remoteCopyUpdated, buttonHandler: { model.updateFromServer(post: post) } ) } else if post.wasDeletedFromServer { RemoteChangePromptView( remoteChangeType: .remoteCopyDeleted, buttonHandler: { self.presentationMode.wrappedValue.dismiss() DispatchQueue.main.async { model.posts.remove(post) } } ) } PostTextEditingView( post: _post, updatingTitleFromServer: $updatingTitleFromServer, updatingBodyFromServer: $updatingBodyFromServer ) } .navigationBarTitleDisplayMode(.inline) .padding() .toolbar { ToolbarItem(placement: .principal) { PostEditorStatusToolbarView(post: post) } ToolbarItem(placement: .primaryAction) { if model.isProcessingRequest { ProgressView() } else { Menu(content: { if post.status == PostStatus.local.rawValue { Menu(content: { Label("Publish to…", systemImage: "paperplane") Button(action: { if model.account.isLoggedIn { post.collectionAlias = nil publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")") }) ForEach(collections) { collection in Button(action: { if model.account.isLoggedIn { post.collectionAlias = collection.alias publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(collection.title)") }) } }, label: { Label("Publish…", systemImage: "paperplane") }) + .accessibilityHint(Text("Choose the blog you want to publish this post to")) + .disabled(post.body.count == 0) } else { Button(action: { if model.account.isLoggedIn { publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Label("Publish", systemImage: "paperplane") }) .disabled(post.status == PostStatus.published.rawValue || post.body.count == 0) } Button(action: { sharePost() }, label: { Label("Share", systemImage: "square.and.arrow.up") }) + .accessibilityHint(Text("Open the system share sheet to share a link to this post")) .disabled(post.postId == nil) // Button(action: { // print("Tapped 'Delete...' button") // }, label: { // Label("Delete…", systemImage: "trash") // }) if model.account.isLoggedIn && post.status != PostStatus.local.rawValue { Section(header: Text("Move To Collection")) { Label("Move to:", systemImage: "arrowshape.zigzag.right") Picker(selection: $selectedCollection, label: Text("Move to…")) { Text( " \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")" ).tag(nil as WFACollection?) ForEach(collections) { collection in Text(" \(collection.title)").tag(collection as WFACollection?) } } } } }, label: { - Image(systemName: "ellipsis.circle") + ZStack { + Image("does.not.exist") + .accessibilityHidden(true) + Image(systemName: "ellipsis.circle") + .imageScale(.large) + .accessibilityHidden(true) + } }) + .accessibilityLabel(Text("Menu")) + .accessibilityHint(Text("Opens a context menu to publish, share, or move the post")) + .onTapGesture { + hideKeyboard() + } + .disabled(post.body.count == 0) } } } .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if !post.hasNewerRemoteCopy { updatingTitleFromServer = true updatingBodyFromServer = true } }) .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in if post.collectionAlias == newCollection?.alias { return } else { post.collectionAlias = newCollection?.alias model.move(post: post, from: selectedCollection, to: newCollection) } }) .onChange(of: post.status, perform: { value in if value != PostStatus.published.rawValue { self.model.editor.saveLastDraft(post) } else { self.model.editor.clearLastDraft() } DispatchQueue.main.async { LocalStorageManager().saveContext() } }) .onAppear(perform: { self.selectedCollection = collections.first { $0.alias == post.collectionAlias } if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { self.model.editor.saveLastDraft(post) } } else { self.model.editor.clearLastDraft() } }) .onDisappear(perform: { self.model.editor.clearLastDraft() if post.title.count == 0 && post.body.count == 0 && post.status == PostStatus.local.rawValue && post.updatedDate == nil && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { LocalStorageManager().saveContext() } } }) } private func publishPost() { DispatchQueue.main.async { LocalStorageManager().saveContext() model.publish(post: post) } #if os(iOS) self.hideKeyboard() #endif } private func sharePost() { // If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early. guard let postId = post.postId else { return } var urlString: String if let postSlug = post.slug, let postCollectionAlias = post.collectionAlias { // This post is in a collection, so share the URL as server/collectionAlias/postSlug. urlString = "\(model.account.server)/\((postCollectionAlias))/\((postSlug))" } else { // This is a draft post, so share the URL as server/postID urlString = "\(model.account.server)/\((postId))" } guard let data = URL(string: urlString) else { return } let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil) UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil) if UIDevice.current.userInterfaceIdiom == .pad { activityView.popoverPresentationController?.permittedArrowDirections = .up activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first activityView.popoverPresentationController?.sourceRect = CGRect( x: UIScreen.main.bounds.width, y: -125, width: 200, height: 200 ) } } } struct PostEditorView_EmptyPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let testPost = WFAPost(context: context) testPost.createdDate = Date() testPost.appearance = "norm" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } struct PostEditorView_ExistingPostPreviews: PreviewProvider { static var previews: some View { let context = LocalStorageManager.persistentContainer.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() testPost.appearance = "code" testPost.hasNewerRemoteCopy = true let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/iOS/PostEditor/PostTextEditingView.swift b/iOS/PostEditor/PostTextEditingView.swift index a58fbae..51f5ed2 100644 --- a/iOS/PostEditor/PostTextEditingView.swift +++ b/iOS/PostEditor/PostTextEditingView.swift @@ -1,102 +1,108 @@ import SwiftUI struct PostTextEditingView: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass @ObservedObject var post: WFAPost @Binding var updatingTitleFromServer: Bool @Binding var updatingBodyFromServer: Bool @State private var appearance: PostAppearance = .serif @State private var titleTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 26)! @State private var titleTextHeight: CGFloat = 50 @State private var titleIsFirstResponder: Bool = true @State private var bodyTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 17)! @State private var bodyIsFirstResponder: Bool = false private let lineSpacingMultiplier: CGFloat = 0.5 init( post: ObservedObject, updatingTitleFromServer: Binding, updatingBodyFromServer: Binding ) { self._post = post self._updatingTitleFromServer = updatingTitleFromServer self._updatingBodyFromServer = updatingBodyFromServer UITextView.appearance().backgroundColor = .clear } var titleFieldHeight: CGFloat { let minHeight: CGFloat = 50 if titleTextHeight < minHeight { return minHeight } return titleTextHeight } var body: some View { VStack { ZStack(alignment: .topLeading) { if post.title.count == 0 { Text("Title (optional)") .font(Font(titleTextStyle)) .foregroundColor(Color(UIColor.placeholderText)) .padding(.horizontal, 4) .padding(.vertical, 8) + .accessibilityHidden(true) } PostTitleTextView( text: $post.title, textStyle: $titleTextStyle, height: $titleTextHeight, isFirstResponder: $titleIsFirstResponder, lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier ) + .accessibilityLabel(Text("Title (optional)")) + .accessibilityHint(Text("Add or edit the title for your post; use the Return key to skip to the body")) .frame(height: titleFieldHeight) .onChange(of: post.title) { _ in if post.status == PostStatus.published.rawValue && !updatingTitleFromServer { post.status = PostStatus.edited.rawValue } if updatingTitleFromServer { updatingTitleFromServer = false } } } ZStack(alignment: .topLeading) { if post.body.count == 0 { Text("Write…") .font(Font(bodyTextStyle)) .foregroundColor(Color(UIColor.placeholderText)) .padding(.horizontal, 4) .padding(.vertical, 8) + .accessibilityHidden(true) } PostBodyTextView( text: $post.body, textStyle: $bodyTextStyle, isFirstResponder: $bodyIsFirstResponder, lineSpacing: horizontalSizeClass == .compact ? lineSpacingMultiplier / 2 : lineSpacingMultiplier ) + .accessibilityLabel(Text("Body")) + .accessibilityHint(Text("Add or edit the body of your post")) .onChange(of: post.body) { _ in if post.status == PostStatus.published.rawValue && !updatingBodyFromServer { post.status = PostStatus.edited.rawValue } if updatingBodyFromServer { updatingBodyFromServer = false } } } } .onChange(of: titleIsFirstResponder, perform: { _ in self.bodyIsFirstResponder.toggle() }) .onAppear(perform: { switch post.appearance { case "sans": self.appearance = .sans case "wrap", "mono", "code": self.appearance = .mono default: self.appearance = .serif } self.titleTextStyle = UIFont(name: appearance.rawValue, size: 26)! self.bodyTextStyle = UIFont(name: appearance.rawValue, size: 17)! }) } } diff --git a/iOS/PostEditor/RemoteChangePromptView.swift b/iOS/PostEditor/RemoteChangePromptView.swift index 0807155..184d6b3 100644 --- a/iOS/PostEditor/RemoteChangePromptView.swift +++ b/iOS/PostEditor/RemoteChangePromptView.swift @@ -1,55 +1,63 @@ import SwiftUI enum RemotePostChangeType { case remoteCopyUpdated case remoteCopyDeleted } struct RemoteChangePromptView: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass @State private var promptText: String = "This is placeholder prompt text. Replace it?" @State private var promptIcon: Image = Image(systemName: "questionmark.square.dashed") + @State private var accessibilityLabel: String = "Replace" + @State private var accessibilityHint: String = "Replace this text with an accessibility hint" @State var remoteChangeType: RemotePostChangeType @State var buttonHandler: () -> Void var body: some View { HStack { Text("⚠️ \(promptText)") .font(horizontalSizeClass == .compact ? .caption : .body) .foregroundColor(.secondary) Button(action: buttonHandler, label: { promptIcon }) + .accessibilityLabel(Text(accessibilityLabel)) + .accessibilityHint(Text(accessibilityHint)) } .padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)) .background(Color(UIColor.secondarySystemBackground)) .clipShape(Capsule()) .padding(.bottom) .onAppear(perform: { switch remoteChangeType { case .remoteCopyUpdated: promptText = "Newer copy on server. Replace local copy?" promptIcon = Image(systemName: "square.and.arrow.down") + accessibilityLabel = "Update post" + accessibilityHint = "Replace this post with the server version" case .remoteCopyDeleted: promptText = "Post deleted from server. Delete local copy?" promptIcon = Image(systemName: "trash") + accessibilityLabel = "Delete" + accessibilityHint = "Delete this post from your device" } }) } } struct RemoteChangePromptView_UpdatedPreviews: PreviewProvider { static var previews: some View { RemoteChangePromptView( remoteChangeType: .remoteCopyUpdated, buttonHandler: { print("Hello, updated post!") } ) } } struct RemoteChangePromptView_DeletedPreviews: PreviewProvider { static var previews: some View { RemoteChangePromptView( remoteChangeType: .remoteCopyDeleted, buttonHandler: { print("Goodbye, deleted post!") } ) } } diff --git a/iOS/Settings/SettingsHeaderView.swift b/iOS/Settings/SettingsHeaderView.swift index ca65578..090040b 100644 --- a/iOS/Settings/SettingsHeaderView.swift +++ b/iOS/Settings/SettingsHeaderView.swift @@ -1,32 +1,34 @@ import SwiftUI struct SettingsHeaderView: View { @Environment(\.presentationMode) var presentationMode var body: some View { VStack { HStack { Text("Settings") .font(.largeTitle) .fontWeight(.bold) Spacer() Button(action: { presentationMode.wrappedValue.dismiss() }, label: { Image(systemName: "xmark.circle") }) + .accessibilityLabel(Text("Close")) + .accessibilityHint(Text("Dismiss the Settings sheet")) } Text("WriteFreely v\(Bundle.main.appMarketingVersion) (build \(Bundle.main.appBuildVersion))") .font(.caption) .foregroundColor(.secondary) .padding(.top) } .padding() } } struct SettingsHeaderView_Previews: PreviewProvider { static var previews: some View { SettingsHeaderView() } }