diff --git a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java index 2384e438dd45..c08ae4e45111 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java @@ -92,6 +92,7 @@ import org.wordpress.android.ui.posts.PostDatePickerDialogFragment; import org.wordpress.android.ui.posts.PostListFragment; import org.wordpress.android.ui.posts.PostNotificationScheduleTimeDialogFragment; +import org.wordpress.android.ui.posts.PostResolutionOverlayFragment; import org.wordpress.android.ui.posts.PostSettingsListDialogFragment; import org.wordpress.android.ui.posts.PostSettingsTagsFragment; import org.wordpress.android.ui.posts.PostTimePickerDialogFragment; @@ -555,4 +556,6 @@ public interface AppComponent { void inject(WeekWidgetBlockListProviderFactory object); void inject(WPMainNavigationView object); + + void inject(PostResolutionOverlayFragment object); } diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java b/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java index cbd2ff792a84..2ee57454f4ef 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java @@ -33,6 +33,7 @@ import org.wordpress.android.ui.posts.EditorBloggingPromptsViewModel; import org.wordpress.android.ui.posts.EditorJetpackSocialViewModel; import org.wordpress.android.ui.posts.PostListMainViewModel; +import org.wordpress.android.ui.posts.PostResolutionOverlayViewModel; import org.wordpress.android.ui.posts.editor.StorePostViewModel; import org.wordpress.android.ui.posts.prepublishing.PrepublishingViewModel; import org.wordpress.android.ui.posts.prepublishing.categories.PrepublishingCategoriesViewModel; @@ -539,4 +540,9 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(EditorJetpackSocialViewModel.class) abstract ViewModel editorJetpackSocialViewModel(EditorJetpackSocialViewModel viewModel); + + @Binds + @IntoMap + @ViewModelKey(PostResolutionOverlayViewModel.class) + abstract ViewModel postResolutionOverlayViewModel(PostResolutionOverlayViewModel viewModel); } 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 2a95431d0ce3..6c07a971956b 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 @@ -234,7 +234,7 @@ import org.wordpress.android.util.analytics.AnalyticsUtils import org.wordpress.android.util.analytics.AnalyticsUtils.BlockEditorEnabledSource import org.wordpress.android.util.config.ContactSupportFeatureConfig import org.wordpress.android.util.config.GlobalStyleSupportFeatureConfig -import org.wordpress.android.util.config.SyncPublishingFeatureConfig +import org.wordpress.android.util.config.PostConflictResolutionFeatureConfig import org.wordpress.android.util.extensions.setLiftOnScrollTargetViewIdAndRequestLayout import org.wordpress.android.util.helpers.MediaFile import org.wordpress.android.util.helpers.MediaGallery @@ -403,7 +403,7 @@ class EditPostActivity : LocaleAwareActivity(), EditorFragmentActivity, EditorIm @Inject lateinit var contactSupportFeatureConfig: ContactSupportFeatureConfig - @Inject lateinit var syncPublishingFeatureConfig: SyncPublishingFeatureConfig + @Inject lateinit var postConflictResolutionFeatureConfig: PostConflictResolutionFeatureConfig @Inject lateinit var storePostViewModel: StorePostViewModel @Inject lateinit var storageUtilsViewModel: StorageUtilsViewModel @@ -593,7 +593,7 @@ class EditPostActivity : LocaleAwareActivity(), EditorFragmentActivity, EditorIm updatingPostArea = findViewById(R.id.updating) // check if post content needs updating - if (syncPublishingFeatureConfig.isEnabled()) { + if (postConflictResolutionFeatureConfig.isEnabled()) { storePostViewModel.checkIfUpdatedPostVersionExists((editPostRepository), siteModel) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostActionHandler.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostActionHandler.kt index 132e2af293de..d323b7ba7209 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostActionHandler.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostActionHandler.kt @@ -74,7 +74,7 @@ class PostActionHandler( private val showToast: (ToastMessageHolder) -> Unit, private val triggerPreviewStateUpdate: (PostListRemotePreviewState, PostInfoType) -> Unit, private val copyPost: (SiteModel, PostModel, Boolean) -> Unit, - private val syncPublishingFeatureUtils: SyncPublishingFeatureUtils + private val postConflictResolutionFeatureUtils: PostConflictResolutionFeatureUtils ) { private val criticalPostActionTracker = CriticalPostActionTracker(onStateChanged = { invalidateList.invoke() @@ -209,7 +209,7 @@ class PostActionHandler( } post.setStatus(DRAFT.toString()) dispatcher.dispatch(PostActionBuilder.newPushPostAction( - syncPublishingFeatureUtils.getRemotePostPayloadForPush(RemotePostPayload(post, site)) + postConflictResolutionFeatureUtils.getRemotePostPayloadForPush(RemotePostPayload(post, site)) )) val localPostId = LocalId(post.id) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/SyncPublishingFeatureUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostConflictResolutionFeatureUtils.kt similarity index 71% rename from WordPress/src/main/java/org/wordpress/android/ui/posts/SyncPublishingFeatureUtils.kt rename to WordPress/src/main/java/org/wordpress/android/ui/posts/PostConflictResolutionFeatureUtils.kt index cbc5ef6564e3..91c58b78bca7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/SyncPublishingFeatureUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostConflictResolutionFeatureUtils.kt @@ -1,14 +1,14 @@ package org.wordpress.android.ui.posts import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload -import org.wordpress.android.util.config.SyncPublishingFeatureConfig +import org.wordpress.android.util.config.PostConflictResolutionFeatureConfig import javax.inject.Inject -class SyncPublishingFeatureUtils @Inject constructor( - private val syncPublishingFeatureConfig: SyncPublishingFeatureConfig +class PostConflictResolutionFeatureUtils @Inject constructor( + private val postConflictResolutionFeatureConfig: PostConflictResolutionFeatureConfig ) { - private fun isSyncPublishingEnabled(): Boolean { - return syncPublishingFeatureConfig.isEnabled() + fun isPostConflictResolutionEnabled(): Boolean { + return postConflictResolutionFeatureConfig.isEnabled() } /** @@ -21,7 +21,7 @@ class SyncPublishingFeatureUtils @Inject constructor( * the remote version. */ fun getRemotePostPayloadForPush(payload: RemotePostPayload): RemotePostPayload { - if (isSyncPublishingEnabled().not()) { + if (isPostConflictResolutionEnabled().not()) { payload.shouldSkipConflictResolutionCheck = true } return payload diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListDialogHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListDialogHelper.kt index afaab60c1274..75dfcadbc8a4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListDialogHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListDialogHelper.kt @@ -28,7 +28,9 @@ private const val POST_TYPE = "post_type" class PostListDialogHelper( private val showDialog: (DialogHolder) -> Unit, private val checkNetworkConnection: () -> Boolean, - private val analyticsTracker: AnalyticsTrackerWrapper + private val analyticsTracker: AnalyticsTrackerWrapper, + private val showConflictResolutionOverlay: ((PostResolutionOverlayActionEvent.ShowDialogAction) -> Unit)? = null, + private val isPostConflictResolutionEnabled: Boolean ) { // Since we are using DialogFragments we need to hold onto which post will be published or trashed / resolved private var localPostIdForDeleteDialog: Int? = null @@ -115,28 +117,45 @@ class PostListDialogHelper( } fun showConflictedPostResolutionDialog(post: PostModel) { - val dialogHolder = DialogHolder( - tag = CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG, - title = UiStringRes(R.string.dialog_confirm_load_remote_post_title), - message = UiStringText(PostUtils.getConflictedPostCustomStringForDialog(post)), - positiveButton = UiStringRes(R.string.dialog_confirm_load_remote_post_discard_local), - negativeButton = UiStringRes(R.string.dialog_confirm_load_remote_post_discard_web) - ) localPostIdForConflictResolutionDialog = post.id - showDialog.invoke(dialogHolder) + if (isPostConflictResolutionEnabled) { + showConflictResolutionOverlay?.invoke( + PostResolutionOverlayActionEvent.ShowDialogAction( + post, + PostResolutionType.SYNC_CONFLICT + ) + ) + } else { + val dialogHolder = DialogHolder( + tag = CONFIRM_ON_CONFLICT_LOAD_REMOTE_POST_DIALOG_TAG, + title = UiStringRes(R.string.dialog_confirm_load_remote_post_title), + message = UiStringText(PostUtils.getConflictedPostCustomStringForDialog(post)), + positiveButton = UiStringRes(R.string.dialog_confirm_load_remote_post_discard_local), + negativeButton = UiStringRes(R.string.dialog_confirm_load_remote_post_discard_web) + ) + showDialog.invoke(dialogHolder) + } } fun showAutoSaveRevisionDialog(post: PostModel) { - analyticsTracker.track(UNPUBLISHED_REVISION_DIALOG_SHOWN, mapOf(POST_TYPE to "post")) - val dialogHolder = DialogHolder( - tag = CONFIRM_ON_AUTOSAVE_REVISION_DIALOG_TAG, - title = UiStringRes(R.string.dialog_confirm_autosave_title), - message = PostUtils.getCustomStringForAutosaveRevisionDialog(post), - positiveButton = UiStringRes(R.string.dialog_confirm_autosave_restore_button), - negativeButton = UiStringRes(R.string.dialog_confirm_autosave_dont_restore_button) - ) localPostIdForAutosaveRevisionResolutionDialog = post.id - showDialog.invoke(dialogHolder) + if (isPostConflictResolutionEnabled) { + showConflictResolutionOverlay?.invoke( + PostResolutionOverlayActionEvent.ShowDialogAction( + post, PostResolutionType.AUTOSAVE_REVISION_CONFLICT + ) + ) + } else { + analyticsTracker.track(UNPUBLISHED_REVISION_DIALOG_SHOWN, mapOf(POST_TYPE to "post")) + val dialogHolder = DialogHolder( + tag = CONFIRM_ON_AUTOSAVE_REVISION_DIALOG_TAG, + title = UiStringRes(R.string.dialog_confirm_autosave_title), + message = PostUtils.getCustomStringForAutosaveRevisionDialog(post), + positiveButton = UiStringRes(R.string.dialog_confirm_autosave_restore_button), + negativeButton = UiStringRes(R.string.dialog_confirm_autosave_dont_restore_button) + ) + showDialog.invoke(dialogHolder) + } } fun showCopyConflictDialog(post: PostModel) { @@ -256,4 +275,71 @@ class PostListDialogHelper( ) } } + + fun onPostResolutionConfirmed( + event: PostResolutionOverlayActionEvent.PostResolutionConfirmationEvent, + updateConflictedPostWithRemoteVersion: (Int) -> Unit, + editRestoredAutoSavePost: (Int) -> Unit, + editLocalPost: (Int) -> Unit, + updateConflictedPostWithLocalVersion: (Int) -> Unit + ) { + when (event.postResolutionType) { + PostResolutionType.AUTOSAVE_REVISION_CONFLICT -> { + handleAutosaveRevisionConflict(event, editRestoredAutoSavePost, editLocalPost) + } + + PostResolutionType.SYNC_CONFLICT -> { + handleSyncRevisionConflict( + event, + updateConflictedPostWithLocalVersion, + updateConflictedPostWithRemoteVersion + ) + } + } + } + + private fun handleAutosaveRevisionConflict( + event: PostResolutionOverlayActionEvent.PostResolutionConfirmationEvent, + editRestoredAutoSavePost: (Int) -> Unit, + editLocalPost: (Int) -> Unit + ) { + when (event.postResolutionConfirmationType) { + PostResolutionConfirmationType.CONFIRM_LOCAL -> { + localPostIdForAutosaveRevisionResolutionDialog?.let { + // open the editor with the local post (don't use the auto save version) + editLocalPost(it) + } + } + + PostResolutionConfirmationType.CONFIRM_OTHER -> { + localPostIdForAutosaveRevisionResolutionDialog?.let { + // open the editor with the restored auto save + localPostIdForAutosaveRevisionResolutionDialog = null + editRestoredAutoSavePost(it) + } + } + } + } + + private fun handleSyncRevisionConflict( + event: PostResolutionOverlayActionEvent.PostResolutionConfirmationEvent, + updateConflictedPostWithLocalVersion: (Int) -> Unit, + updateConflictedPostWithRemoteVersion: (Int) -> Unit + ) { + when (event.postResolutionConfirmationType) { + PostResolutionConfirmationType.CONFIRM_LOCAL -> { + localPostIdForConflictResolutionDialog?.let { + updateConflictedPostWithLocalVersion(it) + } + } + + PostResolutionConfirmationType.CONFIRM_OTHER -> { + localPostIdForConflictResolutionDialog?.let { + localPostIdForConflictResolutionDialog = null + // here load version from remote + updateConflictedPostWithRemoteVersion(it) + } + } + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListMainViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListMainViewModel.kt index c27bf9524922..53e62f326d74 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListMainViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostListMainViewModel.kt @@ -83,8 +83,10 @@ class PostListMainViewModel @Inject constructor( @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, private val uploadStarter: UploadStarter, - private val syncPublishingFeatureUtils: SyncPublishingFeatureUtils + private val postConflictResolutionFeatureUtils: PostConflictResolutionFeatureUtils ) : ViewModel(), CoroutineScope { + private var isStarted = false + private val lifecycleOwner = object : LifecycleOwner { val lifecycleRegistry = LifecycleRegistry(this) override val lifecycle: Lifecycle = lifecycleRegistry @@ -128,6 +130,10 @@ class PostListMainViewModel @Inject constructor( private val _dialogAction = SingleLiveEvent() val dialogAction: LiveData = _dialogAction + private val _conflictResolutionAction = SingleLiveEvent() + val conflictResolutionAction: LiveData = + _conflictResolutionAction + private val _postUploadAction = SingleLiveEvent() val postUploadAction: LiveData = _postUploadAction @@ -150,8 +156,10 @@ class PostListMainViewModel @Inject constructor( private val postListDialogHelper: PostListDialogHelper by lazy { PostListDialogHelper( showDialog = { _dialogAction.postValue(it) }, + showConflictResolutionOverlay = { _conflictResolutionAction.postValue(it) }, checkNetworkConnection = this::checkNetworkConnection, - analyticsTracker = analyticsTracker + analyticsTracker = analyticsTracker, + isPostConflictResolutionEnabled = postConflictResolutionFeatureUtils.isPostConflictResolutionEnabled() ) } @@ -186,7 +194,7 @@ class PostListMainViewModel @Inject constructor( showToast = { _toastMessage.postValue(it) }, triggerPreviewStateUpdate = this::updatePreviewAndDialogState, copyPost = this::copyPost, - syncPublishingFeatureUtils = syncPublishingFeatureUtils + postConflictResolutionFeatureUtils = postConflictResolutionFeatureUtils ) } @@ -235,6 +243,7 @@ class PostListMainViewModel @Inject constructor( currentBottomSheetPostId: LocalId, editPostRepository: EditPostRepository ) { + if (isStarted) return this.site = site this.editPostRepository = editPostRepository @@ -294,6 +303,8 @@ class PostListMainViewModel @Inject constructor( savePostToDbUseCase.savePostToDb(editPostRepository, site) }) } + + isStarted = true } override fun onCleared() { @@ -478,6 +489,17 @@ class PostListMainViewModel @Inject constructor( ) } + // Post Resolution Overlay Actions + fun onPostResolutionConfirmed(event: PostResolutionOverlayActionEvent.PostResolutionConfirmationEvent) { + postListDialogHelper.onPostResolutionConfirmed( + event = event, + updateConflictedPostWithRemoteVersion = postConflictResolver::updateConflictedPostWithRemoteVersion, + editRestoredAutoSavePost = this::editRestoredAutoSavePost, + editLocalPost = this::editLocalPost, + updateConflictedPostWithLocalVersion = postConflictResolver::updateConflictedPostWithLocalVersion + ) + } + private fun showPrepublishingBottomSheet(post: PostModel) { currentBottomSheetPostId = LocalId(post.id) editPostRepository.loadPostByLocalPostId(post.id) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlay.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlay.kt new file mode 100644 index 000000000000..0652214c93cb --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlay.kt @@ -0,0 +1,300 @@ +package org.wordpress.android.ui.posts + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Checkbox +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.ContentAlphaProvider +import org.wordpress.android.ui.compose.theme.AppColor +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.unit.Margin +import org.wordpress.android.ui.compose.utils.uiStringText +import org.wordpress.android.ui.utils.UiString + +private val contentIconForegroundColor: Color + get() = AppColor.White + +private val contentIconBackgroundColor: Color + @Composable get() = if (MaterialTheme.colors.isLight) { + AppColor.Black + } else { + AppColor.White.copy(alpha = 0.18f) + } + +private val contentTextEmphasis: Float + @Composable get() = if (MaterialTheme.colors.isLight) { + 1f + } else { + ContentAlpha.medium + } + +@Composable +fun PostResolutionOverlay( + uiState: PostResolutionOverlayUiState?, + modifier: Modifier = Modifier +) { + if (uiState == null) return + Column(modifier) { + IconButton( + onClick = uiState.closeClick, + modifier = Modifier.align(Alignment.End) + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.label_close_button), + ) + } + + Spacer( + Modifier + .requiredHeightIn( + min = Margin.Medium.value, + max = Margin.ExtraExtraMediumLarge.value + ) + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Margin.ExtraMediumLarge.value) + .padding(bottom = Margin.ExtraLarge.value) + ) { + // Title + Text( + stringResource(uiState.titleResId), + style = androidx.compose.material3.MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(Margin.ExtraLarge.value)) + + Text( + stringResource(uiState.bodyResId), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + start = Margin.ExtraMediumLarge.value, + end = Margin.ExtraMediumLarge.value + ), + textAlign = TextAlign.Center, + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = ContentAlpha.medium), + ) + + Spacer(Modifier.height(Margin.ExtraExtraMediumLarge.value)) + + // Device information + OverlayContent( + items = uiState.content, + onSelected = uiState.onSelected, + modifier = Modifier + .widthIn(max = 400.dp) + .padding(horizontal = Margin.ExtraMediumLarge.value), + ) + + // min spacing + Spacer(Modifier.height(Margin.ExtraLarge.value)) + Spacer(Modifier.weight(1f)) + } + } + + Divider() + + Row( + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { uiState.cancelClick() }, + modifier = Modifier + .weight(1f) + .padding(Margin.ExtraMediumLarge.value), + elevation = null, + contentPadding = PaddingValues(vertical = Margin.Large.value), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.onSurface, + contentColor = MaterialTheme.colors.surface, + ), + ) { + Text(text = stringResource(R.string.cancel)) + } + Button( + onClick = { uiState.confirmClick() }, + enabled = uiState.actionEnabled, + modifier = Modifier + .weight(1f) + .padding(Margin.ExtraMediumLarge.value), + elevation = null, + contentPadding = PaddingValues(vertical = Margin.Large.value), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.onSurface, + contentColor = MaterialTheme.colors.surface, + ), + ) { + Text(text = stringResource(R.string.confirm)) + } + } + } +} + +@Composable +private fun OverlayContent( + items: List, + onSelected: (ContentItem) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(Margin.ExtraMediumLarge.value), + modifier = modifier, + ) { + items.forEach { item -> + OverlayContentItem( + item = item, + onSelected = onSelected + ) + } + } +} + +@Composable +private fun OverlayContentItem( + item: ContentItem, + onSelected: (ContentItem) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + color = contentIconBackgroundColor, + shape = CircleShape, + ), + ) { + Image( + painter = painterResource(item.iconResId), + contentDescription = null, + colorFilter = ColorFilter.tint(contentIconForegroundColor), + modifier = Modifier + .size(24.dp) + .align(Alignment.Center) + ) + } + + Spacer(Modifier.width(Margin.ExtraLarge.value)) + + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = Margin.ExtraLarge.value) + ) { + ContentAlphaProvider(contentTextEmphasis) { + Text( + stringResource(item.headerResId), + style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + + ContentAlphaProvider(contentTextEmphasis) { + Text( + uiStringText(item.dateLine), + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + } + + Checkbox( + checked = item.isSelected, + onCheckedChange = { isChecked -> + onSelected(item.copy(isSelected = isChecked)) + }, + modifier = Modifier.align(Alignment.CenterVertically) + ) + } +} + +@Preview(name = "Light Mode") +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PostResolutionOverlayPreview() { + AppTheme { + PostResolutionOverlay( + uiState = PostResolutionOverlayUiState( + titleResId = R.string.dialog_post_conflict_title, + bodyResId = R.string.dialog_post_conflict_body, + actionEnabled = false, + confirmClick = {}, + closeClick = {}, + cancelClick = {}, + onSelected = {}, + content = listOf( + ContentItem( + headerResId = R.string.dialog_post_conflict_current_device, + dateLine = UiString.UiStringText("Thursday, Mar 4, 2024 1:00 PM"), + isSelected = true, + id = ContentItemType.LOCAL_DEVICE + ), + ContentItem( + headerResId = R.string.dialog_post_conflict_another_device, + dateLine = UiString.UiStringText("Friday, Mar 4, 2024 11:00 AM"), + isSelected = false, + id = ContentItemType.OTHER_DEVICE + ) + ), + ) + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayAnalyticsTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayAnalyticsTracker.kt new file mode 100644 index 000000000000..009fa4247147 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayAnalyticsTracker.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.ui.posts + +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import javax.inject.Inject + +class PostResolutionOverlayAnalyticsTracker @Inject constructor( + private val tracker: AnalyticsTrackerWrapper +) { + fun trackShown(postResolutionType: PostResolutionType) { + val stat = when (postResolutionType) { + PostResolutionType.SYNC_CONFLICT -> AnalyticsTracker.Stat.RESOLVE_CONFLICT_SCREEN_SHOWN + PostResolutionType.AUTOSAVE_REVISION_CONFLICT -> + AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_SCREEN_SHOWN + } + tracker.track(stat) + } + + fun trackCancel(postResolutionType: PostResolutionType) { + val stat = when (postResolutionType) { + PostResolutionType.SYNC_CONFLICT -> AnalyticsTracker.Stat.RESOLVE_CONFLICT_CANCEL_TAPPED + PostResolutionType.AUTOSAVE_REVISION_CONFLICT -> + AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_CANCEL_TAPPED + } + tracker.track(stat) + } + + fun trackClose(postResolutionType: PostResolutionType) { + val stat = when (postResolutionType) { + PostResolutionType.SYNC_CONFLICT -> AnalyticsTracker.Stat.RESOLVE_CONFLICT_CLOSE_TAPPED + PostResolutionType.AUTOSAVE_REVISION_CONFLICT -> + AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_CLOSE_TAPPED + } + tracker.track(stat) + } + + fun trackDismissed(postResolutionType: PostResolutionType) { + val stat = when (postResolutionType) { + PostResolutionType.SYNC_CONFLICT -> AnalyticsTracker.Stat.RESOLVE_CONFLICT_DISMISSED + PostResolutionType.AUTOSAVE_REVISION_CONFLICT -> AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_DISMISSED + } + tracker.track(stat) + } + + fun trackConfirm(postResolutionType: PostResolutionType, confirmationType: PostResolutionConfirmationType) { + val stat = when (postResolutionType) { + PostResolutionType.SYNC_CONFLICT -> AnalyticsTracker.Stat.RESOLVE_CONFLICT_CONFIRM_TAPPED + PostResolutionType.AUTOSAVE_REVISION_CONFLICT -> + AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_CONFIRM_TAPPED + } + tracker.track(stat, mapOf(PROPERTY_CONFIRM_TYPE to confirmationType.analyticsLabel)) + } + + companion object { + const val PROPERTY_CONFIRM_TYPE = "confirm_type" + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayFragment.kt new file mode 100644 index 000000000000..f172c1dc578a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayFragment.kt @@ -0,0 +1,111 @@ +package org.wordpress.android.ui.posts + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.wordpress.android.WordPress +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.util.extensions.fillScreen +import javax.inject.Inject + +@Suppress("DEPRECATION") +class PostResolutionOverlayFragment : BottomSheetDialogFragment() { + @Inject + internal lateinit var viewModelFactory: ViewModelProvider.Factory + + private lateinit var viewModel: PostResolutionOverlayViewModel + + private var listener: PostResolutionOverlayListener? = null + + private val postModel: PostModel? by lazy { + arguments?.getSerializable(ARG_POST_MODEL) as? PostModel + } + + private val postResolutionType: PostResolutionType? by lazy { + arguments?.getSerializable(ARG_POST_RESOLUTION_TYPE) as? PostResolutionType + } + + @Suppress("TooGenericExceptionThrown") + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is PostResolutionOverlayListener) { + listener = context + } else { + throw RuntimeException("$context must implement PostResolutionOverlayListener") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (requireNotNull(activity).application as WordPress).component().inject(this) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + (this as? BottomSheetDialog)?.fillScreen(isDraggable = true) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + initializeViewModelAndStart() + return ComposeView(requireContext()).apply { + setContent { + AppTheme { + val uiState by viewModel.uiState.observeAsState() + PostResolutionOverlay(uiState) + } + } + } + } + + private fun initializeViewModelAndStart() { + viewModel = ViewModelProvider(this, viewModelFactory)[PostResolutionOverlayViewModel::class.java] + + viewModel.triggerListeners.observe(viewLifecycleOwner) { + listener?.onPostResolutionConfirmed(it) + } + + viewModel.dismissDialog.observe(viewLifecycleOwner) { + dismiss() + } + + viewModel.start(postModel, postResolutionType) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + viewModel.onDialogDismissed() + } + + override fun onDetach() { + super.onDetach() + listener = null + } + + companion object { + const val TAG = "PostResolutionOverlayFragment" + + private const val ARG_POST_MODEL = "arg_post_model" + private const val ARG_POST_RESOLUTION_TYPE = "arg_post_resolution_type" + + @JvmStatic + fun newInstance(postModel: PostModel, postResolutionType: PostResolutionType) = + PostResolutionOverlayFragment().apply { + arguments = Bundle().apply { + putSerializable(ARG_POST_MODEL, postModel) + putSerializable(ARG_POST_RESOLUTION_TYPE, postResolutionType) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayListener.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayListener.kt new file mode 100644 index 000000000000..44ac906c6d74 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayListener.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.ui.posts + +interface PostResolutionOverlayListener { + fun onPostResolutionConfirmed(event: PostResolutionOverlayActionEvent.PostResolutionConfirmationEvent) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayUiState.kt new file mode 100644 index 000000000000..b09e0724d072 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayUiState.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.ui.posts + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.ui.utils.UiString + +data class PostResolutionOverlayUiState( + @StringRes val titleResId: Int, + @StringRes val bodyResId: Int, + val actionEnabled: Boolean = false, + val content: List, + val selectedContentItem: ContentItem? = null, + val onSelected: (ContentItem) -> Unit, + val closeClick: () -> Unit, + val cancelClick: () -> Unit, + val confirmClick: () -> Unit +) + +data class ContentItem( + val id: ContentItemType, + @DrawableRes val iconResId: Int = R.drawable.ic_pages_white_24dp, + @StringRes val headerResId: Int, + val dateLine: UiString, + val isSelected: Boolean, +) + +enum class ContentItemType { + LOCAL_DEVICE, + OTHER_DEVICE +} + +fun ContentItemType.toPostResolutionConfirmationType(): PostResolutionConfirmationType { + return when (this) { + ContentItemType.LOCAL_DEVICE -> PostResolutionConfirmationType.CONFIRM_LOCAL + ContentItemType.OTHER_DEVICE -> PostResolutionConfirmationType.CONFIRM_OTHER + } +} + +enum class PostResolutionType { + SYNC_CONFLICT, + AUTOSAVE_REVISION_CONFLICT +} + +enum class PostResolutionConfirmationType(val analyticsLabel: String) { + CONFIRM_LOCAL("local_version"), + CONFIRM_OTHER("remote_version") +} + +sealed class PostResolutionOverlayActionEvent { + data class ShowDialogAction(val postModel: PostModel, val postResolutionType: PostResolutionType) + data class PostResolutionConfirmationEvent( + val postResolutionType: PostResolutionType, + val postResolutionConfirmationType: PostResolutionConfirmationType + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayViewModel.kt new file mode 100644 index 000000000000..70732b6ae4e5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostResolutionOverlayViewModel.kt @@ -0,0 +1,182 @@ +package org.wordpress.android.ui.posts + +import android.text.TextUtils +import android.text.format.DateUtils +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.ui.utils.UiString +import org.wordpress.android.util.DateTimeUtilsWrapper +import org.wordpress.android.util.DateUtilsWrapper +import org.wordpress.android.viewmodel.SingleLiveEvent +import javax.inject.Inject + +class PostResolutionOverlayViewModel @Inject constructor( + private val dateTimeUtilsWrapper: DateTimeUtilsWrapper, + private val dateUtilsWrapper: DateUtilsWrapper, + private val tracker: PostResolutionOverlayAnalyticsTracker +) : ViewModel() { + private val _uiState = MutableLiveData() + val uiState: LiveData = _uiState + + private val _triggerListeners = MutableLiveData() + val triggerListeners: MutableLiveData = + _triggerListeners + + private val _dismissDialog = SingleLiveEvent() + val dismissDialog = _dismissDialog as LiveData + + private var isStarted = false + private lateinit var resolutionType: PostResolutionType + private lateinit var post: PostModel + + fun start(postModel: PostModel?, postResolutionType: PostResolutionType?) { + if (isStarted) return + + if (postModel == null || postResolutionType == null) { + _dismissDialog.postValue(true) + return + } + + resolutionType = postResolutionType + post = postModel + + onDialogShown() + + val uiState = when (resolutionType) { + PostResolutionType.SYNC_CONFLICT -> getUiStateForSyncConflict(postModel) + PostResolutionType.AUTOSAVE_REVISION_CONFLICT -> getUiStateForAutosaveRevisionConflict(postModel) + } + _uiState.postValue(uiState) + isStarted = true + } + + private fun getUiStateForSyncConflict(post: PostModel): PostResolutionOverlayUiState { + return PostResolutionOverlayUiState( + titleResId = R.string.dialog_post_conflict_title, + bodyResId = if (post.isPage) + R.string.dialog_post_conflict_body_for_page else R.string.dialog_post_conflict_body, + content = buildContentItemsForVersionSync(post), + confirmClick = ::onConfirmClick, + cancelClick = ::onCancelClick, + closeClick = ::onCloseClick, + onSelected = ::onItemSelected + ) + } + + private fun getUiStateForAutosaveRevisionConflict(post: PostModel): PostResolutionOverlayUiState { + return PostResolutionOverlayUiState( + titleResId = R.string.dialog_post_autosave_title, + bodyResId = if (post.isPage) + R.string.dialog_post_autosave_body_for_page else R.string.dialog_post_autosave_body, + content = buildContentItemsForAutosaveSync(post), + confirmClick = ::onConfirmClick, + cancelClick = ::onCancelClick, + closeClick = ::onCloseClick, + onSelected = ::onItemSelected + ) + } + + private fun buildContentItemsForVersionSync(post: PostModel): List { + val localLastModifiedString = + if (TextUtils.isEmpty(post.dateLocallyChanged)) post.lastModified else post.dateLocallyChanged + val remoteLastModifiedString = post.remoteLastModified + val localLastModifiedAsLong = dateTimeUtilsWrapper.timestampFromIso8601Millis(localLastModifiedString) + val remoteLastModifiedAsLong = dateTimeUtilsWrapper.timestampFromIso8601Millis(remoteLastModifiedString) + + val flags = (DateUtils.FORMAT_SHOW_TIME or + DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_RELATIVE) + + val localModifiedDateTime = dateUtilsWrapper.formatDateTime(localLastModifiedAsLong, flags ) + + val remoteModifiedDateTime = dateUtilsWrapper.formatDateTime(remoteLastModifiedAsLong, flags ) + + return listOf( + ContentItem(headerResId = R.string.dialog_post_conflict_current_device, + dateLine = UiString.UiStringText(localModifiedDateTime), + isSelected = false, + id = ContentItemType.LOCAL_DEVICE), + ContentItem(headerResId = R.string.dialog_post_conflict_another_device, + dateLine = UiString.UiStringText(remoteModifiedDateTime), + isSelected = false, + id = ContentItemType.OTHER_DEVICE) + ) + } + + private fun buildContentItemsForAutosaveSync(post: PostModel): List { + val localLastModifiedString = + if (TextUtils.isEmpty(post.dateLocallyChanged)) post.lastModified else post.dateLocallyChanged + val autoSaveModifiedString = post.autoSaveModified as String + val localLastModifiedAsLong = dateTimeUtilsWrapper.timestampFromIso8601Millis(localLastModifiedString) + val autoSaveModifiedAsLong = dateTimeUtilsWrapper.timestampFromIso8601Millis(autoSaveModifiedString) + + val flags = (DateUtils.FORMAT_SHOW_TIME or + DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_RELATIVE) + + val localModifiedDateTime = dateUtilsWrapper.formatDateTime(localLastModifiedAsLong, flags ) + + val remoteModifiedDateTime = dateUtilsWrapper.formatDateTime(autoSaveModifiedAsLong, flags ) + + return listOf( + ContentItem(headerResId = R.string.dialog_post_autosave_current_device, + dateLine = UiString.UiStringText(localModifiedDateTime), + isSelected = false, + id = ContentItemType.LOCAL_DEVICE), + ContentItem(headerResId = R.string.dialog_post_autosave_another_device, + dateLine = UiString.UiStringText(remoteModifiedDateTime), + isSelected = false, + id = ContentItemType.OTHER_DEVICE) + ) + } + + private fun onConfirmClick() { + _uiState.value?.selectedContentItem?.let { + val confirmationType = it.id.toPostResolutionConfirmationType() + tracker.trackConfirm(resolutionType, confirmationType) + _triggerListeners.value = PostResolutionOverlayActionEvent.PostResolutionConfirmationEvent(resolutionType, + confirmationType) + } + _dismissDialog.value = true + } + + private fun onCloseClick() { + tracker.trackClose(resolutionType) + _dismissDialog.value = true + } + + private fun onCancelClick() { + tracker.trackCancel(resolutionType) + _dismissDialog.value = true + } + + fun onDialogDismissed() { + tracker.trackDismissed(resolutionType) + } + + private fun onDialogShown() { + tracker.trackShown(resolutionType) + } + + private fun onItemSelected(selectedItem: ContentItem) { + val selectedState = selectedItem.isSelected + + // Update the isSelected property of the selected item within the content list + val updatedContent = _uiState.value?.content?.map { contentItem -> + contentItem.copy(isSelected = selectedState && contentItem.id == selectedItem.id ) + } ?: return + + val currentUiState = _uiState.value ?: return // Return if UiState is null + val updatedUiState = currentUiState.copy( + selectedContentItem = selectedItem, + content = updatedContent, + actionEnabled = selectedState + ) + _uiState.postValue(updatedUiState) + } +} 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 07d637895a7d..4da1601f9492 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 @@ -78,7 +78,8 @@ class PostsListActivity : LocaleAwareActivity(), BasicDialogPositiveClickInterface, BasicDialogNegativeClickInterface, BasicDialogOnDismissByOutsideTouchInterface, - ScrollableViewInitializedListener { + ScrollableViewInitializedListener, + PostResolutionOverlayListener { @Inject internal lateinit var siteStore: SiteStore @@ -360,6 +361,14 @@ class PostsListActivity : LocaleAwareActivity(), viewModel.dialogAction.observe(this@PostsListActivity) { it?.show(this@PostsListActivity, supportFragmentManager, uiHelpers) } + viewModel.conflictResolutionAction.observe(this@PostsListActivity) { + val fragment = supportFragmentManager.findFragmentByTag(PostResolutionOverlayFragment.TAG) + if (fragment == null) { + PostResolutionOverlayFragment + .newInstance(it.postModel, it.postResolutionType) + .show(supportFragmentManager, PostResolutionOverlayFragment.TAG) + } + } viewModel.postUploadAction.observe(this@PostsListActivity) { it?.let { uploadAction -> handleUploadAction( @@ -439,11 +448,10 @@ class PostsListActivity : LocaleAwareActivity(), } } - public override fun onResume() { + override fun onResume() { super.onResume() ActivityId.trackLastActivity(ActivityId.POSTS) } - @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) @@ -455,7 +463,6 @@ class PostsListActivity : LocaleAwareActivity(), data, this, site, data.getIntExtra(EditPostActivityConstants.EXTRA_POST_LOCAL_ID, 0) ) - // a restart will happen so, no need to continue here return } @@ -628,6 +635,11 @@ class PostsListActivity : LocaleAwareActivity(), binding.appbarMain.setTag(R.id.posts_non_search_recycler_view_id_tag_key, containerId) } + // PostResolutionOverlayListener Callbacks + override fun onPostResolutionConfirmed(event: PostResolutionOverlayActionEvent.PostResolutionConfirmationEvent) { + viewModel.onPostResolutionConfirmed(event) + } + companion object { private const val BLOGGING_REMINDERS_FRAGMENT_TAG = "blogging_reminders_fragment_tag" private const val ACTIONS_SHOWN_BY_DEFAULT = "actions_shown_by_default" diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StorePostViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StorePostViewModel.kt index f53fef7e0f82..faf67250bc34 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StorePostViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StorePostViewModel.kt @@ -34,7 +34,7 @@ import org.wordpress.android.ui.posts.editor.StorePostViewModel.UpdateFromEditor import org.wordpress.android.ui.uploads.UploadServiceFacade import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper -import org.wordpress.android.util.config.SyncPublishingFeatureConfig +import org.wordpress.android.util.config.PostConflictResolutionFeatureConfig import org.wordpress.android.viewmodel.Event import org.wordpress.android.viewmodel.ScopedViewModel import javax.inject.Inject @@ -53,7 +53,7 @@ class StorePostViewModel private val networkUtils: NetworkUtilsWrapper, private val dispatcher: Dispatcher, private val postFreshnessChecker: IPostFreshnessChecker, - private val syncPublishingFeatureConfig: SyncPublishingFeatureConfig + private val postConflictResolutionFeatureConfig: PostConflictResolutionFeatureConfig ) : ScopedViewModel(uiCoroutineDispatcher), DialogVisibilityProvider { private var debounceCounter = 0 private var saveJob: Job? = null @@ -232,7 +232,7 @@ class StorePostViewModel } private fun handlePostRefreshedIfNeeded(event: OnPostChanged) { - if (syncPublishingFeatureConfig.isEnabled().not()) return + if (postConflictResolutionFeatureConfig.isEnabled().not()) return // Refresh post content if needed (event.causeOfChange as? CauseOfOnPostChanged.UpdatePost)?.let { updatePost -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/uploads/PostUploadHandler.java b/WordPress/src/main/java/org/wordpress/android/ui/uploads/PostUploadHandler.java index 2448042982a2..ccdb5534c0e2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/uploads/PostUploadHandler.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/uploads/PostUploadHandler.java @@ -34,7 +34,7 @@ import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload; import org.wordpress.android.fluxc.store.SiteStore; import org.wordpress.android.ui.posts.PostUtils; -import org.wordpress.android.ui.posts.SyncPublishingFeatureUtils; +import org.wordpress.android.ui.posts.PostConflictResolutionFeatureUtils; import org.wordpress.android.ui.prefs.AppPrefs; import org.wordpress.android.ui.uploads.AutoSavePostIfNotDraftResult.FetchPostStatusFailed; import org.wordpress.android.ui.uploads.AutoSavePostIfNotDraftResult.PostAutoSaveFailed; @@ -84,7 +84,7 @@ public class PostUploadHandler implements UploadHandler, OnAutoSavePo @Inject UploadActionUseCase mUploadActionUseCase; @Inject AutoSavePostIfNotDraftUseCase mAutoSavePostIfNotDraftUseCase; @Inject PostMediaHandler mPostMediaHandler; - @Inject SyncPublishingFeatureUtils mSyncPublishingFeatureUtils; + @Inject PostConflictResolutionFeatureUtils mPostConflictResolutionFeatureUtils; PostUploadHandler(PostUploadNotifier postUploadNotifier) { ((WordPress) WordPress.getContext().getApplicationContext()).component().inject(this); @@ -289,7 +289,7 @@ protected UploadPostTaskResult doInBackground(PostModel... posts) { AppLog.d(T.POSTS, "PostUploadHandler - UPLOAD. Post: " + mPost.getTitle()); mDispatcher.dispatch( PostActionBuilder.newPushPostAction( - mSyncPublishingFeatureUtils.getRemotePostPayloadForPush(payload) + mPostConflictResolutionFeatureUtils.getRemotePostPayloadForPush(payload) ) ); break; @@ -297,7 +297,7 @@ protected UploadPostTaskResult doInBackground(PostModel... posts) { mPost.setStatus(PostStatus.DRAFT.toString()); AppLog.d(T.POSTS, "PostUploadHandler - UPLOAD_AS_DRAFT. Post: " + mPost.getTitle()); mDispatcher.dispatch(PostActionBuilder.newPushPostAction( - mSyncPublishingFeatureUtils.getRemotePostPayloadForPush(payload) + mPostConflictResolutionFeatureUtils.getRemotePostPayloadForPush(payload) )); break; case REMOTE_AUTO_SAVE: @@ -646,7 +646,7 @@ public void handleAutoSavePostIfNotDraftResult(@NonNull AutoSavePostIfNotDraftRe post.setStatus(PostStatus.DRAFT.toString()); SiteModel site = mSiteStore.getSiteByLocalId(post.getLocalSiteId()); mDispatcher.dispatch(PostActionBuilder.newPushPostAction( - mSyncPublishingFeatureUtils.getRemotePostPayloadForPush(new RemotePostPayload(post, site)) + mPostConflictResolutionFeatureUtils.getRemotePostPayloadForPush(new RemotePostPayload(post, site)) )); } else { throw new IllegalStateException("All AutoSavePostIfNotDraftResult types must be handled"); diff --git a/WordPress/src/main/java/org/wordpress/android/util/DateTimeUtilsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/util/DateTimeUtilsWrapper.kt index 8e0a065963e5..63644be88497 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/DateTimeUtilsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/DateTimeUtilsWrapper.kt @@ -61,4 +61,6 @@ class DateTimeUtilsWrapper @Inject constructor( } fun getInstantNow(): Instant = Instant.now() + + fun timestampFromIso8601Millis(date: String) = DateTimeUtils.timestampFromIso8601Millis(date) } diff --git a/WordPress/src/main/java/org/wordpress/android/util/DateUtilsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/util/DateUtilsWrapper.kt new file mode 100644 index 000000000000..632cf28d0ce1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/DateUtilsWrapper.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.util + +import android.text.format.DateUtils +import org.wordpress.android.viewmodel.ContextProvider +import javax.inject.Inject + +class DateUtilsWrapper @Inject constructor( + private val contextProvider: ContextProvider +) { + fun formatDateTime(millis: Long, flags: Int) = DateUtils.formatDateTime(contextProvider.getContext(), millis, flags) +} diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/PostConflictResolutionFeatureConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/PostConflictResolutionFeatureConfig.kt new file mode 100644 index 000000000000..66d19018afa0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/config/PostConflictResolutionFeatureConfig.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.util.config + +import org.wordpress.android.BuildConfig +import org.wordpress.android.annotation.Feature +import javax.inject.Inject + +private const val POST_CONFLICT_RESOLUTION_FEATURE_REMOTE_FIELD = "sync_publishing" + +@Feature(POST_CONFLICT_RESOLUTION_FEATURE_REMOTE_FIELD, false) +class PostConflictResolutionFeatureConfig @Inject constructor( + appConfig: AppConfig +) : FeatureConfig( + appConfig, + BuildConfig.SYNC_PUBLISHING, + POST_CONFLICT_RESOLUTION_FEATURE_REMOTE_FIELD +) diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/SyncPublishingFeatureConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/SyncPublishingFeatureConfig.kt deleted file mode 100644 index e1a4580e3b20..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/util/config/SyncPublishingFeatureConfig.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.wordpress.android.util.config - -import org.wordpress.android.BuildConfig -import org.wordpress.android.annotation.Feature -import javax.inject.Inject - -private const val SYNC_PUBLISHING_FEATURE_REMOTE_FIELD = "sync_publishing" - -@Feature(SYNC_PUBLISHING_FEATURE_REMOTE_FIELD, false) -class SyncPublishingFeatureConfig @Inject constructor( - appConfig: AppConfig -) : FeatureConfig( - appConfig, - BuildConfig.SYNC_PUBLISHING, - SYNC_PUBLISHING_FEATURE_REMOTE_FIELD -) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 97f9754abc17..e5ee3486c3fb 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -377,6 +377,18 @@ Web version discarded Undo + + Resolve Conflict + The post was modified on another device. Please select the version of the post to keep. + The page was modified on another device. Please select the version of the page to keep. + Current Device + Another Device + Autosave Available + You\'ve made unsaved changes to this post from a different device. Please select the version of the post to keep. + You\'ve made unsaved changes to this page from a different device. Please select the version of the page to keep. + @string/dialog_post_conflict_current_device + @string/dialog_post_conflict_another_device + Which version would you like to edit? You recently made changes to this post but didn\'t save them. Choose a version to load:\n\n diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/SyncPublishingFeatureUtilsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostConflictResolutionFeatureUtilsTest.kt similarity index 58% rename from WordPress/src/test/java/org/wordpress/android/ui/posts/SyncPublishingFeatureUtilsTest.kt rename to WordPress/src/test/java/org/wordpress/android/ui/posts/PostConflictResolutionFeatureUtilsTest.kt index 56f249cbaa5e..70a8e68e2cac 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/SyncPublishingFeatureUtilsTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostConflictResolutionFeatureUtilsTest.kt @@ -11,39 +11,39 @@ import org.wordpress.android.BaseUnitTest import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload -import org.wordpress.android.util.config.SyncPublishingFeatureConfig +import org.wordpress.android.util.config.PostConflictResolutionFeatureConfig @ExperimentalCoroutinesApi -class SyncPublishingFeatureUtilsTest : BaseUnitTest() { +class PostConflictResolutionFeatureUtilsTest : BaseUnitTest() { @Mock - lateinit var syncPublishingFeatureConfig: SyncPublishingFeatureConfig + lateinit var mPostConflictResolutionFeatureConfig: PostConflictResolutionFeatureConfig private val site: SiteModel = mock() private val post: PostModel = mock() - private lateinit var syncPublishingFeatureUtils: SyncPublishingFeatureUtils + private lateinit var mPostConflictResolutionFeatureUtils: PostConflictResolutionFeatureUtils @Before fun setUp() { - syncPublishingFeatureUtils = SyncPublishingFeatureUtils(syncPublishingFeatureConfig) + mPostConflictResolutionFeatureUtils = PostConflictResolutionFeatureUtils(mPostConflictResolutionFeatureConfig) } @Test fun `given feature is enabled, when request for payload, then shouldSkipConflictResolution to false`() { - whenever(syncPublishingFeatureConfig.isEnabled()).thenReturn(true) + whenever(mPostConflictResolutionFeatureConfig.isEnabled()).thenReturn(true) val remotePostPayload = RemotePostPayload(post, site) - val result = syncPublishingFeatureUtils.getRemotePostPayloadForPush(remotePostPayload) + val result = mPostConflictResolutionFeatureUtils.getRemotePostPayloadForPush(remotePostPayload) assertThat(result.shouldSkipConflictResolutionCheck).isFalse } @Test fun `given feature is disabled, when request for payload, then sets shouldSkipConflictResolution to true`() { - whenever(syncPublishingFeatureConfig.isEnabled()).thenReturn(false) + whenever(mPostConflictResolutionFeatureConfig.isEnabled()).thenReturn(false) val remotePostPayload = RemotePostPayload(post, site) - val result = syncPublishingFeatureUtils.getRemotePostPayloadForPush(remotePostPayload) + val result = mPostConflictResolutionFeatureUtils.getRemotePostPayloadForPush(remotePostPayload) assertThat(result.shouldSkipConflictResolutionCheck).isTrue } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelCopyPostTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelCopyPostTest.kt index fa36266f0b57..d5ef3e1fc0c8 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelCopyPostTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelCopyPostTest.kt @@ -74,7 +74,7 @@ class PostListMainViewModelCopyPostTest : BaseUnitTest() { uploadStarter = mock(), uploadActionUseCase = mock(), savePostToDbUseCase = mock(), - syncPublishingFeatureUtils = mock() + postConflictResolutionFeatureUtils = mock() ) viewModel.postListAction.observeForever(onPostListActionObserver) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelTest.kt index e3750f0e56d1..f4c0f1d10fc5 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostListMainViewModelTest.kt @@ -67,7 +67,7 @@ class PostListMainViewModelTest : BaseUnitTest() { uploadStarter = uploadStarter, uploadActionUseCase = mock(), savePostToDbUseCase = savePostToDbUseCase, - syncPublishingFeatureUtils = mock() + postConflictResolutionFeatureUtils = mock() ) } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/PostResolutionOverlayAnalyticsTrackerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostResolutionOverlayAnalyticsTrackerTest.kt new file mode 100644 index 000000000000..54b47bd72f08 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostResolutionOverlayAnalyticsTrackerTest.kt @@ -0,0 +1,101 @@ +package org.wordpress.android.ui.posts + +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.mock +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.ui.posts.PostResolutionOverlayAnalyticsTracker.Companion.PROPERTY_CONFIRM_TYPE + +@RunWith(MockitoJUnitRunner::class) +class PostResolutionOverlayAnalyticsTrackerTest { + private val analyticsTracker: AnalyticsTrackerWrapper = mock() + lateinit var tracker: PostResolutionOverlayAnalyticsTracker + + @Before + fun setUp() { + tracker = PostResolutionOverlayAnalyticsTracker(analyticsTracker) + } + + @Test + fun `tracksScreenShown tracks correct event`() { + tracker.trackShown(PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + verify(analyticsTracker, times(1)).track( + eq(AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_SCREEN_SHOWN)) + tracker.trackShown(PostResolutionType.SYNC_CONFLICT) + verify(analyticsTracker, times(1)).track( + eq(AnalyticsTracker.Stat.RESOLVE_CONFLICT_SCREEN_SHOWN)) + } + + @Test + fun `tracksCancel tracks correct event`() { + tracker.trackCancel(PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + verify(analyticsTracker, times(1)).track( + eq(AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_CANCEL_TAPPED)) + tracker.trackCancel(PostResolutionType.SYNC_CONFLICT) + verify(analyticsTracker, times(1)).track( + eq(AnalyticsTracker.Stat.RESOLVE_CONFLICT_CANCEL_TAPPED)) + } + + @Test + fun `tracksClose tracks correct event`() { + tracker.trackClose(PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + verify(analyticsTracker, times(1)).track( + eq(AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_CLOSE_TAPPED)) + tracker.trackClose(PostResolutionType.SYNC_CONFLICT) + verify(analyticsTracker, times(1)).track( + eq(AnalyticsTracker.Stat.RESOLVE_CONFLICT_CLOSE_TAPPED)) + } + + @Test + fun `tracksDismiss tracks correct event`() { + tracker.trackDismissed(PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + verify(analyticsTracker, times(1)).track( + eq(AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_DISMISSED)) + tracker.trackDismissed(PostResolutionType.SYNC_CONFLICT) + verify(analyticsTracker, times(1)).track( + eq(AnalyticsTracker.Stat.RESOLVE_CONFLICT_DISMISSED)) + } + + @Test + fun `tracksConfirm tracks correct event and properties`() { + tracker.trackConfirm( + PostResolutionType.AUTOSAVE_REVISION_CONFLICT, + PostResolutionConfirmationType.CONFIRM_OTHER + ) + tracker.trackConfirm( + PostResolutionType.AUTOSAVE_REVISION_CONFLICT, + PostResolutionConfirmationType.CONFIRM_LOCAL + ) + mapCaptor().apply { + verify(analyticsTracker, times(2)).track( + eq(AnalyticsTracker.Stat.RESOLVE_AUTOSAVE_CONFLICT_CONFIRM_TAPPED), + capture() + ) + + Assertions.assertThat(firstValue).containsEntry(PROPERTY_CONFIRM_TYPE, "remote_version") + Assertions.assertThat(secondValue).containsEntry(PROPERTY_CONFIRM_TYPE, "local_version") + } + + tracker.trackConfirm(PostResolutionType.SYNC_CONFLICT, PostResolutionConfirmationType.CONFIRM_OTHER) + tracker.trackConfirm(PostResolutionType.SYNC_CONFLICT, PostResolutionConfirmationType.CONFIRM_LOCAL) + mapCaptor().apply { + verify(analyticsTracker, times(2)).track( + eq(AnalyticsTracker.Stat.RESOLVE_CONFLICT_CONFIRM_TAPPED), + capture() + ) + + Assertions.assertThat(firstValue).containsEntry(PROPERTY_CONFIRM_TYPE, "remote_version") + Assertions.assertThat(secondValue).containsEntry(PROPERTY_CONFIRM_TYPE, "local_version") + } + } + + private fun mapCaptor() = argumentCaptor>() +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/PostResolutionOverlayViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostResolutionOverlayViewModelTest.kt new file mode 100644 index 000000000000..7ac8150bd338 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostResolutionOverlayViewModelTest.kt @@ -0,0 +1,272 @@ +package org.wordpress.android.ui.posts + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.util.DateTimeUtilsWrapper +import org.wordpress.android.util.DateUtilsWrapper +import org.wordpress.android.ui.posts.PostResolutionOverlayActionEvent.PostResolutionConfirmationEvent +import org.wordpress.android.ui.utils.UiString + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class PostResolutionOverlayViewModelTest : BaseUnitTest() { + @Mock + lateinit var dateTimeUtilsWrapper: DateTimeUtilsWrapper + + @Mock + lateinit var dateUtilsWrapper: DateUtilsWrapper + + @Mock + lateinit var tracker: PostResolutionOverlayAnalyticsTracker + + lateinit var viewModel: PostResolutionOverlayViewModel + + private lateinit var dismissDialog: MutableList + private lateinit var uiStates: MutableList + private lateinit var triggerListener: MutableList + + private val postModel = PostModel().apply { + setIsPage(false) + setDateLocallyChanged("2024-05-24") + setLastModified("2024-05-25") + setRemoteLastModified("2024-04-12") + setAutoSaveModified("2025-01-10") + setId(1) + } + + private val selectedContentItem = ContentItem( + ContentItemType.LOCAL_DEVICE, + 1, + 1, + UiString.UiStringText("date"), + true) + + private val unSelectedContentItem = ContentItem( + ContentItemType.LOCAL_DEVICE, + 1, + 1, + UiString.UiStringText("date"), + false) + + @Before + fun setUp() = test { + viewModel = PostResolutionOverlayViewModel(dateTimeUtilsWrapper, dateUtilsWrapper, tracker) + dismissDialog = mutableListOf() + uiStates = mutableListOf() + triggerListener = mutableListOf() + launch(testDispatcher()) { + viewModel.dismissDialog.observeForever { + dismissDialog.add(it) + } + + viewModel.uiState.observeForever { + uiStates.add(it) + } + + viewModel.triggerListeners.observeForever { + triggerListener.add(it) + } + } + whenever(dateTimeUtilsWrapper.timestampFromIso8601Millis(any())).thenReturn(1) + whenever(dateUtilsWrapper.formatDateTime(any(), any())).thenReturn("Monday, Dec 24 at 10:25") + } + + @Test + fun `given post model is null, when start, then dialog dismiss event is posted`() { + viewModel.start(null, PostResolutionType.SYNC_CONFLICT) + + assertThat(dismissDialog.last()).isTrue() + } + + @Test + fun `given post resolution type is null, when start, then dialog dismiss event is posted`() { + viewModel.start(PostModel(), null) + + assertThat(dismissDialog.last()).isTrue() + } + + @Test + fun `given sync conflict request, when start for SYNC_CONFLICT, then uiState is built`() { + viewModel.start(postModel, PostResolutionType.SYNC_CONFLICT) + + val uiState = uiStates.last() + assertThat(uiState).isNotNull + assertThat(uiState.content).isNotNull + assertThat(uiState.content.size).isEqualTo(2) + } + + @Test + fun `given sync conflict request, when start for AUTOSAVE_REVISION_CONFLICT, then uiState is built`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + assertThat(uiState).isNotNull + assertThat(uiState.content).isNotNull + assertThat(uiState.content.size).isEqualTo(2) + } + + @Test + fun `when on close click, then dialog dismiss event is posted`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.closeClick.invoke() + + assertThat(dismissDialog.last()).isTrue() + } + + @Test + fun `when on close click, then close is tracked`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.closeClick.invoke() + + verify(tracker, times(1)).trackClose(any()) + } + + @Test + fun `when on cancel click, then dialog dismiss event is posted`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.cancelClick.invoke() + + assertThat(dismissDialog.last()).isTrue() + } + + @Test + fun `when on cancel click, then cancel is tracked`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.cancelClick.invoke() + + verify(tracker, times(1)).trackCancel(any()) + } + + @Test + fun `when on dialog dismissed click, then dialog dismiss is tracked`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + viewModel.onDialogDismissed() + + verify(tracker, times(1)).trackDismissed(any()) + } + + @Test + fun `when on confirm click, then dialog dismiss event is posted`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.confirmClick.invoke() + + assertThat(dismissDialog.last()).isTrue() + } + + @Test + fun `when on confirm click, then confirm is tracked`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.onSelected.invoke(selectedContentItem) + uiState.confirmClick.invoke() + + verify(tracker, times(1)).trackConfirm(any(), any()) + } + + @Test + fun `when item is selected, then uiState is update with selectedContentItem`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + assertThat(uiState.selectedContentItem).isNull() + + uiState.onSelected.invoke(selectedContentItem) + + val selectedContentItem = uiStates.last().selectedContentItem + assertThat(selectedContentItem).isNotNull + } + + @Test + fun `given no selected item, when on confirm click, then no events are posted to trigger listeners`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.confirmClick.invoke() + + assertThat(triggerListener).isEmpty() + } + + @Test + fun `given selected item, when on confirm click, then event is posted to trigger listeners`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.onSelected.invoke(selectedContentItem) + uiState.confirmClick.invoke() + + assertThat(triggerListener.last()).isInstanceOf(PostResolutionConfirmationEvent::class.java) + } + + + @Test + fun `given autosave revision conflict, when on confirm click, then event posted is for autosave`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + uiState.onSelected.invoke(selectedContentItem) + uiState.confirmClick.invoke() + + val event = triggerListener.last() + assertThat(event).isInstanceOf(PostResolutionConfirmationEvent::class.java) + assertThat(event.postResolutionType).isEqualTo(PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + } + + @Test + fun `given sync conflict, when on confirm click, then event posted is for sync conflict`() { + viewModel.start(postModel, PostResolutionType.SYNC_CONFLICT) + + val uiState = uiStates.last() + uiState.onSelected.invoke(selectedContentItem) + uiState.confirmClick.invoke() + + val event = triggerListener.last() + assertThat(event).isInstanceOf(PostResolutionConfirmationEvent::class.java) + assertThat(event.postResolutionType).isEqualTo(PostResolutionType.SYNC_CONFLICT) + } + + @Test + fun `when item is selected, then uiState actionEnabled is updated to true`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + assertThat(uiState.actionEnabled).isFalse() + uiState.onSelected.invoke(selectedContentItem) + + assertThat(uiStates.last().actionEnabled).isTrue() + } + + @Test + fun `when item is deselected, then uiState actionEnabled is updated to false`() { + viewModel.start(postModel, PostResolutionType.AUTOSAVE_REVISION_CONFLICT) + + val uiState = uiStates.last() + assertThat(uiState.actionEnabled).isFalse() + uiState.onSelected.invoke(unSelectedContentItem) + + assertThat(uiStates.last().actionEnabled).isFalse() + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/StorePostViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/StorePostViewModelTest.kt index b758459e4000..391f9c005f4a 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/StorePostViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/StorePostViewModelTest.kt @@ -32,7 +32,7 @@ import org.wordpress.android.ui.posts.editor.StorePostViewModel.UpdateFromEditor import org.wordpress.android.ui.posts.editor.StorePostViewModel.UpdateFromEditor.PostFields import org.wordpress.android.ui.uploads.UploadServiceFacade import org.wordpress.android.util.NetworkUtilsWrapper -import org.wordpress.android.util.config.SyncPublishingFeatureConfig +import org.wordpress.android.util.config.PostConflictResolutionFeatureConfig import org.wordpress.android.viewmodel.Event @ExperimentalCoroutinesApi @@ -65,7 +65,7 @@ class StorePostViewModelTest : BaseUnitTest() { lateinit var postFreshnessChecker: IPostFreshnessChecker @Mock - lateinit var syncPublishingFeatureConfig: SyncPublishingFeatureConfig + lateinit var mPostConflictResolutionFeatureConfig: PostConflictResolutionFeatureConfig private lateinit var viewModel: StorePostViewModel private val title = "title" @@ -89,7 +89,7 @@ class StorePostViewModelTest : BaseUnitTest() { networkUtils, dispatcher, postFreshnessChecker, - syncPublishingFeatureConfig + mPostConflictResolutionFeatureConfig ) postModel.setId(postId) postModel.setTitle(title) diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java index 18b1d3e6e045..aecd05989883 100644 --- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java +++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java @@ -1107,8 +1107,17 @@ public enum Stat { NOTIFICATION_MENU_TAPPED, NOTIFICATIONS_MARK_ALL_READ_TAPPED, NOTIFICATIONS_INLINE_ACTION_TAPPED, - WEBVIEW_TOO_LARGE_PAYLOAD_ERROR; - + WEBVIEW_TOO_LARGE_PAYLOAD_ERROR, + RESOLVE_CONFLICT_SCREEN_SHOWN, + RESOLVE_CONFLICT_CONFIRM_TAPPED, + RESOLVE_CONFLICT_CANCEL_TAPPED, + RESOLVE_CONFLICT_CLOSE_TAPPED, + RESOLVE_CONFLICT_DISMISSED, + RESOLVE_AUTOSAVE_CONFLICT_SCREEN_SHOWN, + RESOLVE_AUTOSAVE_CONFLICT_CONFIRM_TAPPED, + RESOLVE_AUTOSAVE_CONFLICT_CANCEL_TAPPED, + RESOLVE_AUTOSAVE_CONFLICT_CLOSE_TAPPED, + RESOLVE_AUTOSAVE_CONFLICT_DISMISSED; /* * Please set the event name in the enum only if the new Stat's name in lower case does not match it. * In that case you also need to add the event in the `AnalyticsTrackerNosaraTest.specialNames` map.