diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt index 4d10b46e74e2..8f9c08e31777 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt @@ -2755,8 +2755,15 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor mediaId, postRepository ) { _: PostImmutableModel? -> } - } else if (editPostSettingsFragment != null) { - editPostSettingsFragment?.updateFeaturedImage(mediaId, imagePicked) + } else { + // The post settings screen may not be showing (e.g. the image was set from the + // pre-publish sheet), so fall back to writing the id ourselves. Either way the editor is + // synced below via sendToJSFeaturedImageId. + if (editPostSettingsFragment != null) { + editPostSettingsFragment?.updateFeaturedImage(mediaId, imagePicked) + } else { + updateFeaturedImageUseCase.updateFeaturedImage(mediaId, editPostRepository) { } + } } if (editorFragment is GutenbergEditorFragment) { (editorFragment as GutenbergEditorFragment).sendToJSFeaturedImageId(mediaId.toInt()) @@ -3882,6 +3889,14 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor // EditorDataProvider methods override fun getEditPostRepository() = editPostRepository override fun getSite() = siteModel + override fun syncFeaturedImageIdToEditor() { + val featuredImageId = editPostRepository.featuredImageId + editorFragment?.setFeaturedImageId(featuredImageId) + // setFeaturedImageId only stores the id; push it to the JS editor so the featured image + // block isn't left showing a stale image after a set/remove from the pre-publish sheet. + (editorFragment as? GutenbergEditorFragment)?.sendToJSFeaturedImageId(featuredImageId.toInt()) + } + override fun supportsFeaturedImageEditing() = true override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { // This is a workaround for bag discovered on Chromebooks, where Enter key will not work in the toolbar menu diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostSettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostSettingsFragment.java index 777a82ebe5d3..a69e99b6e7c5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostSettingsFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostSettingsFragment.java @@ -175,6 +175,19 @@ public interface EditorDataProvider { EditPostRepository getEditPostRepository(); SiteModel getSite(); + + /** + * Pushes the post's current featured image id to the editor. Used by the pre-publish sheet + * after it changes the featured image, so the editor doesn't keep showing a stale image. + */ + void syncFeaturedImageIdToEditor(); + + /** + * Whether this host can fully edit a featured image (pick, upload, sync to an editor). + * Hosts without an editor (e.g. the posts list quick-publish flow) return false so the + * pre-publish sheet doesn't offer a featured-image option it can't complete. + */ + boolean supportsFeaturedImageEditing(); } public static EditPostSettingsFragment newInstance() { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index 66ce7535fbcc..34a98677b19a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -2471,8 +2471,19 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene mediaId, postRepository ) { _: PostImmutableModel? -> } - } else if (editPostSettingsFragment != null) { - editPostSettingsFragment?.updateFeaturedImage(mediaId, imagePicked) + } else { + // The post settings screen may not be showing (e.g. the image was set from the + // pre-publish sheet), so fall back to writing the id ourselves. + if (editPostSettingsFragment != null) { + editPostSettingsFragment?.updateFeaturedImage(mediaId, imagePicked) + } else { + updateFeaturedImageUseCase.updateFeaturedImage(mediaId, editPostRepository) { } + } + // Record the id on the fragment (kept across config changes). GutenbergKit's + // GutenbergView exposes no app->webview featured-image setter, so the already-loaded + // editor can only pick this up on its next load; the PostModel write above is the + // source of truth for saving. + editorFragment?.setFeaturedImageId(mediaId) } } @@ -3129,6 +3140,13 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene // EditorDataProvider methods override fun getEditPostRepository() = editPostRepository override fun getSite() = siteModel + override fun syncFeaturedImageIdToEditor() { + // GutenbergKit's GutenbergView has no app->webview featured-image setter, so this only + // records the id on the fragment; the loaded editor reflects it on its next load. The + // PostModel write done by the caller is what actually persists the change. + editorFragment?.setFeaturedImageId(editPostRepository.featuredImageId) + } + override fun supportsFeaturedImageEditing() = true override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { // This is a workaround for bag discovered on Chromebooks, where Enter key will not work in the toolbar menu diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt index f7fad74cf6f2..f054a95f10c1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt @@ -128,6 +128,11 @@ class PostsListActivity : BaseAppCompatActivity(), override fun getSite() = site override fun getEditPostRepository() = editPostRepository + override fun syncFeaturedImageIdToEditor() { + // No editor is hosted here, so there is nothing to keep in sync. + } + // The quick-publish flow has no editor, so it can't fully support featured-image editing. + override fun supportsFeaturedImageEditing() = false private lateinit var viewModel: PostListMainViewModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeAdapter.kt index 4640213c87e6..e78cb8ce5aa2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeAdapter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeAdapter.kt @@ -6,21 +6,22 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.wordpress.android.WordPress import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ButtonUiState +import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.FeaturedImageUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.HeaderUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.HomeUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.SocialUiState +import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeViewHolder.PrepublishingFeaturedImageViewHolder import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeViewHolder.PrepublishingHeaderListItemViewHolder import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeViewHolder.PrepublishingHomeListItemViewHolder import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeViewHolder.PrepublishingSocialItemViewHolder -import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeViewHolder.PrepublishingSubmitButtonViewHolder import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.util.image.ImageManager import javax.inject.Inject private const val VIEW_TYPE_HEADER_VIEW: Int = 1 private const val VIEW_TYPE_HOME_ITEM: Int = 2 -private const val VIEW_TYPE_SUBMIT_BUTTON: Int = 3 private const val VIEW_TYPE_SOCIAL_ITEM: Int = 4 +private const val VIEW_TYPE_FEATURED_IMAGE_ITEM: Int = 5 class PrepublishingHomeAdapter(context: Context) : RecyclerView.Adapter() { private var items: List = listOf() @@ -43,8 +44,8 @@ class PrepublishingHomeAdapter(context: Context) : RecyclerView.Adapter PrepublishingHomeListItemViewHolder(parent, uiHelpers) - VIEW_TYPE_SUBMIT_BUTTON -> PrepublishingSubmitButtonViewHolder(parent, uiHelpers) VIEW_TYPE_SOCIAL_ITEM -> PrepublishingSocialItemViewHolder(parent, uiHelpers) + VIEW_TYPE_FEATURED_IMAGE_ITEM -> PrepublishingFeaturedImageViewHolder(parent, uiHelpers, imageManager) else -> throw NotImplementedError("Unknown ViewType") } } @@ -63,13 +64,14 @@ class PrepublishingHomeAdapter(context: Context) : RecyclerView.Adapter VIEW_TYPE_HEADER_VIEW is HomeUiState -> VIEW_TYPE_HOME_ITEM - is ButtonUiState -> VIEW_TYPE_SUBMIT_BUTTON + // The publish/submit button is rendered as a pinned footer, not a list item. + is ButtonUiState -> error("ButtonUiState is not rendered in the list") is SocialUiState -> VIEW_TYPE_SOCIAL_ITEM + is FeaturedImageUiState -> VIEW_TYPE_FEATURED_IMAGE_ITEM } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeDiffCallback.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeDiffCallback.kt index 50a4326b83c6..8ca67659f2c4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeDiffCallback.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeDiffCallback.kt @@ -1,6 +1,7 @@ package org.wordpress.android.ui.posts.prepublishing.home import androidx.recyclerview.widget.DiffUtil +import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.FeaturedImageUiState class PrepublishingHomeDiffCallback( private val oldList: List, @@ -10,6 +11,11 @@ class PrepublishingHomeDiffCallback( val newItem = newList[newItemPosition] val oldItem = oldList[oldItemPosition] + // The featured-image card refreshes often (upload progress, post changes) with fresh + // callback instances, so treat it as the same item to rebind in place rather than + // remove+insert, which reloads the image and flickers. + if (oldItem is FeaturedImageUiState && newItem is FeaturedImageUiState) return true + return (oldItem == newItem) } @@ -20,5 +26,16 @@ class PrepublishingHomeDiffCallback( override fun areContentsTheSame( oldItemPosition: Int, newItemPosition: Int - ): Boolean = oldList[oldItemPosition] == newList[newItemPosition] + ): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + + // Compare only the rendered content, ignoring the (always-changing) callback instances, so + // an unchanged featured-image state doesn't trigger an unnecessary rebind. + if (oldItem is FeaturedImageUiState.Visible && newItem is FeaturedImageUiState.Visible) { + return oldItem.featuredImageData == newItem.featuredImageData + } + + return oldItem == newItem + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeFragment.kt index ea89d63f7125..9e118a8b36f4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeFragment.kt @@ -7,10 +7,15 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode.MAIN import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.databinding.PostPrepublishingHomeFragmentBinding +import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.MediaStore.OnMediaUploaded +import org.wordpress.android.ui.photopicker.MediaPickerLauncher import org.wordpress.android.ui.posts.EditPostRepository import org.wordpress.android.ui.posts.EditPostSettingsFragment import org.wordpress.android.ui.posts.EditorJetpackSocialViewModel @@ -30,6 +35,12 @@ class PrepublishingHomeFragment : Fragment(R.layout.post_prepublishing_home_frag @Inject lateinit var imageManager: ImageManager + @Inject + lateinit var mediaPickerLauncher: MediaPickerLauncher + + @Inject + lateinit var dispatcher: Dispatcher + @Inject internal lateinit var viewModelFactory: ViewModelProvider.Factory private lateinit var viewModel: PrepublishingHomeViewModel @@ -42,6 +53,25 @@ class PrepublishingHomeFragment : Fragment(R.layout.post_prepublishing_home_frag (requireNotNull(activity).application as WordPress).component().inject(this) } + override fun onStart() { + super.onStart() + dispatcher.register(this) + } + + override fun onStop() { + dispatcher.unregister(this) + super.onStop() + } + + override fun onResume() { + super.onResume() + // The media picker returns its result to the host activity, so refresh here to reflect an + // image that was set or queued while the sheet was in the background. + if (::viewModel.isInitialized) { + viewModel.refreshFeaturedImage() + } + } + override fun onAttach(context: Context) { super.onAttach(context) actionClickedListener = parentFragment as PrepublishingActionClickedListener @@ -102,9 +132,33 @@ class PrepublishingHomeFragment : Fragment(R.layout.post_prepublishing_home_frag actionClickedListener?.onSubmitButtonClicked(publishPost) } - viewModel.start(getEditPostRepository(), getSite()) + viewModel.buttonUiState.observe(viewLifecycleOwner) { buttonUiState -> + publishButton.text = uiHelpers.getTextOfUiString(requireContext(), buttonUiState.buttonText) + publishButton.setOnClickListener { + buttonUiState.onButtonClicked?.invoke(buttonUiState.publishPost) + } + } + + viewModel.launchFeaturedImagePicker.observeEvent(viewLifecycleOwner) { postId -> + mediaPickerLauncher.showFeaturedImagePicker(requireActivity(), getSite(), postId) + } + + viewModel.syncFeaturedImageToEditor.observeEvent(viewLifecycleOwner) { + getEditorDataProvider()?.syncFeaturedImageIdToEditor() + } + + // updateAsync mutates the shared post and fires postChanged; observe it raw (not observeEvent) + // since it is a single-consumption Event shared with other observers. + getEditPostRepository().postChanged.observe(viewLifecycleOwner) { + viewModel.refreshFeaturedImage() + } + + viewModel.start(getEditPostRepository(), getSite(), isFeaturedImageEditingSupported()) } + private fun isFeaturedImageEditingSupported(): Boolean = + getEditorDataProvider()?.supportsFeaturedImageEditing() ?: false + private fun setupJetpackSocialViewModel() { jetpackSocialViewModel = (parentFragment as PrepublishingSocialViewModelProvider) .getEditorJetpackSocialViewModel() @@ -147,6 +201,18 @@ class PrepublishingHomeFragment : Fragment(R.layout.post_prepublishing_home_frag } } + @Suppress("unused") + @Subscribe(threadMode = MAIN) + fun onMediaUploaded(event: OnMediaUploaded) { + val media = event.media ?: return + if (!::viewModel.isInitialized) return + // Only react to featured-image uploads for the post this sheet is editing; a queued upload + // for a different post must not refresh this card (matches EditPostActivity's guard). + if (media.markedLocallyAsFeatured && media.localPostId == getEditPostRepository().id) { + viewModel.refreshFeaturedImage() + } + } + companion object { const val TAG = "prepublishing_home_fragment_tag" diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeItemUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeItemUiState.kt index 080079c47ef6..a55a92b734e5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeItemUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeItemUiState.kt @@ -4,6 +4,7 @@ import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import org.wordpress.android.R import org.wordpress.android.ui.posts.EditorJetpackSocialViewModel +import org.wordpress.android.ui.posts.FeaturedImageHelper.FeaturedImageData import org.wordpress.android.ui.posts.prepublishing.PrepublishingScreen import org.wordpress.android.ui.utils.UiString import org.wordpress.android.ui.utils.UiString.UiStringRes @@ -82,6 +83,19 @@ sealed class PrepublishingHomeItemUiState( ) } + sealed class FeaturedImageUiState( + isVisible: Boolean = true + ) : PrepublishingHomeItemUiState(isVisible) { + data class Visible( + val featuredImageData: FeaturedImageData, + val onSetOrReplaceClicked: () -> Unit, + val onRemoveClicked: () -> Unit, + val onRetryClicked: () -> Unit, + ) : FeaturedImageUiState() + + object Hidden : FeaturedImageUiState(isVisible = false) + } + sealed interface ActionType { sealed class PrepublishingScreenNavigation( val textRes: UiStringRes, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewHolder.kt index b50b0d02f325..f728824a2393 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewHolder.kt @@ -3,8 +3,9 @@ package org.wordpress.android.ui.posts.prepublishing.home import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button import android.widget.ImageView +import android.widget.ImageView.ScaleType +import android.widget.PopupMenu import android.widget.TextView import androidx.annotation.LayoutRes import androidx.compose.foundation.clickable @@ -22,7 +23,8 @@ import androidx.recyclerview.widget.RecyclerView import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.posts.EditorJetpackSocialViewModel.JetpackSocialUiState -import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ButtonUiState +import org.wordpress.android.ui.posts.FeaturedImageHelper.FeaturedImageState +import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.FeaturedImageUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.HeaderUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.HomeUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.SocialUiState @@ -90,23 +92,6 @@ sealed class PrepublishingHomeViewHolder( } } - class PrepublishingSubmitButtonViewHolder(parentView: ViewGroup, val uiHelpers: UiHelpers) : - PrepublishingHomeViewHolder( - parentView, - R.layout.prepublishing_home_publish_button_list_item - ) { - private val button: Button = itemView.findViewById(R.id.publish_button) - - override fun onBind(uiState: PrepublishingHomeItemUiState) { - uiState as ButtonUiState - - button.text = uiHelpers.getTextOfUiString(itemView.context, uiState.buttonText) - button.setOnClickListener { - uiState.onButtonClicked?.invoke(uiState.publishPost) - } - } - } - class PrepublishingSocialItemViewHolder( parentView: ViewGroup, val uiHelpers: UiHelpers, @@ -163,4 +148,93 @@ sealed class PrepublishingHomeViewHolder( } } } + + class PrepublishingFeaturedImageViewHolder( + parentView: ViewGroup, + val uiHelpers: UiHelpers, + val imageManager: ImageManager + ) : PrepublishingHomeViewHolder(parentView, R.layout.prepublishing_home_featured_image_item) { + private val card: View = itemView.findViewById(R.id.featured_image_card) + private val placeholder: View = itemView.findViewById(R.id.featured_image_placeholder) + private val remoteImage: ImageView = itemView.findViewById(R.id.post_featured_image) + private val localImage: ImageView = itemView.findViewById(R.id.post_featured_image_local) + private val retryOverlay: View = itemView.findViewById(R.id.featured_image_retry_overlay) + private val progressOverlay: View = itemView.findViewById(R.id.featured_image_progress_overlay) + + override fun onBind(uiState: PrepublishingHomeItemUiState) { + // Hidden items are filtered out before reaching the adapter, but guard rather than crash + // if one ever does. + val uiState = uiState as? FeaturedImageUiState.Visible ?: return + val state = uiState.featuredImageData.uiState + // A remote image (on the server) uses one ImageView, a local upload another, so an empty + // view can never cover a loaded image in the FrameLayout. + val isRemote = state == FeaturedImageState.REMOTE_IMAGE_LOADING || + state == FeaturedImageState.REMOTE_IMAGE_SET + val isLocalUpload = state == FeaturedImageState.IMAGE_UPLOAD_IN_PROGRESS || + state == FeaturedImageState.IMAGE_UPLOAD_FAILED + val isEmpty = state == FeaturedImageState.IMAGE_EMPTY + + val mediaUri = uiState.featuredImageData.mediaUri + val hasMediaUri = !mediaUri.isNullOrEmpty() + val showRemoteImage = isRemote && hasMediaUri + val showLocalImage = isLocalUpload && hasMediaUri + + with(uiHelpers) { + // Fall back to the placeholder when a remote/local image state has no URI to load, + // otherwise we'd show a blank ImageView with no indication the image is missing. + updateVisibility(placeholder, isEmpty || (!showRemoteImage && !showLocalImage)) + updateVisibility(remoteImage, showRemoteImage) + updateVisibility(localImage, showLocalImage) + updateVisibility(retryOverlay, state.retryOverlayVisible) + updateVisibility(progressOverlay, state.progressOverlayVisible) + } + + when { + showRemoteImage -> { + imageManager.cancelRequestAndClearImageView(localImage) + imageManager.load(remoteImage, ImageType.IMAGE, mediaUri.orEmpty(), ScaleType.CENTER_CROP) + } + showLocalImage -> + imageManager.load(localImage, ImageType.IMAGE, mediaUri.orEmpty(), ScaleType.CENTER_CROP) + else -> { + imageManager.cancelRequestAndClearImageView(remoteImage) + imageManager.cancelRequestAndClearImageView(localImage) + } + } + + card.setOnClickListener { anchor -> + when { + isEmpty -> uiState.onSetOrReplaceClicked() + state == FeaturedImageState.IMAGE_UPLOAD_FAILED -> showOptionsMenu(anchor, uiState, retry = true) + else -> showOptionsMenu(anchor, uiState, retry = false) + } + } + } + + private fun showOptionsMenu(anchor: View, uiState: FeaturedImageUiState.Visible, retry: Boolean) { + PopupMenu(anchor.context, anchor).apply { + if (retry) { + menu.add(0, MENU_RETRY, 0, R.string.post_settings_retry_featured_image) + } else { + menu.add(0, MENU_REPLACE, 0, R.string.post_settings_choose_featured_image) + } + menu.add(0, MENU_REMOVE, 1, R.string.post_settings_remove_featured_image) + setOnMenuItemClickListener { item -> + when (item.itemId) { + MENU_REPLACE -> uiState.onSetOrReplaceClicked() + MENU_RETRY -> uiState.onRetryClicked() + MENU_REMOVE -> uiState.onRemoveClicked() + } + true + } + show() + } + } + + companion object { + private const val MENU_REPLACE = 1 + private const val MENU_REMOVE = 2 + private const val MENU_RETRY = 3 + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewModel.kt index 87dbea59e99b..239e6e241e9d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/prepublishing/home/PrepublishingHomeViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.fluxc.model.SiteModel @@ -11,11 +12,16 @@ import org.wordpress.android.fluxc.model.post.PostStatus import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.ui.posts.EditPostRepository import org.wordpress.android.ui.posts.EditorJetpackSocialViewModel +import org.wordpress.android.ui.posts.FeaturedImageHelper +import org.wordpress.android.ui.posts.FeaturedImageHelper.TrackableEvent import org.wordpress.android.ui.posts.GetCategoriesUseCase import org.wordpress.android.ui.posts.GetPostTagsUseCase import org.wordpress.android.ui.posts.PostSettingsUtils +import org.wordpress.android.ui.posts.UpdateFeaturedImageUseCase import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ActionType import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ActionType.PrepublishingScreenNavigation +import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ButtonUiState +import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.FeaturedImageUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.HeaderUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.HomeUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.SocialUiState @@ -38,11 +44,16 @@ class PrepublishingHomeViewModel @Inject constructor( private val getButtonUiStateUseCase: GetButtonUiStateUseCase, private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, private val getCategoriesUseCase: GetCategoriesUseCase, + private val featuredImageHelper: FeaturedImageHelper, + private val updateFeaturedImageUseCase: UpdateFeaturedImageUseCase, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher ) : ScopedViewModel(bgDispatcher) { private var isStarted = false private var updateStoryTitleJob: Job? = null + private var refreshFeaturedImageJob: Job? = null private lateinit var editPostRepository: EditPostRepository + private lateinit var site: SiteModel + private var isFeaturedImageEditingSupported = false private val _onActionClicked = MutableLiveData>() val onActionClicked: LiveData> = _onActionClicked @@ -50,24 +61,44 @@ class PrepublishingHomeViewModel @Inject constructor( private val _onSubmitButtonClicked = MutableLiveData>() val onSubmitButtonClicked: LiveData> = _onSubmitButtonClicked + // The publish/submit button is pinned as a footer outside the scrolling list, so it's exposed + // separately rather than as a list item. + private val _buttonUiState = MutableLiveData() + val buttonUiState: LiveData = _buttonUiState + + private val _launchFeaturedImagePicker = MutableLiveData>() + val launchFeaturedImagePicker: LiveData> = _launchFeaturedImagePicker + + private val _syncFeaturedImageToEditor = MutableLiveData>() + val syncFeaturedImageToEditor: LiveData> = _syncFeaturedImageToEditor + private val _uiState = MutableLiveData>() private var _socialUiState: MutableLiveData = MutableLiveData(SocialUiState.Hidden) + private val _featuredImageUiState: MutableLiveData = + MutableLiveData(FeaturedImageUiState.Hidden) val uiState: LiveData> = merge( _uiState, - _socialUiState - ) { list, socialUiState -> + _socialUiState, + _featuredImageUiState + ) { list, socialUiState, featuredImageUiState -> list?.map { item -> - if (item is SocialUiState) { - socialUiState ?: SocialUiState.Hidden - } else { - item + when (item) { + is SocialUiState -> socialUiState ?: SocialUiState.Hidden + is FeaturedImageUiState -> featuredImageUiState ?: FeaturedImageUiState.Hidden + else -> item } } } - fun start(editPostRepository: EditPostRepository, site: SiteModel) { + fun start( + editPostRepository: EditPostRepository, + site: SiteModel, + isFeaturedImageEditingSupported: Boolean = true + ) { this.editPostRepository = editPostRepository + this.site = site + this.isFeaturedImageEditingSupported = isFeaturedImageEditingSupported if (isStarted) return // PrepublishingViewModel already dismisses the sheet when the host has no post, but that // dismissal is asynchronous and the FragmentManager can restore this fragment and create its @@ -85,6 +116,10 @@ class PrepublishingHomeViewModel @Inject constructor( editPostRepository: EditPostRepository, site: SiteModel ) { + // Built synchronously alongside the other rows so the card is present in the first frame instead + // of popping in (and reflowing the sheet) once a background refresh completes. Later updates go + // through refreshFeaturedImage, which runs off the main thread. + val featuredImageUiState = createFeaturedImageUiState() val prepublishingHomeUiStateList = mutableListOf().apply { add( HeaderUiState( @@ -123,16 +158,22 @@ class PrepublishingHomeViewModel @Inject constructor( )) } + // Replaced via merge with the live featured-image state; it stays Hidden for pages / + // unsupported hosts. + add(featuredImageUiState) + add(SocialUiState.Hidden) + }.toList() - add(getButtonUiStateUseCase.getUiState(editPostRepository, site) { publishPost -> + _featuredImageUiState.postValue(featuredImageUiState) + _uiState.postValue(prepublishingHomeUiStateList) + _buttonUiState.postValue( + getButtonUiStateUseCase.getUiState(editPostRepository, site) { publishPost -> launch(bgDispatcher) { waitForStoryTitleJobAndSubmit(publishPost) } - }) - }.toList() - - _uiState.postValue(prepublishingHomeUiStateList) + } + ) } private fun MutableList.showNotSetPost( @@ -212,6 +253,67 @@ class PrepublishingHomeViewModel @Inject constructor( updateStoryTitleJob?.cancel() } + /** + * Recomputes the featured-image card off the main thread (createCurrentFeaturedImageState does a + * media-store lookup). Called after any featured-image change and on upload events so the card + * reflects empty -> uploading -> set without leaving the sheet. The initial state is seeded + * synchronously by setupHomeUiState, so this only handles updates. + * + * onResume, the postChanged observer and onMediaUploaded can fire near-simultaneously. Runs + * single-flight: cancelling the previous launch keeps the latest state from being overwritten by a + * slower in-flight one. Skipping the relaunch while one is active would not be safe, since that job + * may already have read the pre-change state. + */ + fun refreshFeaturedImage() { + if (!isStarted) return + refreshFeaturedImageJob?.cancel() + refreshFeaturedImageJob = launch(bgDispatcher) { + val featuredImageUiState = createFeaturedImageUiState() + // Bail if a newer refresh superseded this one while the synchronous lookup ran, so a + // slower/stale result never overwrites the latest state. + ensureActive() + _featuredImageUiState.postValue(featuredImageUiState) + } + } + + private fun createFeaturedImageUiState(): FeaturedImageUiState { + val post = editPostRepository.getPost() + return if (editPostRepository.isPage || !isFeaturedImageEditingSupported || post == null) { + FeaturedImageUiState.Hidden + } else { + FeaturedImageUiState.Visible( + featuredImageData = featuredImageHelper.createCurrentFeaturedImageState(site, post), + onSetOrReplaceClicked = ::onFeaturedImageSetOrReplaceClicked, + onRemoveClicked = ::onFeaturedImageRemoveClicked, + onRetryClicked = ::onFeaturedImageRetryClicked + ) + } + } + + private fun onFeaturedImageSetOrReplaceClicked() { + val post = editPostRepository.getPost() ?: return + // Replacing mid-upload would otherwise leave two uploads marked as featured. + featuredImageHelper.cancelFeaturedImageUpload(site, post, false) + featuredImageHelper.trackFeaturedImageEvent(TrackableEvent.IMAGE_SET_CLICKED, post.id) + _launchFeaturedImagePicker.postValue(Event(post.id)) + } + + private fun onFeaturedImageRemoveClicked() { + val post = editPostRepository.getPost() ?: return + featuredImageHelper.cancelFeaturedImageUpload(site, post, false) + featuredImageHelper.trackFeaturedImageEvent(TrackableEvent.IMAGE_REMOVE_CLICKED, post.id) + updateFeaturedImageUseCase.updateFeaturedImage(NO_FEATURED_IMAGE_ID, editPostRepository) { + refreshFeaturedImage() + _syncFeaturedImageToEditor.postValue(Event(Unit)) + } + } + + private fun onFeaturedImageRetryClicked() { + val post = editPostRepository.getPost() ?: return + featuredImageHelper.retryFeaturedImageUpload(site, post) + refreshFeaturedImage() + } + private fun onActionClicked(actionType: ActionType) { _onActionClicked.postValue(Event(actionType)) } @@ -229,4 +331,8 @@ class PrepublishingHomeViewModel @Inject constructor( _socialUiState.postValue(newState) } + + companion object { + private const val NO_FEATURED_IMAGE_ID = 0L + } } diff --git a/WordPress/src/main/res/layout/post_prepublishing_home_fragment.xml b/WordPress/src/main/res/layout/post_prepublishing_home_fragment.xml index ee69abe8761a..985cef6f138d 100644 --- a/WordPress/src/main/res/layout/post_prepublishing_home_fragment.xml +++ b/WordPress/src/main/res/layout/post_prepublishing_home_fragment.xml @@ -1,23 +1,41 @@ - + android:layout_height="wrap_content"> - + - - + android:scrollbars="vertical" /> + + + + diff --git a/WordPress/src/main/res/layout/prepublishing_home_featured_image_item.xml b/WordPress/src/main/res/layout/prepublishing_home_featured_image_item.xml new file mode 100644 index 000000000000..648af69646e7 --- /dev/null +++ b/WordPress/src/main/res/layout/prepublishing_home_featured_image_item.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/src/main/res/layout/prepublishing_home_publish_button_list_item.xml b/WordPress/src/main/res/layout/prepublishing_home_publish_button_list_item.xml deleted file mode 100644 index dcc8c9a1971f..000000000000 --- a/WordPress/src/main/res/layout/prepublishing_home_publish_button_list_item.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/PrepublishingHomeViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/PrepublishingHomeViewModelTest.kt index 6542b486233f..d0752f71ba22 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/PrepublishingHomeViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/PrepublishingHomeViewModelTest.kt @@ -14,11 +14,14 @@ import org.wordpress.android.R import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.post.PostStatus +import org.wordpress.android.ui.posts.FeaturedImageHelper.FeaturedImageData +import org.wordpress.android.ui.posts.FeaturedImageHelper.FeaturedImageState import org.wordpress.android.fluxc.model.post.PostStatus.PRIVATE import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ActionType import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ActionType.PrepublishingScreenNavigation import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ButtonUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.ButtonUiState.PublishButtonUiState +import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.FeaturedImageUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.HeaderUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.HomeUiState import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeItemUiState.SocialUiState @@ -48,6 +51,12 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() { @Mock lateinit var getCategoriesUseCase: GetCategoriesUseCase + @Mock + lateinit var featuredImageHelper: FeaturedImageHelper + + @Mock + lateinit var updateFeaturedImageUseCase: UpdateFeaturedImageUseCase + @Mock lateinit var site: SiteModel @@ -60,6 +69,8 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() { getButtonUiStateUseCase, mock(), getCategoriesUseCase, + featuredImageHelper, + updateFeaturedImageUseCase, testDispatcher() ) whenever( @@ -76,6 +87,9 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() { whenever(postSettingsUtils.getPublishDateLabel(any())).thenReturn(("")) whenever(site.name).thenReturn("") whenever(getCategoriesUseCase.getPostCategoriesString(any(), any())).thenReturn("") + whenever(editPostRepository.getPost()).thenReturn(mock()) + whenever(featuredImageHelper.createCurrentFeaturedImageState(any(), any())) + .thenReturn(FeaturedImageData(FeaturedImageState.IMAGE_EMPTY, null)) // need to observe forever to be able to access `value` since it's a MediatorLiveData viewModel.uiState.observeForever(mock()) @@ -159,21 +173,43 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() { } @Test - fun `verify that header ui state is propagated to prepublishingHomeUiState once the viewModel is started`() { + fun `verify featured image card is visible for a post on a supporting host`() { // arrange - val expectedActionsAmount = 1 + whenever(editPostRepository.isPage).thenReturn(false) // act viewModel.start(editPostRepository, site) // assert - assertThat(viewModel.uiState.value?.filterIsInstance(HeaderUiState::class.java)?.size).isEqualTo( - expectedActionsAmount - ) + assertThat(getFeaturedImageUiState()).isInstanceOf(FeaturedImageUiState.Visible::class.java) + } + + @Test + fun `verify featured image card is hidden for a page`() { + // arrange + whenever(editPostRepository.isPage).thenReturn(true) + + // act + viewModel.start(editPostRepository, site) + + // assert + assertThat(getFeaturedImageUiState()).isEqualTo(FeaturedImageUiState.Hidden) } @Test - fun `verify that publish button ui state is propagated to uiState once the viewModel is started`() { + fun `verify featured image card is hidden when the host cannot edit featured images`() { + // arrange + whenever(editPostRepository.isPage).thenReturn(false) + + // act + viewModel.start(editPostRepository, site, isFeaturedImageEditingSupported = false) + + // assert + assertThat(getFeaturedImageUiState()).isEqualTo(FeaturedImageUiState.Hidden) + } + + @Test + fun `verify that header ui state is propagated to prepublishingHomeUiState once the viewModel is started`() { // arrange val expectedActionsAmount = 1 @@ -181,11 +217,20 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() { viewModel.start(editPostRepository, site) // assert - assertThat(viewModel.uiState.value?.filterIsInstance(ButtonUiState::class.java)?.size).isEqualTo( + assertThat(viewModel.uiState.value?.filterIsInstance(HeaderUiState::class.java)?.size).isEqualTo( expectedActionsAmount ) } + @Test + fun `verify that publish button ui state is exposed once the viewModel is started`() { + // act + viewModel.start(editPostRepository, site) + + // assert + assertThat(viewModel.buttonUiState.value).isInstanceOf(PublishButtonUiState::class.java) + } + @Test fun `verify that publish action type is propagated to prepublishingActionType`() { // arrange @@ -427,7 +472,7 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() { private fun getHeaderUiState() = viewModel.uiState.value?.filterIsInstance(HeaderUiState::class.java)?.first() private fun getButtonUiState(): ButtonUiState? { - return viewModel.uiState.value?.filterIsInstance(ButtonUiState::class.java)?.first() + return viewModel.buttonUiState.value } private fun getHomeUiState(actionType: ActionType): HomeUiState? { @@ -435,4 +480,7 @@ class PrepublishingHomeViewModelTest : BaseUnitTest() { ?.filterIsInstance(HomeUiState::class.java) return actions?.find { it.navigationAction == actionType } } + + private fun getFeaturedImageUiState(): FeaturedImageUiState? = + viewModel.uiState.value?.filterIsInstance(FeaturedImageUiState::class.java)?.firstOrNull() }